From f84a41dcb881c72adb5fccace6848a4a6d6e33dd Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Tue, 17 Mar 2026 15:37:55 +0100 Subject: [PATCH 001/393] fix(security): block JVM, Python, and .NET env injection vectors in host exec sandbox (#49025) Add JAVA_TOOL_OPTIONS, _JAVA_OPTIONS, JDK_JAVA_OPTIONS, PYTHONBREAKPOINT, and DOTNET_STARTUP_HOOKS to blockedKeys in the host exec security policy. Closes #22681 --- CHANGELOG.md | 1 + .../OpenClaw/HostEnvSecurityPolicy.generated.swift | 7 ++++++- src/infra/host-env-security-policy.json | 7 ++++++- src/infra/host-env-security.test.ts | 10 ++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd6db6fdaf..1dd11adfede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -442,6 +442,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows. - macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint. - Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322. +- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025) ## 2026.3.8 diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 932c9fc5e61..ecdbdd0d77c 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -23,7 +23,12 @@ enum HostEnvSecurityPolicy { "PS4", "GCONV_PATH", "IFS", - "SSLKEYLOGFILE" + "SSLKEYLOGFILE", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", + "JDK_JAVA_OPTIONS", + "PYTHONBREAKPOINT", + "DOTNET_STARTUP_HOOKS" ] static let blockedOverrideKeys: Set = [ diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 9e3ad27581e..bf99f458e58 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -17,7 +17,12 @@ "PS4", "GCONV_PATH", "IFS", - "SSLKEYLOGFILE" + "SSLKEYLOGFILE", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", + "JDK_JAVA_OPTIONS", + "PYTHONBREAKPOINT", + "DOTNET_STARTUP_HOOKS" ], "blockedOverrideKeys": [ "HOME", diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index acb756b62a2..fe194eabc28 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -48,6 +48,16 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true); expect(isDangerousHostEnvVarName("ld_preload")).toBe(true); expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true); + expect(isDangerousHostEnvVarName("JAVA_TOOL_OPTIONS")).toBe(true); + expect(isDangerousHostEnvVarName("java_tool_options")).toBe(true); + expect(isDangerousHostEnvVarName("_JAVA_OPTIONS")).toBe(true); + expect(isDangerousHostEnvVarName("_java_options")).toBe(true); + expect(isDangerousHostEnvVarName("JDK_JAVA_OPTIONS")).toBe(true); + expect(isDangerousHostEnvVarName("jdk_java_options")).toBe(true); + expect(isDangerousHostEnvVarName("PYTHONBREAKPOINT")).toBe(true); + expect(isDangerousHostEnvVarName("pythonbreakpoint")).toBe(true); + expect(isDangerousHostEnvVarName("DOTNET_STARTUP_HOOKS")).toBe(true); + expect(isDangerousHostEnvVarName("dotnet_startup_hooks")).toBe(true); expect(isDangerousHostEnvVarName("PATH")).toBe(false); expect(isDangerousHostEnvVarName("FOO")).toBe(false); }); From 7cd0acf8af989be11695d0a5a864d46815619ded Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:53:51 -0500 Subject: [PATCH 002/393] CI: rename startup memory smoke (#49041) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9316afb6d09..9271c5f5a1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -304,8 +304,8 @@ jobs: - name: Enforce safe external URL opening policy run: pnpm lint:ui:no-raw-window-open - startup-memory: - name: "startup-memory" + build-smoke: + name: "build-smoke" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 From f036ed27f427f53d3888f63bc09b77913f0f8927 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Tue, 17 Mar 2026 10:55:55 -0400 Subject: [PATCH 003/393] CI: guard gateway watch against duplicate runtime regressions (#49048) --- .github/workflows/ci.yml | 28 ++ package.json | 1 + scripts/check-gateway-watch-regression.mjs | 464 +++++++++++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 scripts/check-gateway-watch-regression.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9271c5f5a1b..ce1299a5d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,6 +333,34 @@ jobs: - name: Check CLI startup memory run: pnpm test:startup:memory + gateway-watch-regression: + name: "gateway-watch-regression" + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Run gateway watch regression harness + run: pnpm test:gateway:watch-regression + + - name: Upload gateway watch regression artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: gateway-watch-regression + path: .local/gateway-watch-regression/ + retention-days: 7 + # Validate docs (format, lint, broken links) only when docs files changed. check-docs: needs: [docs-scope] diff --git a/package.json b/package.json index c6ed4cab402..95a240b0892 100644 --- a/package.json +++ b/package.json @@ -553,6 +553,7 @@ "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", "test:gateway": "vitest run --config vitest.gateway.config.ts --pool=forks", + "test:gateway:watch-regression": "node scripts/check-gateway-watch-regression.mjs", "test:install:e2e": "bash scripts/test-install-sh-e2e-docker.sh", "test:install:e2e:anthropic": "OPENCLAW_E2E_MODELS=anthropic CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh", "test:install:e2e:openai": "OPENCLAW_E2E_MODELS=openai CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh", diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs new file mode 100644 index 00000000000..238bc68e742 --- /dev/null +++ b/scripts/check-gateway-watch-regression.mjs @@ -0,0 +1,464 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const DEFAULTS = { + outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"), + windowMs: 10_000, + sigkillGraceMs: 10_000, + cpuWarnMs: 1_000, + cpuFailMs: 8_000, + distRuntimeFileGrowthMax: 200, + distRuntimeByteGrowthMax: 2 * 1024 * 1024, + keepLogs: true, + skipBuild: false, +}; + +function parseArgs(argv) { + const options = { ...DEFAULTS }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + const readValue = () => { + if (!next) { + throw new Error(`Missing value for ${arg}`); + } + i += 1; + return next; + }; + switch (arg) { + case "--output-dir": + options.outputDir = path.resolve(readValue()); + break; + case "--window-ms": + options.windowMs = Number(readValue()); + break; + case "--sigkill-grace-ms": + options.sigkillGraceMs = Number(readValue()); + break; + case "--cpu-warn-ms": + options.cpuWarnMs = Number(readValue()); + break; + case "--cpu-fail-ms": + options.cpuFailMs = Number(readValue()); + break; + case "--dist-runtime-file-growth-max": + options.distRuntimeFileGrowthMax = Number(readValue()); + break; + case "--dist-runtime-byte-growth-max": + options.distRuntimeByteGrowthMax = Number(readValue()); + break; + case "--skip-build": + options.skipBuild = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function normalizePath(filePath) { + return filePath.replaceAll("\\", "/"); +} + +function listTreeEntries(rootName) { + const rootPath = path.join(process.cwd(), rootName); + if (!fs.existsSync(rootPath)) { + return [`${rootName} (missing)`]; + } + + const entries = [rootName]; + const queue = [rootPath]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current) { + continue; + } + const dirents = fs.readdirSync(current, { withFileTypes: true }); + for (const dirent of dirents) { + const fullPath = path.join(current, dirent.name); + const relativePath = normalizePath(path.relative(process.cwd(), fullPath)); + entries.push(relativePath); + if (dirent.isDirectory()) { + queue.push(fullPath); + } + } + } + return entries.toSorted((a, b) => a.localeCompare(b)); +} + +function humanBytes(bytes) { + if (bytes < 1024) { + return `${bytes}B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}K`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)}M`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`; +} + +function snapshotTree(rootName) { + const rootPath = path.join(process.cwd(), rootName); + const stats = { + exists: fs.existsSync(rootPath), + files: 0, + directories: 0, + symlinks: 0, + entries: 0, + apparentBytes: 0, + }; + + if (!stats.exists) { + return stats; + } + + const queue = [rootPath]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current) { + continue; + } + const currentStats = fs.lstatSync(current); + stats.entries += 1; + if (currentStats.isDirectory()) { + stats.directories += 1; + for (const dirent of fs.readdirSync(current, { withFileTypes: true })) { + queue.push(path.join(current, dirent.name)); + } + continue; + } + if (currentStats.isSymbolicLink()) { + stats.symlinks += 1; + continue; + } + if (currentStats.isFile()) { + stats.files += 1; + stats.apparentBytes += currentStats.size; + } + } + + return stats; +} + +function writeSnapshot(snapshotDir) { + ensureDir(snapshotDir); + const pathEntries = [...listTreeEntries("dist"), ...listTreeEntries("dist-runtime")]; + fs.writeFileSync(path.join(snapshotDir, "paths.txt"), `${pathEntries.join("\n")}\n`, "utf8"); + + const dist = snapshotTree("dist"); + const distRuntime = snapshotTree("dist-runtime"); + const snapshot = { + generatedAt: new Date().toISOString(), + dist, + distRuntime, + }; + fs.writeFileSync( + path.join(snapshotDir, "snapshot.json"), + `${JSON.stringify(snapshot, null, 2)}\n`, + ); + fs.writeFileSync( + path.join(snapshotDir, "stats.txt"), + [ + `generated_at: ${snapshot.generatedAt}`, + "", + "[dist]", + `files: ${dist.files}`, + `directories: ${dist.directories}`, + `symlinks: ${dist.symlinks}`, + `entries: ${dist.entries}`, + `apparent_bytes: ${dist.apparentBytes}`, + `apparent_human: ${humanBytes(dist.apparentBytes)}`, + "", + "[dist-runtime]", + `files: ${distRuntime.files}`, + `directories: ${distRuntime.directories}`, + `symlinks: ${distRuntime.symlinks}`, + `entries: ${distRuntime.entries}`, + `apparent_bytes: ${distRuntime.apparentBytes}`, + `apparent_human: ${humanBytes(distRuntime.apparentBytes)}`, + "", + ].join("\n"), + "utf8", + ); + return snapshot; +} + +function runCheckedCommand(command, args) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + stdio: "inherit", + env: process.env, + }); + if (typeof result.status === "number" && result.status === 0) { + return; + } + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir) { + const shellSource = [ + 'echo "$$" > "$OPENCLAW_WATCH_PID_FILE"', + "exec node scripts/watch-node.mjs gateway --force --allow-unconfigured", + ].join("\n"); + const env = { + OPENCLAW_WATCH_PID_FILE: pidFilePath, + HOME: isolatedHomeDir, + OPENCLAW_HOME: isolatedHomeDir, + }; + + if (process.platform === "darwin") { + return { + command: "/usr/bin/time", + args: ["-lp", "-o", timeFilePath, "/bin/sh", "-lc", shellSource], + env, + }; + } + + return { + command: "/usr/bin/time", + args: [ + "-f", + "__TIMING__ user=%U sys=%S elapsed=%e", + "-o", + timeFilePath, + "/bin/sh", + "-lc", + shellSource, + ], + env, + }; +} + +function parseTimingFile(timeFilePath) { + const text = fs.readFileSync(timeFilePath, "utf8"); + if (process.platform === "darwin") { + const user = Number(text.match(/^user\s+([0-9.]+)/m)?.[1] ?? "NaN"); + const sys = Number(text.match(/^sys\s+([0-9.]+)/m)?.[1] ?? "NaN"); + const elapsed = Number(text.match(/^real\s+([0-9.]+)/m)?.[1] ?? "NaN"); + return { + userSeconds: user, + sysSeconds: sys, + elapsedSeconds: elapsed, + }; + } + + const match = text.match(/__TIMING__ user=([0-9.]+) sys=([0-9.]+) elapsed=([0-9.]+)/); + return { + userSeconds: Number(match?.[1] ?? "NaN"), + sysSeconds: Number(match?.[2] ?? "NaN"), + elapsedSeconds: Number(match?.[3] ?? "NaN"), + }; +} + +async function runTimedWatch(options, outputDir) { + const pidFilePath = path.join(outputDir, "watch.pid"); + const timeFilePath = path.join(outputDir, "watch.time.log"); + const isolatedHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-")); + fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8"); + const stdoutPath = path.join(outputDir, "watch.stdout.log"); + const stderrPath = path.join(outputDir, "watch.stderr.log"); + const { command, args, env } = buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir); + const child = spawn(command, args, { + cwd: process.cwd(), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + + const exitPromise = new Promise((resolve) => { + child.on("exit", (code, signal) => resolve({ code, signal })); + }); + + let watchPid = null; + for (let attempt = 0; attempt < 50; attempt += 1) { + if (fs.existsSync(pidFilePath)) { + watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim()); + break; + } + await sleep(100); + } + + await sleep(options.windowMs); + + if (watchPid) { + try { + process.kill(watchPid, "SIGTERM"); + } catch { + // ignore + } + } + + const gracefulExit = await Promise.race([ + exitPromise, + sleep(options.sigkillGraceMs).then(() => null), + ]); + + if (gracefulExit === null) { + if (watchPid) { + try { + process.kill(watchPid, "SIGKILL"); + } catch { + // ignore + } + } + } + + const exit = (await exitPromise) ?? { code: null, signal: null }; + fs.writeFileSync(stdoutPath, stdout, "utf8"); + fs.writeFileSync(stderrPath, stderr, "utf8"); + const timing = fs.existsSync(timeFilePath) + ? parseTimingFile(timeFilePath) + : { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }; + + return { + exit, + timing, + stdoutPath, + stderrPath, + timeFilePath, + }; +} + +function parsePathFile(filePath) { + return fs + .readFileSync(filePath, "utf8") + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean); +} + +function writeDiffArtifacts(outputDir, preDir, postDir) { + const diffDir = path.join(outputDir, "diff"); + ensureDir(diffDir); + const prePaths = parsePathFile(path.join(preDir, "paths.txt")); + const postPaths = parsePathFile(path.join(postDir, "paths.txt")); + const preSet = new Set(prePaths); + const postSet = new Set(postPaths); + const added = postPaths.filter((entry) => !preSet.has(entry)); + const removed = prePaths.filter((entry) => !postSet.has(entry)); + + fs.writeFileSync(path.join(diffDir, "added-paths.txt"), `${added.join("\n")}\n`, "utf8"); + fs.writeFileSync(path.join(diffDir, "removed-paths.txt"), `${removed.join("\n")}\n`, "utf8"); + return { added, removed }; +} + +function fail(message) { + console.error(`FAIL: ${message}`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + ensureDir(options.outputDir); + if (!options.skipBuild) { + runCheckedCommand("pnpm", ["build"]); + } + + const preDir = path.join(options.outputDir, "pre"); + const pre = writeSnapshot(preDir); + + const watchDir = path.join(options.outputDir, "watch"); + ensureDir(watchDir); + const watchResult = await runTimedWatch(options, watchDir); + + const postDir = path.join(options.outputDir, "post"); + const post = writeSnapshot(postDir); + const diff = writeDiffArtifacts(options.outputDir, preDir, postDir); + + const distRuntimeFileGrowth = post.distRuntime.files - pre.distRuntime.files; + const distRuntimeByteGrowth = post.distRuntime.apparentBytes - pre.distRuntime.apparentBytes; + const distRuntimeAddedPaths = diff.added.filter((entry) => + entry.startsWith("dist-runtime/"), + ).length; + const cpuMs = Math.round((watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000); + const watchTriggeredBuild = + fs + .readFileSync(watchResult.stderrPath, "utf8") + .includes("Building TypeScript (dist is stale).") || + fs + .readFileSync(watchResult.stdoutPath, "utf8") + .includes("Building TypeScript (dist is stale)."); + + const summary = { + windowMs: options.windowMs, + watchTriggeredBuild, + cpuMs, + cpuWarnMs: options.cpuWarnMs, + cpuFailMs: options.cpuFailMs, + distRuntimeFileGrowth, + distRuntimeFileGrowthMax: options.distRuntimeFileGrowthMax, + distRuntimeByteGrowth, + distRuntimeByteGrowthMax: options.distRuntimeByteGrowthMax, + distRuntimeAddedPaths, + addedPaths: diff.added.length, + removedPaths: diff.removed.length, + watchExit: watchResult.exit, + timing: watchResult.timing, + }; + fs.writeFileSync( + path.join(options.outputDir, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + + console.log(JSON.stringify(summary, null, 2)); + + const failures = []; + if (distRuntimeFileGrowth > options.distRuntimeFileGrowthMax) { + failures.push( + `dist-runtime file growth ${distRuntimeFileGrowth} exceeded max ${options.distRuntimeFileGrowthMax}`, + ); + } + if (distRuntimeByteGrowth > options.distRuntimeByteGrowthMax) { + failures.push( + `dist-runtime apparent byte growth ${distRuntimeByteGrowth} exceeded max ${options.distRuntimeByteGrowthMax}`, + ); + } + if (!Number.isFinite(cpuMs)) { + failures.push("failed to parse CPU timing from the bounded gateway:watch run"); + } else if (cpuMs > options.cpuFailMs) { + failures.push( + `LOUD ALARM: gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above loud-alarm threshold ${options.cpuFailMs}ms`, + ); + } else if (cpuMs > options.cpuWarnMs) { + failures.push( + `gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above target ${options.cpuWarnMs}ms`, + ); + } + + if (failures.length > 0) { + for (const message of failures) { + fail(message); + } + fail( + "Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.", + ); + process.exit(1); + } + + process.exit(0); +} + +await main(); From 4f6955fb1166de18ee7c7d00f13f890acc3e27e1 Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Tue, 17 Mar 2026 17:30:37 +0200 Subject: [PATCH 004/393] fix(hooks): pass sessionFile and sessionKey in after_compaction hook (#40781) Merged via squash. Prepared head SHA: 11e85f865148f6c6216aaf00fc5b0ef78238070a Co-authored-by: jarimustonen <1272053+jarimustonen@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + .../pi-embedded-runner/compact.hooks.test.ts | 1 + src/agents/pi-embedded-runner/compact.ts | 1 + ...-embedded-subscribe.handlers.compaction.ts | 3 ++- src/plugins/wired-hooks-compaction.test.ts | 20 ++++++++++++++++--- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd11adfede..661dac6d613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,7 @@ Docs: https://docs.openclaw.ai - Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) - Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy. - Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar. +- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen. ## 2026.3.12 diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 54ad50539e3..72b16ad003f 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -529,6 +529,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { messageCount: 1, tokenCount: 10, compactedCount: 1, + sessionFile: "/tmp/session.jsonl", }, expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }), ); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 98a3b438d21..4e967730667 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -1039,6 +1039,7 @@ export async function compactEmbeddedPiSessionDirect( messageCount: messageCountAfter, tokenCount: tokensAfter, compactedCount, + sessionFile: params.sessionFile, }, { sessionId: params.sessionId, diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index 7b9c4499eff..f0717f140cf 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -80,8 +80,9 @@ export function handleAutoCompactionEnd( { messageCount: ctx.params.session.messages?.length ?? 0, compactedCount: ctx.getCompactionCount(), + sessionFile: ctx.params.session.sessionFile, }, - {}, + { sessionKey: ctx.params.sessionKey }, ) .catch((err) => { ctx.log.warn(`after_compaction hook failed: ${String(err)}`); diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index f8ce4d0a668..1fc258d4cef 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -39,11 +39,20 @@ describe("compaction hook wiring", () => { function createCompactionEndCtx(params: { runId: string; messages?: unknown[]; + sessionFile?: string; + sessionKey?: string; compactionCount?: number; withRetryHooks?: boolean; }) { return { - params: { runId: params.runId, session: { messages: params.messages ?? [] } }, + params: { + runId: params.runId, + sessionKey: params.sessionKey, + session: { + messages: params.messages ?? [], + sessionFile: params.sessionFile, + }, + }, state: { compactionInFlight: true }, log: { debug: vi.fn(), warn: vi.fn() }, maybeResolveCompactionWait: vi.fn(), @@ -107,6 +116,8 @@ describe("compaction hook wiring", () => { const ctx = createCompactionEndCtx({ runId: "r2", messages: [1, 2], + sessionFile: "/tmp/session.jsonl", + sessionKey: "agent:main:web-xyz", compactionCount: 1, }); @@ -122,13 +133,16 @@ describe("compaction hook wiring", () => { expect(hookMocks.runner.runAfterCompaction).toHaveBeenCalledTimes(1); const afterCalls = hookMocks.runner.runAfterCompaction.mock.calls as unknown as Array< - [unknown] + [unknown, unknown] >; const event = afterCalls[0]?.[0] as - | { messageCount?: number; compactedCount?: number } + | { messageCount?: number; compactedCount?: number; sessionFile?: string } | undefined; expect(event?.messageCount).toBe(2); expect(event?.compactedCount).toBe(1); + expect(event?.sessionFile).toBe("/tmp/session.jsonl"); + const hookCtx = afterCalls[0]?.[1] as { sessionKey?: string } | undefined; + expect(hookCtx?.sessionKey).toBe("agent:main:web-xyz"); expect(ctx.incrementCompactionCount).toHaveBeenCalledTimes(1); expect(ctx.maybeResolveCompactionWait).toHaveBeenCalledTimes(1); expect(hookMocks.emitAgentEvent).toHaveBeenCalledWith({ From 795f1f438b1dc881c194b23bdfeb7c855b5b7566 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:37:02 -0700 Subject: [PATCH 005/393] refactor: expose lazy runtime helper to plugins --- extensions/bluebubbles/src/actions.ts | 2 +- extensions/bluebubbles/src/channel.ts | 2 +- extensions/feishu/src/channel.ts | 2 +- extensions/googlechat/src/channel.ts | 2 +- extensions/matrix/src/channel.ts | 2 +- extensions/msteams/src/channel.ts | 2 +- extensions/zalo/src/actions.ts | 2 +- scripts/lib/plugin-sdk-entrypoints.json | 1 + src/plugin-sdk/lazy-runtime.ts | 5 + src/plugins/contracts/auth.contract.test.ts | 2 +- src/plugins/runtime/runtime-discord.ts | 158 +++++++------------- src/plugins/runtime/runtime-slack.ts | 76 ++++------ src/plugins/runtime/runtime-telegram.ts | 108 ++++--------- src/plugins/runtime/runtime-whatsapp.ts | 37 ++--- src/shared/lazy-runtime.ts | 8 + 15 files changed, 147 insertions(+), 262 deletions(-) create mode 100644 src/plugin-sdk/lazy-runtime.ts diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 47eedf97511..bd797f5ee53 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -11,7 +11,7 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, } from "openclaw/plugin-sdk/bluebubbles"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; import { normalizeSecretInputString } from "./secret-input.js"; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index f3f3cdd7eb3..5343fb501a2 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -18,7 +18,7 @@ import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, type ResolvedBlueBubblesAccount, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index c2df79e0028..ace8592497d 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -12,7 +12,7 @@ import { PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/feishu"; import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveFeishuAccount, resolveFeishuCredentials, diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 84715321ce8..ffccbb9bbac 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -27,7 +27,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/googlechat"; import { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listGoogleChatAccountIds, diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 03007151d18..777017dcbf8 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -7,6 +7,7 @@ import { buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; import { buildChannelConfigSchema, buildProbeChannelStatusSummary, @@ -15,7 +16,6 @@ import { PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index e337566e483..7400f61e819 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,5 +1,6 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, @@ -14,7 +15,6 @@ import { MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import type { ProbeMSTeamsResult } from "./probe.js"; import { diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index b492a57a6dc..67b6f42b8a7 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -1,10 +1,10 @@ +import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, OpenClawConfig, } from "openclaw/plugin-sdk/zalo"; import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { listEnabledZaloAccounts } from "./accounts.js"; type ZaloActionsRuntime = typeof import("./actions.runtime.js").zaloActionsRuntime; diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 6e41a759867..cad41b15fca 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -47,6 +47,7 @@ "irc", "llm-task", "lobster", + "lazy-runtime", "matrix", "mattermost", "memory-core", diff --git a/src/plugin-sdk/lazy-runtime.ts b/src/plugin-sdk/lazy-runtime.ts new file mode 100644 index 00000000000..ef8d7039373 --- /dev/null +++ b/src/plugin-sdk/lazy-runtime.ts @@ -0,0 +1,5 @@ +export { + createLazyRuntimeMethod, + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../shared/lazy-runtime.js"; diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index 40a82482edd..355ceb43962 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -20,7 +20,7 @@ type LoginQwenPortalOAuth = type GithubCopilotLoginCommand = (typeof import("openclaw/plugin-sdk/provider-auth"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = - (typeof import("../../commands/oauth-flow.js"))["createVpsAwareOAuthHandlers"]; + (typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 4878bff3d81..6203fa6c2d8 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -9,121 +9,71 @@ import { setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } from "../../../extensions/discord/src/monitor/thread-bindings.js"; -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import { createDiscordTypingLease } from "./runtime-discord-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; -type RuntimeDiscordOps = typeof import("./runtime-discord-ops.runtime.js").runtimeDiscordOps; - const loadRuntimeDiscordOps = createLazyRuntimeSurface( () => import("./runtime-discord-ops.runtime.js"), ({ runtimeDiscordOps }) => runtimeDiscordOps, ); -const auditChannelPermissionsLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.auditChannelPermissions); +const bindDiscordRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeDiscordOps); -const listDirectoryGroupsLiveLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryGroupsLive); - -const listDirectoryPeersLiveLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryPeersLive); - -const probeDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.probeDiscord); - -const resolveChannelAllowlistLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.resolveChannelAllowlist); - -const resolveUserAllowlistLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.resolveUserAllowlist); - -const sendComponentMessageLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendComponentMessage); - -const sendMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendMessageDiscord); - -const sendPollDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendPollDiscord); - -const monitorDiscordProviderLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.monitorDiscordProvider); - -const sendTypingDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.typing.pulse); - -const editMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editMessage); - -const deleteMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->( - loadRuntimeDiscordOps, +const auditChannelPermissionsLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.auditChannelPermissions, +); +const listDirectoryGroupsLiveLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryGroupsLive, +); +const listDirectoryPeersLiveLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryPeersLive, +); +const probeDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.probeDiscord, +); +const resolveChannelAllowlistLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.resolveChannelAllowlist, +); +const resolveUserAllowlistLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.resolveUserAllowlist, +); +const sendComponentMessageLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.sendComponentMessage, +); +const sendMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.sendMessageDiscord, +); +const sendPollDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.sendPollDiscord, +); +const monitorDiscordProviderLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.monitorDiscordProvider, +); +const sendTypingDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.typing.pulse, +); +const editMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editMessage, +); +const deleteMessageDiscordLazy = bindDiscordRuntimeMethod( (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.deleteMessage, ); - -const pinMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.pinMessage); - -const unpinMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.unpinMessage); - -const createThreadDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.createThread); - -const editChannelDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editChannel); +const pinMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.pinMessage, +); +const unpinMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.unpinMessage, +); +const createThreadDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.createThread, +); +const editChannelDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editChannel, +); export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { return { diff --git a/src/plugins/runtime/runtime-slack.ts b/src/plugins/runtime/runtime-slack.ts index 30742195ad6..9f1cab0f094 100644 --- a/src/plugins/runtime/runtime-slack.ts +++ b/src/plugins/runtime/runtime-slack.ts @@ -1,60 +1,38 @@ -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; -type RuntimeSlackOps = typeof import("./runtime-slack-ops.runtime.js").runtimeSlackOps; - const loadRuntimeSlackOps = createLazyRuntimeSurface( () => import("./runtime-slack-ops.runtime.js"), ({ runtimeSlackOps }) => runtimeSlackOps, ); -const listDirectoryGroupsLiveLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.listDirectoryGroupsLive); +const bindSlackRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeSlackOps); -const listDirectoryPeersLiveLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.listDirectoryPeersLive); - -const probeSlackLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.probeSlack); - -const resolveChannelAllowlistLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.resolveChannelAllowlist); - -const resolveUserAllowlistLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.resolveUserAllowlist); - -const sendMessageSlackLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.sendMessageSlack); - -const monitorSlackProviderLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.monitorSlackProvider); - -const handleSlackActionLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.handleSlackAction); +const listDirectoryGroupsLiveLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.listDirectoryGroupsLive, +); +const listDirectoryPeersLiveLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.listDirectoryPeersLive, +); +const probeSlackLazy = bindSlackRuntimeMethod((runtimeSlackOps) => runtimeSlackOps.probeSlack); +const resolveChannelAllowlistLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.resolveChannelAllowlist, +); +const resolveUserAllowlistLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.resolveUserAllowlist, +); +const sendMessageSlackLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.sendMessageSlack, +); +const monitorSlackProviderLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.monitorSlackProvider, +); +const handleSlackActionLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.handleSlackAction, +); export function createRuntimeSlack(): PluginRuntimeChannel["slack"] { return { diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index b83df21670f..f8d71de11e0 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -5,104 +5,54 @@ import { setTelegramThreadBindingMaxAgeBySessionKey, } from "../../../extensions/telegram/src/thread-bindings.js"; import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; -type RuntimeTelegramOps = typeof import("./runtime-telegram-ops.runtime.js").runtimeTelegramOps; - const loadRuntimeTelegramOps = createLazyRuntimeSurface( () => import("./runtime-telegram-ops.runtime.js"), ({ runtimeTelegramOps }) => runtimeTelegramOps, ); -const auditGroupMembershipLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.auditGroupMembership); +const bindTelegramRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeTelegramOps); -const probeTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.probeTelegram); - -const sendMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.sendMessageTelegram); - -const sendPollTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.sendPollTelegram); - -const monitorTelegramProviderLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.monitorTelegramProvider); - -const sendTypingTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.typing.pulse); - -const editMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const auditGroupMembershipLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.auditGroupMembership, +); +const probeTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.probeTelegram, +); +const sendMessageTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.sendMessageTelegram, +); +const sendPollTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.sendPollTelegram, +); +const monitorTelegramProviderLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.monitorTelegramProvider, +); +const sendTypingTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.typing.pulse, +); +const editMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.editMessage, ); - -const editMessageReplyMarkupTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const editMessageReplyMarkupTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.editReplyMarkup, ); - -const deleteMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const deleteMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.deleteMessage, ); - -const renameForumTopicTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const renameForumTopicTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.renameTopic, ); - -const pinMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const pinMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.pinMessage, ); - -const unpinMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const unpinMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.unpinMessage, ); diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 63871bc08f8..e3b38710ce1 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -6,15 +6,13 @@ import { readWebSelfId, webAuthExists, } from "../../../extensions/whatsapp/src/auth-store.js"; -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import { createRuntimeWhatsAppLoginTool } from "./runtime-whatsapp-login-tool.js"; import type { PluginRuntime } from "./types.js"; -type RuntimeWhatsAppOutbound = - typeof import("./runtime-whatsapp-outbound.runtime.js").runtimeWhatsAppOutbound; -type RuntimeWhatsAppLogin = - typeof import("./runtime-whatsapp-login.runtime.js").runtimeWhatsAppLogin; - const loadWebOutbound = createLazyRuntimeSurface( () => import("./runtime-whatsapp-outbound.runtime.js"), ({ runtimeWhatsAppOutbound }) => runtimeWhatsAppOutbound, @@ -25,23 +23,18 @@ const loadWebLogin = createLazyRuntimeSurface( ({ runtimeWhatsAppLogin }) => runtimeWhatsAppLogin, ); -const sendMessageWhatsAppLazy = createLazyRuntimeMethod< - RuntimeWhatsAppOutbound, - Parameters, - ReturnType ->(loadWebOutbound, (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendMessageWhatsApp); +const bindWhatsAppOutboundMethod = createLazyRuntimeMethodBinder(loadWebOutbound); +const bindWhatsAppLoginMethod = createLazyRuntimeMethodBinder(loadWebLogin); -const sendPollWhatsAppLazy = createLazyRuntimeMethod< - RuntimeWhatsAppOutbound, - Parameters, - ReturnType ->(loadWebOutbound, (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendPollWhatsApp); - -const loginWebLazy = createLazyRuntimeMethod< - RuntimeWhatsAppLogin, - Parameters, - ReturnType ->(loadWebLogin, (runtimeWhatsAppLogin) => runtimeWhatsAppLogin.loginWeb); +const sendMessageWhatsAppLazy = bindWhatsAppOutboundMethod( + (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendMessageWhatsApp, +); +const sendPollWhatsAppLazy = bindWhatsAppOutboundMethod( + (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendPollWhatsApp, +); +const loginWebLazy = bindWhatsAppLoginMethod( + (runtimeWhatsAppLogin) => runtimeWhatsAppLogin.loginWeb, +); const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async ( ...args diff --git a/src/shared/lazy-runtime.ts b/src/shared/lazy-runtime.ts index 3edaa865f50..cbbccfe7dec 100644 --- a/src/shared/lazy-runtime.ts +++ b/src/shared/lazy-runtime.ts @@ -19,3 +19,11 @@ export function createLazyRuntimeMethod(load: () => Promise) { + return function ( + select: (surface: TSurface) => (...args: TArgs) => TResult, + ): (...args: TArgs) => Promise> { + return createLazyRuntimeMethod(load, select); + }; +} From e1b0e74e78c20e4035d402b5b4ed46d7d96ce221 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:07:05 -0700 Subject: [PATCH 006/393] refactor: align telegram test support with plugin runtime seam --- .../src/bot-native-commands.plugin-command-test-support.ts | 2 +- package.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts b/extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts index b4a47b728e4..dec0046de1f 100644 --- a/extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts @@ -6,7 +6,7 @@ export const pluginCommandMocks = { executePluginCommand: vi.fn(async () => ({ text: "ok" })), }; -vi.mock("../../../src/plugins/commands.js", () => ({ +vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, diff --git a/package.json b/package.json index 95a240b0892..27975bdffe2 100644 --- a/package.json +++ b/package.json @@ -230,6 +230,10 @@ "types": "./dist/plugin-sdk/lobster.d.ts", "default": "./dist/plugin-sdk/lobster.js" }, + "./plugin-sdk/lazy-runtime": { + "types": "./dist/plugin-sdk/lazy-runtime.d.ts", + "default": "./dist/plugin-sdk/lazy-runtime.js" + }, "./plugin-sdk/matrix": { "types": "./dist/plugin-sdk/matrix.d.ts", "default": "./dist/plugin-sdk/matrix.js" From ebee4e2210e1f282a982c7ef2ad79d77a572fc87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:08:10 -0700 Subject: [PATCH 007/393] fix(tlon): defer DM cite expansion until after auth --- CHANGELOG.md | 1 + extensions/tlon/src/monitor/index.ts | 25 +++- extensions/tlon/src/monitor/utils.ts | 18 +++ extensions/tlon/src/security.test.ts | 184 ++++++++++++++++++++++++++- 4 files changed, 221 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661dac6d613..c30c8dad708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai - Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark. - Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. +- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path. - Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 1ea42902aaf..19c9ec5b841 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -36,6 +36,7 @@ import { stripBotMention, isDmAllowed, isSummarizationRequest, + resolveAuthorizedMessageText, type ParsedCite, } from "./utils.js"; @@ -1245,9 +1246,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise normalizeShip(ship)).some((ship) => ship === normalizedInviter); } +/** + * Resolve quoted/cited content only after the caller has passed authorization. + * Unauthorized paths must keep raw text and must not trigger cross-channel cite fetches. + */ +export async function resolveAuthorizedMessageText(params: { + rawText: string; + content: unknown; + authorizedForCites: boolean; + resolveAllCites: (content: unknown) => Promise; +}): Promise { + const { rawText, content, authorizedForCites, resolveAllCites } = params; + if (!authorizedForCites) { + return rawText; + } + const citedContent = await resolveAllCites(content); + return citedContent + rawText; +} + // Helper to recursively extract text from inline content function renderInlineItem( item: any, diff --git a/extensions/tlon/src/security.test.ts b/extensions/tlon/src/security.test.ts index 04fad337b14..2733f2e3780 100644 --- a/extensions/tlon/src/security.test.ts +++ b/extensions/tlon/src/security.test.ts @@ -8,12 +8,14 @@ * - Bot mention detection boundaries */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + extractCites, isDmAllowed, isGroupInviteAllowed, isBotMentioned, extractMessageText, + resolveAuthorizedMessageText, } from "./monitor/utils.js"; import { normalizeShip } from "./targets.js"; @@ -340,6 +342,186 @@ describe("Security: Authorization Edge Cases", () => { }); }); +describe("Security: Cite Resolution Authorization Ordering", () => { + async function resolveAllCitesForPoC( + content: unknown, + api: { scry: (path: string) => Promise }, + ): Promise { + const cites = extractCites(content); + if (cites.length === 0) { + return ""; + } + + const resolved: string[] = []; + for (const cite of cites) { + if (cite.type !== "chan" || !cite.nest || !cite.postId) { + continue; + } + const data = (await api.scry(`/channels/v4/${cite.nest}/posts/post/${cite.postId}.json`)) as { + essay?: { content?: unknown }; + }; + const text = data?.essay?.content ? extractMessageText(data.essay.content) : ""; + if (text) { + resolved.push(`> ${cite.author || "unknown"} wrote: ${text}`); + } + } + + return resolved.length > 0 ? resolved.join("\n") + "\n\n" : ""; + } + + function buildCitedMessage( + secretNest = "chat/~private-ship/ops", + postId = "1701411845077995094", + ) { + return [ + { + block: { + cite: { + chan: { + nest: secretNest, + where: `/msg/~victim-ship/${postId}`, + }, + }, + }, + }, + { inline: ["~bot-ship please summarize this"] }, + ]; + } + + it("does not resolve channel cites for unauthorized senders", async () => { + const content = buildCitedMessage(); + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async () => ({ + essay: { content: [{ inline: ["TOP-SECRET"] }] }, + })), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: false, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(messageText).toBe(rawText); + expect(api.scry).not.toHaveBeenCalled(); + }); + + it("resolves channel cites after sender authorization passes", async () => { + const secretNest = "chat/~private-ship/ops"; + const postId = "170141184507799509469114119040828178432"; + const content = buildCitedMessage(secretNest, postId); + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async (path: string) => { + expect(path).toBe(`/channels/v4/${secretNest}/posts/post/${postId}.json`); + return { + essay: { content: [{ inline: ["TOP-SECRET: migration key is rotate-me"] }] }, + }; + }), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: true, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(api.scry).toHaveBeenCalledTimes(1); + expect(messageText).toContain("TOP-SECRET: migration key is rotate-me"); + expect(messageText).toContain("> ~victim-ship wrote: TOP-SECRET: migration key is rotate-me"); + }); + + it("does not resolve DM cites before a deny path", async () => { + const content = buildCitedMessage("chat/~secret-dm/ops", "1701411845077995095"); + const rawText = extractMessageText(content); + const senderShip = "~attacker-ship"; + const allowlist = ["~trusted-ship"]; + const api = { + scry: vi.fn(async () => ({ + essay: { content: [{ inline: ["DM-SECRET"] }] }, + })), + }; + + const senderAllowed = allowlist + .map((ship) => normalizeShip(ship)) + .includes(normalizeShip(senderShip)); + expect(senderAllowed).toBe(false); + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: senderAllowed, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(messageText).toBe(rawText); + expect(api.scry).not.toHaveBeenCalled(); + }); + + it("does not resolve DM cites before owner approval command handling", async () => { + const content = [ + { + block: { + cite: { + chan: { + nest: "chat/~private-ship/admin", + where: "/msg/~victim-ship/1701411845077995096", + }, + }, + }, + }, + { inline: ["/approve 1"] }, + ]; + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async () => ({ + essay: { content: [{ inline: ["ADMIN-SECRET"] }] }, + })), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: false, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(rawText).toContain("/approve 1"); + expect(messageText).toBe(rawText); + expect(messageText).not.toContain("ADMIN-SECRET"); + expect(api.scry).not.toHaveBeenCalled(); + }); + + it("resolves DM cites for allowed senders after authorization passes", async () => { + const secretNest = "chat/~private-ship/dm"; + const postId = "1701411845077995097"; + const content = buildCitedMessage(secretNest, postId); + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async (path: string) => { + expect(path).toBe(`/channels/v4/${secretNest}/posts/post/${postId}.json`); + return { + essay: { content: [{ inline: ["ALLOWED-DM-SECRET"] }] }, + }; + }), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: true, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(api.scry).toHaveBeenCalledTimes(1); + expect(messageText).toContain("ALLOWED-DM-SECRET"); + expect(messageText).toContain("> ~victim-ship wrote: ALLOWED-DM-SECRET"); + }); +}); + describe("Security: Sender Role Identification", () => { /** * Tests for sender role identification (owner vs user). From 094a0cc4126574fb0aab2bffeb28a0c512f5045e Mon Sep 17 00:00:00 2001 From: F_ool <112874572+hhhhao28@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:14:14 +0800 Subject: [PATCH 008/393] fix(context-engine): preserve legacy plugin sessionKey interop (#44779) Merged via squash. Prepared head SHA: e04c6fb47d1ad2623121c907b2e8dcaff62b9ad7 Co-authored-by: hhhhao28 <112874572+hhhhao28@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/context-engine/context-engine.test.ts | 198 ++++++++++++++++++++++ src/context-engine/registry.ts | 198 +++++++++++++++++++++- 3 files changed, 396 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30c8dad708..7a4850dc72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -302,6 +302,7 @@ Docs: https://docs.openclaw.ai - Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit. - Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec. +- Plugins/context engines: retry legacy lifecycle calls once without `sessionKey` when older plugins reject that field, memoize legacy mode after the first strict-schema fallback, and preserve non-compat runtime errors without retry. (#44779) thanks @hhhhao28. ## 2026.3.11 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 703ee88bf57..f684e5596d5 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -109,6 +109,113 @@ class MockContextEngine implements ContextEngine { } } +class LegacySessionKeyStrictEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: "legacy-sessionkey-strict", + name: "Legacy SessionKey Strict Engine", + }; + readonly ingestCalls: Array> = []; + readonly assembleCalls: Array> = []; + readonly compactCalls: Array> = []; + readonly ingestedMessages: AgentMessage[] = []; + + private rejectSessionKey(params: { sessionKey?: string }): void { + if (Object.prototype.hasOwnProperty.call(params, "sessionKey")) { + throw new Error("Unrecognized key(s) in object: 'sessionKey'"); + } + } + + async ingest(params: { + sessionId: string; + sessionKey?: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise { + this.ingestCalls.push({ ...params }); + this.rejectSessionKey(params); + this.ingestedMessages.push(params.message); + return { ingested: true }; + } + + async assemble(params: { + sessionId: string; + sessionKey?: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + this.assembleCalls.push({ ...params }); + this.rejectSessionKey(params); + return { + messages: params.messages, + estimatedTokens: 7, + }; + } + + async compact(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + tokenBudget?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + runtimeContext?: Record; + }): Promise { + this.compactCalls.push({ ...params }); + this.rejectSessionKey(params); + return { + ok: true, + compacted: true, + result: { + tokensBefore: 50, + tokensAfter: 25, + }, + }; + } +} + +class SessionKeyRuntimeErrorEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: "sessionkey-runtime-error", + name: "SessionKey Runtime Error Engine", + }; + assembleCalls = 0; + constructor(private readonly errorMessage = "sessionKey lookup failed") {} + + async ingest(_params: { + sessionId: string; + sessionKey?: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise { + return { ingested: true }; + } + + async assemble(_params: { + sessionId: string; + sessionKey?: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + this.assembleCalls += 1; + throw new Error(this.errorMessage); + } + + async compact(_params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + tokenBudget?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + runtimeContext?: Record; + }): Promise { + return { + ok: true, + compacted: false, + }; + } +} + // ═══════════════════════════════════════════════════════════════════════════ // 1. Engine contract tests // ═══════════════════════════════════════════════════════════════════════════ @@ -325,6 +432,97 @@ describe("Registry tests", () => { // 3. Default engine selection // ═══════════════════════════════════════════════════════════════════════════ +describe("Legacy sessionKey compatibility", () => { + it("memoizes legacy mode after the first strict compatibility retry", async () => { + const engineId = `legacy-sessionkey-${Date.now().toString(36)}`; + const strictEngine = new LegacySessionKeyStrictEngine(); + registerContextEngine(engineId, () => strictEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + const firstAssembled = await engine.assemble({ + sessionId: "s1", + sessionKey: "agent:main:test", + messages: [makeMockMessage()], + }); + const compacted = await engine.compact({ + sessionId: "s1", + sessionKey: "agent:main:test", + sessionFile: "/tmp/session.json", + }); + + expect(firstAssembled.estimatedTokens).toBe(7); + expect(compacted.compacted).toBe(true); + expect(strictEngine.assembleCalls).toHaveLength(2); + expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test"); + expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey"); + expect(strictEngine.compactCalls).toHaveLength(1); + expect(strictEngine.compactCalls[0]).not.toHaveProperty("sessionKey"); + }); + + it("retries strict ingest once and ingests each message only once", async () => { + const engineId = `legacy-sessionkey-ingest-${Date.now().toString(36)}`; + const strictEngine = new LegacySessionKeyStrictEngine(); + registerContextEngine(engineId, () => strictEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + const firstMessage = makeMockMessage("user", "first"); + const secondMessage = makeMockMessage("assistant", "second"); + + await engine.ingest({ + sessionId: "s1", + sessionKey: "agent:main:test", + message: firstMessage, + }); + await engine.ingest({ + sessionId: "s1", + sessionKey: "agent:main:test", + message: secondMessage, + }); + + expect(strictEngine.ingestCalls).toHaveLength(3); + expect(strictEngine.ingestCalls[0]).toHaveProperty("sessionKey", "agent:main:test"); + expect(strictEngine.ingestCalls[1]).not.toHaveProperty("sessionKey"); + expect(strictEngine.ingestCalls[2]).not.toHaveProperty("sessionKey"); + expect(strictEngine.ingestedMessages).toEqual([firstMessage, secondMessage]); + }); + + it("does not retry non-compat runtime errors", async () => { + const engineId = `sessionkey-runtime-${Date.now().toString(36)}`; + const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(); + registerContextEngine(engineId, () => runtimeErrorEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + + await expect( + engine.assemble({ + sessionId: "s1", + sessionKey: "agent:main:test", + messages: [makeMockMessage()], + }), + ).rejects.toThrow("sessionKey lookup failed"); + expect(runtimeErrorEngine.assembleCalls).toBe(1); + }); + + it("does not treat 'Unknown sessionKey' runtime failures as schema-compat errors", async () => { + const engineId = `sessionkey-unknown-runtime-${Date.now().toString(36)}`; + const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine( + 'Unknown sessionKey "agent:main:missing"', + ); + registerContextEngine(engineId, () => runtimeErrorEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + + await expect( + engine.assemble({ + sessionId: "s1", + sessionKey: "agent:main:missing", + messages: [makeMockMessage()], + }), + ).rejects.toThrow('Unknown sessionKey "agent:main:missing"'); + expect(runtimeErrorEngine.assembleCalls).toBe(1); + }); +}); + describe("Default engine selection", () => { // Ensure both legacy and a custom test engine are registered before these tests. beforeEach(() => { diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 1701877790a..2c5cac439c0 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -13,6 +13,202 @@ type RegisterContextEngineForOwnerOptions = { allowSameOwnerRefresh?: boolean; }; +const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat"); +const SESSION_KEY_COMPAT_METHODS = [ + "bootstrap", + "ingest", + "ingestBatch", + "afterTurn", + "assemble", + "compact", +] as const; + +type SessionKeyCompatMethodName = (typeof SESSION_KEY_COMPAT_METHODS)[number]; +type SessionKeyCompatParams = { + sessionKey?: string; +}; + +function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCompatMethodName { + return ( + typeof value === "string" && (SESSION_KEY_COMPAT_METHODS as readonly string[]).includes(value) + ); +} + +function hasOwnSessionKey(params: unknown): params is SessionKeyCompatParams { + return ( + params !== null && + typeof params === "object" && + Object.prototype.hasOwnProperty.call(params, "sessionKey") + ); +} + +function withoutSessionKey(params: T): T { + const legacyParams = { ...params }; + delete legacyParams.sessionKey; + return legacyParams; +} + +function issueRejectsSessionKeyStrictly(issue: unknown): boolean { + if (!issue || typeof issue !== "object") { + return false; + } + + const issueRecord = issue as { + code?: unknown; + keys?: unknown; + message?: unknown; + }; + if ( + issueRecord.code === "unrecognized_keys" && + Array.isArray(issueRecord.keys) && + issueRecord.keys.some((key) => key === "sessionKey") + ) { + return true; + } + + return isSessionKeyCompatibilityError(issueRecord.message); +} + +function* iterateErrorChain(error: unknown) { + let current = error; + const seen = new Set(); + while (current !== undefined && current !== null && !seen.has(current)) { + yield current; + seen.add(current); + if (typeof current !== "object") { + break; + } + current = (current as { cause?: unknown }).cause; + } +} + +const SESSION_KEY_UNKNOWN_FIELD_PATTERNS = [ + /\bunrecognized key(?:\(s\)|s)? in object:.*['"`]sessionKey['"`]/i, + /\badditional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i, + /\bmust not have additional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i, + /\b(?:unexpected|extraneous)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i, + /\b(?:unknown|invalid)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i, + /['"`]sessionKey['"`].*\b(?:was|is)\s+not allowed\b/i, + /"code"\s*:\s*"unrecognized_keys"[^]*"sessionKey"/i, +] as const; + +function isSessionKeyUnknownFieldValidationMessage(message: string): boolean { + return SESSION_KEY_UNKNOWN_FIELD_PATTERNS.some((pattern) => pattern.test(message)); +} + +function isSessionKeyCompatibilityError(error: unknown): boolean { + for (const candidate of iterateErrorChain(error)) { + if (Array.isArray(candidate)) { + if (candidate.some((entry) => issueRejectsSessionKeyStrictly(entry))) { + return true; + } + continue; + } + + if (typeof candidate === "string") { + if (isSessionKeyUnknownFieldValidationMessage(candidate)) { + return true; + } + continue; + } + + if (!candidate || typeof candidate !== "object") { + continue; + } + + const issueContainer = candidate as { + message?: unknown; + issues?: unknown; + errors?: unknown; + }; + + if ( + Array.isArray(issueContainer.issues) && + issueContainer.issues.some((issue) => issueRejectsSessionKeyStrictly(issue)) + ) { + return true; + } + + if ( + Array.isArray(issueContainer.errors) && + issueContainer.errors.some((issue) => issueRejectsSessionKeyStrictly(issue)) + ) { + return true; + } + + if ( + typeof issueContainer.message === "string" && + isSessionKeyUnknownFieldValidationMessage(issueContainer.message) + ) { + return true; + } + } + + return false; +} + +async function invokeWithLegacySessionKeyCompat( + method: (params: TParams) => Promise | TResult, + params: TParams, + opts?: { + onLegacyModeDetected?: () => void; + }, +): Promise { + if (!hasOwnSessionKey(params)) { + return await method(params); + } + + try { + return await method(params); + } catch (error) { + if (!isSessionKeyCompatibilityError(error)) { + throw error; + } + opts?.onLegacyModeDetected?.(); + return await method(withoutSessionKey(params)); + } +} + +function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEngine { + const marked = engine as ContextEngine & { + [LEGACY_SESSION_KEY_COMPAT]?: boolean; + }; + if (marked[LEGACY_SESSION_KEY_COMPAT]) { + return engine; + } + + let isLegacy = false; + const proxy: ContextEngine = new Proxy(engine, { + get(target, property, receiver) { + if (property === LEGACY_SESSION_KEY_COMPAT) { + return true; + } + + const value = Reflect.get(target, property, receiver); + if (typeof value !== "function") { + return value; + } + + if (!isSessionKeyCompatMethodName(property)) { + return value.bind(target); + } + + return (params: SessionKeyCompatParams) => { + const method = value.bind(target) as (params: SessionKeyCompatParams) => unknown; + if (isLegacy && hasOwnSessionKey(params)) { + return method(withoutSessionKey(params)); + } + return invokeWithLegacySessionKeyCompat(method, params, { + onLegacyModeDetected: () => { + isLegacy = true; + }, + }); + }; + }, + }); + return proxy; +} + // --------------------------------------------------------------------------- // Registry (module-level singleton) // --------------------------------------------------------------------------- @@ -139,5 +335,5 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise Date: Tue, 17 Mar 2026 08:01:38 +0000 Subject: [PATCH 009/393] test: harden CI-sensitive test suites --- .github/workflows/ci.yml | 4 +- extensions/feishu/src/bot.test.ts | 21 ++-- extensions/feishu/src/client.test.ts | 103 ++++++++++++------ ...compaction-retry-aggregate-timeout.test.ts | 44 ++++---- .../pi-embedded-runner/system-prompt.test.ts | 21 +++- src/plugins/contracts/wizard.contract.test.ts | 19 ++-- 6 files changed, 131 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce1299a5d2a..6dc68d2275a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -274,8 +274,8 @@ jobs: - name: Run changed extension tests env: - EXTENSION_ID: ${{ matrix.extension }} - run: pnpm test:extension "$EXTENSION_ID" + OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }} + run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION" # Types, lint, and format check. check: diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index ea7dbcb51ec..910fa03f28c 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -77,14 +77,19 @@ vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, })); -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params), - ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params), - getSessionBindingService: () => ({ - resolveByConversation: mockResolveBoundConversation, - touch: mockTouchBinding, - }), -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params), + ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params), + getSessionBindingService: () => ({ + resolveByConversation: mockResolveBoundConversation, + touch: mockTouchBinding, + }), + }; +}); function createRuntimeEnv(): RuntimeEnv { return { diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index 6efda0cbb4e..fe4e04dc310 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,7 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js"; -const clientCtorMock = vi.hoisted(() => vi.fn()); +type CreateFeishuClient = typeof import("./client.js").createFeishuClient; +type CreateFeishuWSClient = typeof import("./client.js").createFeishuWSClient; +type ClearClientCache = typeof import("./client.js").clearClientCache; +type SetFeishuClientRuntimeForTest = typeof import("./client.js").setFeishuClientRuntimeForTest; + +const clientCtorMock = vi.hoisted(() => + vi.fn(function clientCtor() { + return { connected: true }; + }), +); const wsClientCtorMock = vi.hoisted(() => vi.fn(function wsClientCtor() { return { connected: true }; @@ -12,7 +21,6 @@ const httpsProxyAgentCtorMock = vi.hoisted(() => return { proxyUrl }; }), ); - const mockBaseHttpInstance = vi.hoisted(() => ({ request: vi.fn().mockResolvedValue({}), get: vi.fn().mockResolvedValue({}), @@ -23,19 +31,17 @@ const mockBaseHttpInstance = vi.hoisted(() => ({ head: vi.fn().mockResolvedValue({}), options: vi.fn().mockResolvedValue({}), })); -import { - createFeishuClient, - createFeishuWSClient, - clearClientCache, - FEISHU_HTTP_TIMEOUT_MS, - FEISHU_HTTP_TIMEOUT_MAX_MS, - FEISHU_HTTP_TIMEOUT_ENV_VAR, - setFeishuClientRuntimeForTest, -} from "./client.js"; - const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const; type ProxyEnvKey = (typeof proxyEnvKeys)[number]; +let createFeishuClient: CreateFeishuClient; +let createFeishuWSClient: CreateFeishuWSClient; +let clearClientCache: ClearClientCache; +let setFeishuClientRuntimeForTest: SetFeishuClientRuntimeForTest; +let FEISHU_HTTP_TIMEOUT_MS: number; +let FEISHU_HTTP_TIMEOUT_MAX_MS: number; +let FEISHU_HTTP_TIMEOUT_ENV_VAR: string; + let priorProxyEnv: Partial> = {}; let priorFeishuTimeoutEnv: string | undefined; @@ -55,7 +61,31 @@ function firstWsClientOptions(): { agent?: unknown } { return calls[0]?.[0] ?? {}; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + vi.doMock("@larksuiteoapi/node-sdk", () => ({ + AppType: { SelfBuild: "self" }, + Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" }, + LoggerLevel: { info: "info" }, + Client: clientCtorMock, + WSClient: wsClientCtorMock, + EventDispatcher: vi.fn(), + defaultHttpInstance: mockBaseHttpInstance, + })); + vi.doMock("https-proxy-agent", () => ({ + HttpsProxyAgent: httpsProxyAgentCtorMock, + })); + + ({ + createFeishuClient, + createFeishuWSClient, + clearClientCache, + setFeishuClientRuntimeForTest, + FEISHU_HTTP_TIMEOUT_MS, + FEISHU_HTTP_TIMEOUT_MAX_MS, + FEISHU_HTTP_TIMEOUT_ENV_VAR, + } = await import("./client.js")); + priorProxyEnv = {}; priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; @@ -104,7 +134,7 @@ describe("createFeishuClient HTTP timeout", () => { }); const getLastClientHttpInstance = () => { - const calls = clientCtorMock.mock.calls; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; const lastCall = calls[calls.length - 1]?.[0] as | { httpInstance?: { get: (...args: unknown[]) => Promise } } | undefined; @@ -124,21 +154,22 @@ describe("createFeishuClient HTTP timeout", () => { it("passes a custom httpInstance with default timeout to Lark.Client", () => { createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret - const calls = clientCtorMock.mock.calls; - const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown }; - expect(lastCall.httpInstance).toBeDefined(); + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; + const lastCall = calls[calls.length - 1]?.[0] as { httpInstance?: unknown } | undefined; + expect(lastCall?.httpInstance).toBeDefined(); }); it("injects default timeout into HTTP request options", async () => { createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret - const calls = clientCtorMock.mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { post: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance: { post: (...args: unknown[]) => Promise } } + | undefined; + const httpInstance = lastCall?.httpInstance; - await httpInstance.post( + expect(httpInstance).toBeDefined(); + await httpInstance?.post( "https://example.com/api", { data: 1 }, { headers: { "X-Custom": "yes" } }, @@ -154,13 +185,14 @@ describe("createFeishuClient HTTP timeout", () => { it("allows explicit timeout override per-request", async () => { createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret - const calls = clientCtorMock.mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance: { get: (...args: unknown[]) => Promise } } + | undefined; + const httpInstance = lastCall?.httpInstance; - await httpInstance.get("https://example.com/api", { timeout: 5_000 }); + expect(httpInstance).toBeDefined(); + await httpInstance?.get("https://example.com/api", { timeout: 5_000 }); expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( "https://example.com/api", @@ -243,13 +275,14 @@ describe("createFeishuClient HTTP timeout", () => { config: { httpTimeoutMs: 45_000 }, }); - const calls = clientCtorMock.mock.calls; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; expect(calls.length).toBe(2); - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - await lastCall.httpInstance.get("https://example.com/api"); + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance: { get: (...args: unknown[]) => Promise } } + | undefined; + expect(lastCall?.httpInstance).toBeDefined(); + await lastCall?.httpInstance.get("https://example.com/api"); expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( "https://example.com/api", @@ -264,7 +297,7 @@ describe("createFeishuWSClient proxy handling", () => { expect(httpsProxyAgentCtorMock).not.toHaveBeenCalled(); const options = firstWsClientOptions(); - expect(options?.agent).toBeUndefined(); + expect(options.agent).toBeUndefined(); }); it("uses proxy env precedence: https_proxy first, then HTTPS_PROXY, then http_proxy/HTTP_PROXY", () => { diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts index 7b7ce460826..e5f02cecf0c 100644 --- a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js"; type AggregateTimeoutParams = Parameters[0]; +type TimeoutCallback = NonNullable; +type TimeoutCallbackMock = ReturnType>; async function withFakeTimers(run: () => Promise) { vi.useFakeTimers(); @@ -13,7 +15,7 @@ async function withFakeTimers(run: () => Promise) { } } -function expectClearedTimeoutState(onTimeout: ReturnType, timedOut: boolean) { +function expectClearedTimeoutState(onTimeout: TimeoutCallbackMock, timedOut: boolean) { if (timedOut) { expect(onTimeout).toHaveBeenCalledTimes(1); } else { @@ -25,18 +27,15 @@ function expectClearedTimeoutState(onTimeout: ReturnType, timedOut function buildAggregateTimeoutParams( overrides: Partial & Pick, -): { params: AggregateTimeoutParams; onTimeoutSpy: ReturnType } { - const onTimeoutSpy = vi.fn(); - const onTimeout = overrides.onTimeout ?? (() => onTimeoutSpy()); +): AggregateTimeoutParams & { onTimeout: TimeoutCallbackMock } { + const onTimeout = + (overrides.onTimeout as TimeoutCallbackMock | undefined) ?? vi.fn(); return { - params: { - waitForCompactionRetry: overrides.waitForCompactionRetry, - abortable: overrides.abortable ?? (async (promise) => await promise), - aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000, - isCompactionStillInFlight: overrides.isCompactionStillInFlight, - onTimeout, - }, - onTimeoutSpy, + waitForCompactionRetry: overrides.waitForCompactionRetry, + abortable: overrides.abortable ?? (async (promise) => await promise), + aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000, + isCompactionStillInFlight: overrides.isCompactionStillInFlight, + onTimeout, }; } @@ -44,7 +43,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { it("times out and fires callback when compaction retry never resolves", async () => { await withFakeTimers(async () => { const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry }); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); @@ -52,7 +51,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(onTimeoutSpy, true); + expectClearedTimeoutState(params.onTimeout, true); }); }); @@ -72,15 +71,14 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { waitForCompactionRetry, isCompactionStillInFlight: () => compactionInFlight, }); - const { params: aggregateTimeoutParams, onTimeoutSpy } = params; - const resultPromise = waitForCompactionRetryWithAggregateTimeout(aggregateTimeoutParams); + const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); await vi.advanceTimersByTimeAsync(170_000); const result = await resultPromise; expect(result.timedOut).toBe(false); - expectClearedTimeoutState(onTimeoutSpy, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); @@ -91,7 +89,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { setTimeout(() => { compactionInFlight = false; }, 90_000); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ + const params = buildAggregateTimeoutParams({ waitForCompactionRetry, isCompactionStillInFlight: () => compactionInFlight, }); @@ -102,19 +100,19 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(onTimeoutSpy, true); + expectClearedTimeoutState(params.onTimeout, true); }); }); it("does not time out when compaction retry resolves", async () => { await withFakeTimers(async () => { const waitForCompactionRetry = vi.fn(async () => {}); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry }); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); const result = await waitForCompactionRetryWithAggregateTimeout(params); expect(result.timedOut).toBe(false); - expectClearedTimeoutState(onTimeoutSpy, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); @@ -123,7 +121,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const abortError = new Error("aborted"); abortError.name = "AbortError"; const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ + const params = buildAggregateTimeoutParams({ waitForCompactionRetry, abortable: async () => { throw abortError; @@ -132,7 +130,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { await expect(waitForCompactionRetryWithAggregateTimeout(params)).rejects.toThrow("aborted"); - expectClearedTimeoutState(onTimeoutSpy, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); }); diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/pi-embedded-runner/system-prompt.test.ts index 0ba4ee66d0f..b50565eb738 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/system-prompt.test.ts @@ -2,16 +2,25 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { applySystemPromptOverrideToSession, createSystemPromptOverride } from "./system-prompt.js"; -type MutableSystemPromptFields = { +type MutableSession = { _baseSystemPrompt?: string; _rebuildSystemPrompt?: (toolNames: string[]) => string; }; -function createMockSession() { - const setSystemPrompt = vi.fn(); +type MockSession = MutableSession & { + agent: { + setSystemPrompt: ReturnType; + }; +}; + +function createMockSession(): { + session: MockSession; + setSystemPrompt: ReturnType; +} { + const setSystemPrompt = vi.fn<(prompt: string) => void>(); const session = { agent: { setSystemPrompt }, - } as unknown as AgentSession; + } as MockSession; return { session, setSystemPrompt }; } @@ -19,9 +28,9 @@ function applyAndGetMutableSession( prompt: Parameters[1], ) { const { session, setSystemPrompt } = createMockSession(); - applySystemPromptOverrideToSession(session, prompt); + applySystemPromptOverrideToSession(session as unknown as AgentSession, prompt); return { - mutable: session as unknown as MutableSystemPromptFields, + mutable: session, setSystemPrompt, }; } diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 9af9d21d411..1e0ca6e49be 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -8,12 +8,10 @@ vi.mock("../providers.js", () => ({ resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), })); -const { - buildProviderPluginMethodChoice, - resolveProviderModelPickerEntries, - resolveProviderPluginChoice, - resolveProviderWizardOptions, -} = await import("../provider-wizard.js"); +let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice; +let resolveProviderModelPickerEntries: typeof import("../provider-wizard.js").resolveProviderModelPickerEntries; +let resolveProviderPluginChoice: typeof import("../provider-wizard.js").resolveProviderPluginChoice; +let resolveProviderWizardOptions: typeof import("../provider-wizard.js").resolveProviderWizardOptions; function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) { const values: string[] = []; @@ -72,7 +70,14 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { } describe("provider wizard contract", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ + buildProviderPluginMethodChoice, + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + resolveProviderWizardOptions, + } = await import("../provider-wizard.js")); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); }); From f2107a53cb2b52d307f894c65f1999c52a7254b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:03:06 +0000 Subject: [PATCH 010/393] test: remove repeated update module imports --- src/plugins/update.test.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index e3c21e8d7ef..7e93ab7ba50 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -20,6 +20,8 @@ vi.mock("./bundled-sources.js", () => ({ resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args), })); +const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = await import("./update.js"); + describe("updateNpmInstalledPlugins", () => { beforeEach(() => { installPluginFromNpmSpecMock.mockReset(); @@ -36,7 +38,6 @@ describe("updateNpmInstalledPlugins", () => { extensions: ["index.ts"], }); - const { updateNpmInstalledPlugins } = await import("./update.js"); await updateNpmInstalledPlugins({ config: { plugins: { @@ -71,7 +72,6 @@ describe("updateNpmInstalledPlugins", () => { extensions: ["index.ts"], }); - const { updateNpmInstalledPlugins } = await import("./update.js"); await updateNpmInstalledPlugins({ config: { plugins: { @@ -104,7 +104,6 @@ describe("updateNpmInstalledPlugins", () => { error: "Package not found on npm: @openclaw/missing.", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -137,7 +136,6 @@ describe("updateNpmInstalledPlugins", () => { error: "unsupported npm spec: github:evil/evil", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -172,7 +170,6 @@ describe("updateNpmInstalledPlugins", () => { extensions: ["index.ts"], }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -231,7 +228,6 @@ describe("updateNpmInstalledPlugins", () => { marketplacePlugin: "claude-bundle", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -280,7 +276,6 @@ describe("updateNpmInstalledPlugins", () => { marketplacePlugin: "claude-bundle", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -330,7 +325,6 @@ describe("syncPluginsForUpdateChannel", () => { ]), ); - const { syncPluginsForUpdateChannel } = await import("./update.js"); const result = await syncPluginsForUpdateChannel({ channel: "beta", config: { @@ -369,7 +363,6 @@ describe("syncPluginsForUpdateChannel", () => { ]), ); - const { syncPluginsForUpdateChannel } = await import("./update.js"); const result = await syncPluginsForUpdateChannel({ channel: "beta", config: { @@ -402,7 +395,6 @@ describe("syncPluginsForUpdateChannel", () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; - const { syncPluginsForUpdateChannel } = await import("./update.js"); await syncPluginsForUpdateChannel({ channel: "beta", config: {}, @@ -434,7 +426,6 @@ describe("syncPluginsForUpdateChannel", () => { const previousHome = process.env.HOME; process.env.HOME = "/tmp/process-home"; try { - const { syncPluginsForUpdateChannel } = await import("./update.js"); const result = await syncPluginsForUpdateChannel({ channel: "beta", env: { From 9b22bd41d84ed071e62e0597e2db2d2d0e9ef626 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:03:58 +0000 Subject: [PATCH 011/393] test: inline bluebubbles action mocks --- extensions/bluebubbles/src/actions.test.ts | 29 ++++------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 0560567c5fb..a7a9e549051 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -1,7 +1,12 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.js"; +import { sendBlueBubblesAttachment } from "./attachments.js"; +import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { sendBlueBubblesReaction } from "./reactions.js"; +import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; vi.mock("./accounts.js", async () => { const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); @@ -277,7 +282,6 @@ describe("bluebubblesMessageActions", () => { }); it("throws when chatGuid cannot be resolved", async () => { - const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null); const cfg: OpenClawConfig = { @@ -299,8 +303,6 @@ describe("bluebubblesMessageActions", () => { }); it("sends reaction successfully with chatGuid", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const result = await runReactAction({ emoji: "❤️", messageId: "msg-123", @@ -321,8 +323,6 @@ describe("bluebubblesMessageActions", () => { }); it("sends reaction removal successfully", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const result = await runReactAction({ emoji: "❤️", messageId: "msg-123", @@ -342,8 +342,6 @@ describe("bluebubblesMessageActions", () => { }); it("resolves chatGuid from to parameter", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543"); const cfg: OpenClawConfig = { @@ -374,8 +372,6 @@ describe("bluebubblesMessageActions", () => { }); it("passes partIndex when provided", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -404,8 +400,6 @@ describe("bluebubblesMessageActions", () => { }); it("uses toolContext currentChannelId when no explicit target is provided", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111"); const cfg: OpenClawConfig = { @@ -442,8 +436,6 @@ describe("bluebubblesMessageActions", () => { }); it("resolves short messageId before reacting", async () => { - const { resolveBlueBubblesMessageId } = await import("./monitor.js"); - const { sendBlueBubblesReaction } = await import("./reactions.js"); vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid"); const cfg: OpenClawConfig = { @@ -475,7 +467,6 @@ describe("bluebubblesMessageActions", () => { }); it("propagates short-id errors from the resolver", async () => { - const { resolveBlueBubblesMessageId } = await import("./monitor.js"); vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => { throw new Error("short id expired"); }); @@ -504,8 +495,6 @@ describe("bluebubblesMessageActions", () => { }); it("accepts message param for edit action", async () => { - const { editBlueBubblesMessage } = await import("./chat.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -530,8 +519,6 @@ describe("bluebubblesMessageActions", () => { }); it("accepts message/target aliases for sendWithEffect", async () => { - const { sendMessageBlueBubbles } = await import("./send.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -563,8 +550,6 @@ describe("bluebubblesMessageActions", () => { }); it("passes asVoice through sendAttachment", async () => { - const { sendBlueBubblesAttachment } = await import("./attachments.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -619,8 +604,6 @@ describe("bluebubblesMessageActions", () => { }); it("sets group icon successfully with chatGuid and buffer", async () => { - const { setGroupIconBlueBubbles } = await import("./chat.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -658,8 +641,6 @@ describe("bluebubblesMessageActions", () => { }); it("uses default filename when not provided for setGroupIcon", async () => { - const { setGroupIconBlueBubbles } = await import("./chat.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { From 94a48912dec09ced73cb5d2f1899cf0d3221305e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:04:47 +0000 Subject: [PATCH 012/393] test: reuse subagent orphan recovery imports --- src/agents/subagent-orphan-recovery.test.ts | 61 ++------------------- 1 file changed, 5 insertions(+), 56 deletions(-) diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 66b8097154c..287d2c714f3 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as sessions from "../config/sessions.js"; +import * as gateway from "../gateway/call.js"; +import * as sessionUtils from "../gateway/session-utils.fs.js"; +import { recoverOrphanedSubagentSessions } from "./subagent-orphan-recovery.js"; +import * as subagentRegistry from "./subagent-registry.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; // Mock dependencies before importing the module under test @@ -51,10 +56,6 @@ describe("subagent-orphan-recovery", () => { }); it("recovers orphaned sessions with abortedLastRun=true", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - const subagentRegistry = await import("./subagent-registry.js"); - const sessionEntry = { sessionId: "session-abc", updatedAt: Date.now(), @@ -69,8 +70,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", run); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -98,9 +97,6 @@ describe("subagent-orphan-recovery", () => { }); it("skips sessions that are not aborted", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - vi.mocked(sessions.loadSessionStore).mockReturnValue({ "agent:main:subagent:test-session-1": { sessionId: "session-abc", @@ -112,8 +108,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -124,8 +118,6 @@ describe("subagent-orphan-recovery", () => { }); it("skips runs that have already ended", async () => { - const gateway = await import("../gateway/call.js"); - const activeRuns = new Map(); activeRuns.set( "run-1", @@ -134,8 +126,6 @@ describe("subagent-orphan-recovery", () => { }), ); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -145,9 +135,6 @@ describe("subagent-orphan-recovery", () => { }); it("handles multiple orphaned sessions", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - vi.mocked(sessions.loadSessionStore).mockReturnValue({ "agent:main:subagent:session-a": { sessionId: "id-a", @@ -192,8 +179,6 @@ describe("subagent-orphan-recovery", () => { }), ); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -204,9 +189,6 @@ describe("subagent-orphan-recovery", () => { }); it("handles callGateway failure gracefully and preserves abortedLastRun flag", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - vi.mocked(sessions.loadSessionStore).mockReturnValue({ "agent:main:subagent:test-session-1": { sessionId: "session-abc", @@ -220,8 +202,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -235,8 +215,6 @@ describe("subagent-orphan-recovery", () => { }); it("returns empty results when no active runs exist", async () => { - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => new Map(), }); @@ -247,17 +225,12 @@ describe("subagent-orphan-recovery", () => { }); it("skips sessions with missing session entry in store", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - // Store has no matching entry vi.mocked(sessions.loadSessionStore).mockReturnValue({}); const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -268,9 +241,6 @@ describe("subagent-orphan-recovery", () => { }); it("clears abortedLastRun flag after successful resume", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - // Ensure callGateway succeeds for this test vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "resumed-run" } as never); @@ -285,8 +255,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -309,9 +277,6 @@ describe("subagent-orphan-recovery", () => { }); it("truncates long task descriptions in resume message", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - vi.mocked(sessions.loadSessionStore).mockReturnValue({ "agent:main:subagent:test-session-1": { sessionId: "session-abc", @@ -324,8 +289,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord({ task: longTask })); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -340,10 +303,6 @@ describe("subagent-orphan-recovery", () => { }); it("includes last human message in resume when available", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - const sessionUtils = await import("../gateway/session-utils.fs.js"); - vi.mocked(sessions.loadSessionStore).mockReturnValue({ "agent:main:subagent:test-session-1": { sessionId: "session-abc", @@ -363,7 +322,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns }); const callArgs = vi.mocked(gateway.callGateway).mock.calls[0]; @@ -374,10 +332,6 @@ describe("subagent-orphan-recovery", () => { }); it("adds config change hint when assistant messages reference config modifications", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - const sessionUtils = await import("../gateway/session-utils.fs.js"); - vi.mocked(sessions.loadSessionStore).mockReturnValue({ "agent:main:subagent:test-session-1": { sessionId: "session-abc", @@ -394,7 +348,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns }); const callArgs = vi.mocked(gateway.callGateway).mock.calls[0]; @@ -404,9 +357,6 @@ describe("subagent-orphan-recovery", () => { }); it("prevents duplicate resume when updateSessionStore fails", async () => { - const sessions = await import("../config/sessions.js"); - const gateway = await import("../gateway/call.js"); - vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "new-run" } as never); vi.mocked(sessions.updateSessionStore).mockRejectedValue(new Error("write failed")); @@ -427,7 +377,6 @@ describe("subagent-orphan-recovery", () => { }), ); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns }); expect(result.recovered).toBe(1); From 63c5932e845dbc0768462476925a28357ccc3ff5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:08:14 +0000 Subject: [PATCH 013/393] test: flatten twitch send mocks --- extensions/twitch/src/send.test.ts | 54 +++++++++--------------------- 1 file changed, 15 insertions(+), 39 deletions(-) diff --git a/extensions/twitch/src/send.test.ts b/extensions/twitch/src/send.test.ts index b45321229a4..4607670e3bf 100644 --- a/extensions/twitch/src/send.test.ts +++ b/extensions/twitch/src/send.test.ts @@ -11,12 +11,16 @@ */ import { describe, expect, it, vi } from "vitest"; +import { getClientManager } from "./client-manager-registry.js"; +import { getAccountConfig } from "./config.js"; import { sendMessageTwitchInternal } from "./send.js"; import { BASE_TWITCH_TEST_ACCOUNT, installTwitchTestHooks, makeTwitchTestConfig, } from "./test-fixtures.js"; +import { stripMarkdownForTwitch } from "./utils/markdown.js"; +import { isAccountConfigured } from "./utils/twitch.js"; // Mock dependencies vi.mock("./config.js", () => ({ @@ -55,15 +59,16 @@ describe("send", () => { installTwitchTestHooks(); describe("sendMessageTwitchInternal", () => { + function setupBaseAccount(params?: { configured?: boolean }) { + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(isAccountConfigured).mockReturnValue(params?.configured ?? true); + } + async function mockSuccessfulSend(params: { messageId: string; stripMarkdown?: (text: string) => string; }) { - const { getAccountConfig } = await import("./config.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + setupBaseAccount(); vi.mocked(getClientManager).mockReturnValue({ sendMessage: vi.fn().mockResolvedValue({ ok: true, @@ -112,8 +117,6 @@ describe("send", () => { }); it("should return error when account not found", async () => { - const { getAccountConfig } = await import("./config.js"); - vi.mocked(getAccountConfig).mockReturnValue(null); const result = await sendMessageTwitchInternal( @@ -130,11 +133,7 @@ describe("send", () => { }); it("should return error when account not configured", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(false); + setupBaseAccount({ configured: false }); const result = await sendMessageTwitchInternal( "#testchannel", @@ -150,9 +149,6 @@ describe("send", () => { }); it("should return error when no channel specified", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - // Set channel to undefined to trigger the error (bypassing type check) const accountWithoutChannel = { ...mockAccount, @@ -175,12 +171,7 @@ describe("send", () => { }); it("should skip sending empty message after markdown stripping", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); vi.mocked(stripMarkdownForTwitch).mockReturnValue(""); const result = await sendMessageTwitchInternal( @@ -197,12 +188,7 @@ describe("send", () => { }); it("should return error when client manager not found", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); vi.mocked(getClientManager).mockReturnValue(undefined); const result = await sendMessageTwitchInternal( @@ -219,12 +205,7 @@ describe("send", () => { }); it("should handle send errors gracefully", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); vi.mocked(getClientManager).mockReturnValue({ sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")), } as unknown as ReturnType); @@ -244,12 +225,7 @@ describe("send", () => { }); it("should use account channel when channel parameter is empty", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); const mockSend = vi.fn().mockResolvedValue({ ok: true, messageId: "twitch-msg-789", From 00b730839653150fb93d690a18bc1d052100e530 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:13:51 +0000 Subject: [PATCH 014/393] test: stabilize pdf tool runtime mocks --- src/agents/tools/pdf-tool.test.ts | 36 ++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index a9c9539d61d..2ff557b3dca 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -10,15 +10,24 @@ import { providerSupportsNativePdf, resolvePdfToolMaxTokens, } from "./pdf-tool.helpers.js"; -import { createPdfTool, resolvePdfModelConfigForTool } from "./pdf-tool.js"; -vi.mock("@mariozechner/pi-ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - complete: vi.fn(), - }; -}); +const completeMock = vi.hoisted(() => vi.fn()); + +type PdfToolModule = typeof import("./pdf-tool.js"); +let createPdfTool: PdfToolModule["createPdfTool"]; +let resolvePdfModelConfigForTool: PdfToolModule["resolvePdfModelConfigForTool"]; + +async function importPdfToolModule(): Promise { + vi.resetModules(); + vi.doMock("@mariozechner/pi-ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + complete: completeMock, + }; + }); + return import("./pdf-tool.js"); +} async function withTempAgentDir(run: (agentDir: string) => Promise): Promise { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-")); @@ -242,8 +251,10 @@ describe("providerSupportsNativePdf", () => { describe("resolvePdfModelConfigForTool", () => { const priorFetch = global.fetch; - beforeEach(() => { + beforeEach(async () => { resetAuthEnv(); + completeMock.mockReset(); + ({ resolvePdfModelConfigForTool } = await importPdfToolModule()); }); afterEach(() => { @@ -321,8 +332,10 @@ describe("resolvePdfModelConfigForTool", () => { describe("createPdfTool", () => { const priorFetch = global.fetch; - beforeEach(() => { + beforeEach(async () => { resetAuthEnv(); + completeMock.mockReset(); + ({ createPdfTool } = await importPdfToolModule()); }); afterEach(() => { @@ -484,8 +497,7 @@ describe("createPdfTool", () => { images: [], }); - const piAi = await import("@mariozechner/pi-ai"); - vi.mocked(piAi.complete).mockResolvedValue({ + completeMock.mockResolvedValue({ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "fallback summary" }], From 2b980bfceed6eabf007c35bef2eb2c7456e07adc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:14:49 +0000 Subject: [PATCH 015/393] test: reuse run-node module imports --- src/infra/run-node.test.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index dfebf6c2ad2..9b6c871379b 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { runNodeMain } from "../../scripts/run-node.mjs"; async function withTempDir(run: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-run-node-")); @@ -70,7 +71,6 @@ describe("run-node script", () => { }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["--version"], @@ -130,7 +130,6 @@ describe("run-node script", () => { return createExitedProcess(0); }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -205,7 +204,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -233,7 +231,6 @@ describe("run-node script", () => { return createExitedProcess(0); }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -282,7 +279,6 @@ describe("run-node script", () => { }; const spawnSync = () => ({ status: 1, stdout: "" }); - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -354,7 +350,6 @@ describe("run-node script", () => { }; const spawnSync = () => ({ status: 1, stdout: "" }); - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -419,7 +414,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -490,7 +484,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -560,7 +553,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -636,7 +628,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -696,7 +687,6 @@ describe("run-node script", () => { }; const spawnSync = () => ({ status: 1, stdout: "" }); - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -758,7 +748,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], From a3f09d519d2237304ae1fd671fd51dde93bfe4fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:15:32 +0000 Subject: [PATCH 016/393] test: reuse git commit module exports --- src/infra/git-commit.test.ts | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index cffd27162b0..03af9a053ac 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -40,13 +40,15 @@ async function makeFakeGitRepo( describe("git commit resolution", () => { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + let resolveCommitHash: (typeof import("./git-commit.js"))["resolveCommitHash"]; + let __testing: (typeof import("./git-commit.js"))["__testing"]; beforeEach(async () => { vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); vi.resetModules(); - const { __testing } = await import("./git-commit.js"); + ({ resolveCommitHash, __testing } = await import("./git-commit.js")); __testing.clearCachedGitCommits(); }); @@ -54,9 +56,8 @@ describe("git commit resolution", () => { vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); - vi.resetModules(); - const { __testing } = await import("./git-commit.js"); __testing.clearCachedGitCommits(); + vi.resetModules(); }); it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { @@ -85,7 +86,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; vi.spyOn(process, "cwd").mockReturnValue(otherRepo); @@ -101,7 +101,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; expect( @@ -117,7 +116,6 @@ describe("git commit resolution", () => { it("caches build-info fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-build-info-cache"); - const { resolveCommitHash } = await import("./git-commit.js"); const readBuildInfoCommit = vi.fn(() => "deadbee"); expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe( @@ -133,7 +131,6 @@ describe("git commit resolution", () => { it("caches package.json fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-package-json-cache"); - const { resolveCommitHash } = await import("./git-commit.js"); const readPackageJsonCommit = vi.fn(() => "badc0ff"); expect( @@ -169,8 +166,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(() => resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} }), ).not.toThrow(); @@ -201,8 +196,6 @@ describe("git commit resolution", () => { ); const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; - const { resolveCommitHash } = await import("./git-commit.js"); - expect( resolveCommitHash({ moduleUrl, @@ -227,8 +220,6 @@ describe("git commit resolution", () => { head: "89abcdef0123456789abcdef0123456789abcdef\n", }); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); expect(resolveCommitHash({ cwd: repoB, env: {} })).toBe("89abcde"); expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); @@ -241,7 +232,6 @@ describe("git commit resolution", () => { head: "not-a-commit\n", }); - const { resolveCommitHash } = await import("./git-commit.js"); const readGitCommit = vi.fn(() => null); expect(resolveCommitHash({ cwd: repoRoot, env: {}, readers: { readGitCommit } })).toBeNull(); @@ -257,7 +247,6 @@ describe("git commit resolution", () => { await makeFakeGitRepo(repoRoot, { head: "0123456789abcdef0123456789abcdef01234567\n", }); - const { resolveCommitHash } = await import("./git-commit.js"); const readGitCommit = vi.fn(() => { const error = Object.assign(new Error(`EACCES: permission denied`), { code: "EACCES", @@ -294,8 +283,6 @@ describe("git commit resolution", () => { it("formats env-provided commit strings consistently", async () => { const temp = await makeTempDir("git-commit-env"); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "ABCDEF0123456789" } })).toBe( "abcdef0", ); @@ -308,8 +295,6 @@ describe("git commit resolution", () => { it("rejects unsafe HEAD refs and accepts valid refs", async () => { const temp = await makeTempDir("git-commit-refs"); - const { resolveCommitHash } = await import("./git-commit.js"); - const absoluteRepo = path.join(temp, "absolute"); await makeFakeGitRepo(absoluteRepo, { head: "ref: /tmp/evil\n" }); expect(resolveCommitHash({ cwd: absoluteRepo, env: {} })).toBeNull(); @@ -347,8 +332,6 @@ describe("git commit resolution", () => { commondir: "../common-git", }); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("bbbbbbb"); }); @@ -363,8 +346,6 @@ describe("git commit resolution", () => { }, }); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("ccccccc"); }); }); From 8a9dee9ac89f2a154b77aec2865c9f5c17454730 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:17:16 +0000 Subject: [PATCH 017/393] test: trim redundant context engine assertions --- src/context-engine/context-engine.test.ts | 75 +---------------------- 1 file changed, 3 insertions(+), 72 deletions(-) diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index f684e5596d5..82c3501343b 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -237,57 +237,6 @@ describe("Engine contract tests", () => { expect(engine.info.id).toBe("mock"); }); - it("ingest() returns IngestResult with ingested boolean", async () => { - const engine = new MockContextEngine(); - const result = await engine.ingest({ - sessionId: "s1", - message: makeMockMessage(), - }); - - expect(result).toHaveProperty("ingested"); - expect(typeof result.ingested).toBe("boolean"); - expect(result.ingested).toBe(true); - }); - - it("assemble() returns AssembleResult with messages array and estimatedTokens", async () => { - const engine = new MockContextEngine(); - const msgs = [makeMockMessage(), makeMockMessage("assistant", "world")]; - const result = await engine.assemble({ - sessionId: "s1", - messages: msgs, - }); - - expect(Array.isArray(result.messages)).toBe(true); - expect(result.messages).toHaveLength(2); - expect(typeof result.estimatedTokens).toBe("number"); - expect(result.estimatedTokens).toBe(42); - expect(result.systemPromptAddition).toBe("mock system addition"); - }); - - it("compact() returns CompactResult with ok, compacted, reason, result fields", async () => { - const engine = new MockContextEngine(); - const result = await engine.compact({ - sessionId: "s1", - sessionFile: "/tmp/session.json", - }); - - expect(typeof result.ok).toBe("boolean"); - expect(typeof result.compacted).toBe("boolean"); - expect(result.ok).toBe(true); - expect(result.compacted).toBe(true); - expect(result.reason).toBe("mock compaction"); - expect(result.result).toBeDefined(); - expect(result.result!.summary).toBe("mock summary"); - expect(result.result!.tokensBefore).toBe(100); - expect(result.result!.tokensAfter).toBe(50); - }); - - it("dispose() is callable (optional method)", async () => { - const engine = new MockContextEngine(); - // Should complete without error - await expect(engine.dispose()).resolves.toBeUndefined(); - }); - it("legacy compact preserves runtimeContext currentTokenCount when top-level value is absent", async () => { const engine = new LegacyContextEngine(); @@ -313,14 +262,7 @@ describe("Engine contract tests", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Registry tests", () => { - it("registerContextEngine() stores a factory", () => { - const factory = () => new MockContextEngine(); - registerContextEngine("reg-test-1", factory); - - expect(getContextEngineFactory("reg-test-1")).toBe(factory); - }); - - it("getContextEngineFactory() returns the factory", () => { + it("registerContextEngine() stores retrievable factories", () => { const factory = () => new MockContextEngine(); registerContextEngine("reg-test-2", factory); @@ -567,13 +509,7 @@ describe("Default engine selection", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Invalid engine fallback", () => { - it("resolveContextEngine() with config pointing to unregistered engine throws with helpful error", async () => { - await expect(resolveContextEngine(configWithSlot("nonexistent-engine"))).rejects.toThrow( - /nonexistent-engine/, - ); - }); - - it("error message includes the requested id and available ids", async () => { + it("includes the requested id and available ids in unknown-engine errors", async () => { // Ensure at least legacy is registered so we see it in the available list registerLegacyContextEngine(); @@ -639,16 +575,11 @@ describe("LegacyContextEngine parity", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Initialization guard", () => { - it("ensureContextEnginesInitialized() is idempotent (calling twice does not throw)", async () => { + it("ensureContextEnginesInitialized() is idempotent and registers legacy", async () => { const { ensureContextEnginesInitialized } = await import("./init.js"); expect(() => ensureContextEnginesInitialized()).not.toThrow(); expect(() => ensureContextEnginesInitialized()).not.toThrow(); - }); - - it("after init, 'legacy' engine is registered", async () => { - const { ensureContextEnginesInitialized } = await import("./init.js"); - ensureContextEnginesInitialized(); const ids = listContextEngineIds(); expect(ids).toContain("legacy"); From 40f1aad0191947cd74a4fb2ce3d53b753c6bd2ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:19:00 +0000 Subject: [PATCH 018/393] test: merge duplicate update cli scenarios --- src/cli/update-cli.test.ts | 152 ++++++++++++++++--------------------- 1 file changed, 67 insertions(+), 85 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 77593f876aa..97074f1c29f 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -206,6 +206,14 @@ describe("update-cli", () => { return call; }; + const expectPackageInstallSpec = (spec: string) => { + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", spec, "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + }; + const makeOkUpdateResult = (overrides: Partial = {}): UpdateRunResult => ({ status: "ok", @@ -456,18 +464,54 @@ describe("update-cli", () => { ); }); - it("honors --tag override", async () => { - const tempDir = createCaseDir("openclaw-update"); - - mockPackageInstallStatus(tempDir); - - await updateCommand({ tag: "next" }); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - ["npm", "i", "-g", "openclaw@next", "--no-fund", "--no-audit", "--loglevel=error"], - expect.any(Object), - ); + it("resolves package install specs from tags and env overrides", async () => { + for (const scenario of [ + { + name: "explicit dist-tag", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ tag: "next" }); + }, + expectedSpec: "openclaw@next", + }, + { + name: "main shorthand", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ yes: true, tag: "main" }); + }, + expectedSpec: "github:openclaw/openclaw#main", + }, + { + name: "explicit git package spec", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" }); + }, + expectedSpec: "github:openclaw/openclaw#main", + }, + { + name: "OPENCLAW_UPDATE_PACKAGE_SPEC override", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await withEnvAsync( + { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" }, + async () => { + await updateCommand({ yes: true, tag: "latest" }); + }, + ); + }, + expectedSpec: "http://10.211.55.2:8138/openclaw-next.tgz", + }, + ]) { + vi.clearAllMocks(); + readPackageName.mockResolvedValue("openclaw"); + readPackageVersion.mockResolvedValue("1.0.0"); + resolveGlobalManager.mockResolvedValue("npm"); + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); + await scenario.run(); + expectPackageInstallSpec(scenario.expectedSpec); + } }); it("prepends portable Git PATH for package updates on Windows", async () => { @@ -523,74 +567,6 @@ describe("update-cli", () => { expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1"); }); - it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for package updates", async () => { - const tempDir = createCaseDir("openclaw-update"); - mockPackageInstallStatus(tempDir); - - await withEnvAsync( - { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" }, - async () => { - await updateCommand({ yes: true, tag: "latest" }); - }, - ); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [ - "npm", - "i", - "-g", - "http://10.211.55.2:8138/openclaw-next.tgz", - "--no-fund", - "--no-audit", - "--loglevel=error", - ], - expect.any(Object), - ); - }); - - it("maps --tag main to the GitHub main package spec for package updates", async () => { - const tempDir = createCaseDir("openclaw-update"); - mockPackageInstallStatus(tempDir); - - await updateCommand({ yes: true, tag: "main" }); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [ - "npm", - "i", - "-g", - "github:openclaw/openclaw#main", - "--no-fund", - "--no-audit", - "--loglevel=error", - ], - expect.any(Object), - ); - }); - - it("passes explicit git package specs through for package updates", async () => { - const tempDir = createCaseDir("openclaw-update"); - mockPackageInstallStatus(tempDir); - - await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" }); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [ - "npm", - "i", - "-g", - "github:openclaw/openclaw#main", - "--no-fund", - "--no-audit", - "--loglevel=error", - ], - expect.any(Object), - ); - }); - it("updateCommand outputs JSON when --json is set", async () => { vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(defaultRuntime.log).mockClear(); @@ -758,12 +734,18 @@ describe("update-cli", () => { expect(runDaemonInstall).not.toHaveBeenCalled(); }); - it("updateCommand falls back to restart when env refresh install fails", async () => { - await runRestartFallbackScenario({ daemonInstall: "fail" }); - }); + it("updateCommand falls back to restart when service env refresh cannot complete", async () => { + for (const daemonInstall of ["fail", "ok"] as const) { + vi.clearAllMocks(); + vi.mocked(runDaemonRestart).mockResolvedValue(true); + await runRestartFallbackScenario({ daemonInstall }); - it("updateCommand falls back to restart when no detached restart script is available", async () => { - await runRestartFallbackScenario({ daemonInstall: "ok" }); + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runDaemonRestart).toHaveBeenCalled(); + } }); it("updateCommand does not refresh service env when --no-restart is set", async () => { From 91f055c10e0b0f46ece48c3dd718671b678bc3f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:19:51 +0000 Subject: [PATCH 019/393] test: preload plugin sdk subpath imports --- src/plugin-sdk/subpaths.test.ts | 52 +++++++++++++++------------------ 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 7a43a159b73..dc10258e324 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -41,6 +41,19 @@ const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ })); const asExports = (mod: object) => mod as Record; +const ircSdk = await import("openclaw/plugin-sdk/irc"); +const feishuSdk = await import("openclaw/plugin-sdk/feishu"); +const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); +const zaloSdk = await import("openclaw/plugin-sdk/zalo"); +const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); +const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); +const tlonSdk = await import("openclaw/plugin-sdk/tlon"); +const acpxSdk = await import("openclaw/plugin-sdk/acpx"); +const bluebubblesSdk = await import("openclaw/plugin-sdk/bluebubbles"); +const matrixSdk = await import("openclaw/plugin-sdk/matrix"); +const mattermostSdk = await import("openclaw/plugin-sdk/mattermost"); +const nextcloudTalkSdk = await import("openclaw/plugin-sdk/nextcloud-talk"); +const twitchSdk = await import("openclaw/plugin-sdk/twitch"); describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { @@ -162,7 +175,6 @@ describe("plugin-sdk subpath exports", () => { }); it("exports IRC helpers", async () => { - const ircSdk = await import("openclaw/plugin-sdk/irc"); expect(typeof ircSdk.resolveIrcAccount).toBe("function"); expect(typeof ircSdk.ircSetupWizard).toBe("object"); expect(typeof ircSdk.ircSetupAdapter).toBe("object"); @@ -177,7 +189,6 @@ describe("plugin-sdk subpath exports", () => { }); it("exports Feishu helpers", async () => { - const feishuSdk = await import("openclaw/plugin-sdk/feishu"); expect(typeof feishuSdk.feishuSetupWizard).toBe("object"); expect(typeof feishuSdk.feishuSetupAdapter).toBe("object"); }); @@ -202,38 +213,32 @@ describe("plugin-sdk subpath exports", () => { }); it("exports Google Chat helpers", async () => { - const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object"); expect(typeof googlechatSdk.googlechatSetupAdapter).toBe("object"); }); it("exports Zalo helpers", async () => { - const zaloSdk = await import("openclaw/plugin-sdk/zalo"); expect(typeof zaloSdk.zaloSetupWizard).toBe("object"); expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); }); it("exports Synology Chat helpers", async () => { - const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); expect(typeof synologyChatSdk.synologyChatSetupWizard).toBe("object"); expect(typeof synologyChatSdk.synologyChatSetupAdapter).toBe("object"); }); it("exports Zalouser helpers", async () => { - const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); expect(typeof zalouserSdk.zalouserSetupAdapter).toBe("object"); }); it("exports Tlon helpers", async () => { - const tlonSdk = await import("openclaw/plugin-sdk/tlon"); expect(typeof tlonSdk.fetchWithSsrFGuard).toBe("function"); expect(typeof tlonSdk.tlonSetupWizard).toBe("object"); expect(typeof tlonSdk.tlonSetupAdapter).toBe("object"); }); it("exports acpx helpers", async () => { - const acpxSdk = await import("openclaw/plugin-sdk/acpx"); expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function"); expect(typeof acpxSdk.omitEnvKeysCaseInsensitive).toBe("function"); }); @@ -247,26 +252,15 @@ describe("plugin-sdk subpath exports", () => { }); it("keeps the newly added bundled plugin-sdk contracts available", async () => { - const bluebubbles = await import("openclaw/plugin-sdk/bluebubbles"); - expect(typeof bluebubbles.parseFiniteNumber).toBe("function"); - - const matrix = await import("openclaw/plugin-sdk/matrix"); - expect(typeof matrix.matrixSetupWizard).toBe("object"); - expect(typeof matrix.matrixSetupAdapter).toBe("object"); - - const mattermost = await import("openclaw/plugin-sdk/mattermost"); - expect(typeof mattermost.parseStrictPositiveInteger).toBe("function"); - - const nextcloudTalk = await import("openclaw/plugin-sdk/nextcloud-talk"); - expect(typeof nextcloudTalk.waitForAbortSignal).toBe("function"); - - const twitch = await import("openclaw/plugin-sdk/twitch"); - expect(typeof twitch.DEFAULT_ACCOUNT_ID).toBe("string"); - expect(typeof twitch.normalizeAccountId).toBe("function"); - expect(typeof twitch.twitchSetupWizard).toBe("object"); - expect(typeof twitch.twitchSetupAdapter).toBe("object"); - - const zalo = await import("openclaw/plugin-sdk/zalo"); - expect(typeof zalo.resolveClientIp).toBe("function"); + expect(typeof bluebubblesSdk.parseFiniteNumber).toBe("function"); + expect(typeof matrixSdk.matrixSetupWizard).toBe("object"); + expect(typeof matrixSdk.matrixSetupAdapter).toBe("object"); + expect(typeof mattermostSdk.parseStrictPositiveInteger).toBe("function"); + expect(typeof nextcloudTalkSdk.waitForAbortSignal).toBe("function"); + expect(typeof twitchSdk.DEFAULT_ACCOUNT_ID).toBe("string"); + expect(typeof twitchSdk.normalizeAccountId).toBe("function"); + expect(typeof twitchSdk.twitchSetupWizard).toBe("object"); + expect(typeof twitchSdk.twitchSetupAdapter).toBe("object"); + expect(typeof zaloSdk.resolveClientIp).toBe("function"); }); }); From a53de5ad513021cbe085e411c77cb42b8be842e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:21:57 +0000 Subject: [PATCH 020/393] test: cache provider discovery fixtures --- .../contracts/discovery.contract.test.ts | 80 +++++++------------ 1 file changed, 30 insertions(+), 50 deletions(-) diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index c2ec44496bb..9035391ac9e 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -58,6 +58,21 @@ const modelStudioPlugin = (await import("../../../extensions/modelstudio/index.j const cloudflareAiGatewayPlugin = ( await import("../../../extensions/cloudflare-ai-gateway/index.js") ).default; +const qwenPortalProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); +const githubCopilotProvider = requireProvider( + registerProviders(githubCopilotPlugin), + "github-copilot", +); +const ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama"); +const vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm"); +const sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang"); +const minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax"); +const minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); +const modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio"); +const cloudflareAiGatewayProvider = requireProvider( + registerProviders(cloudflareAiGatewayPlugin), + "cloudflare-ai-gateway", +); function createModelConfig(id: string, name = id): ModelDefinitionConfig { return { @@ -76,14 +91,6 @@ function createModelConfig(id: string, name = id): ModelDefinitionConfig { }; } -function requireQwenPortalProvider() { - return requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); -} - -function requireGithubCopilotProvider() { - return requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); -} - function setQwenPortalOauthSnapshot() { replaceRuntimeAuthProfileStoreSnapshots([ { @@ -143,12 +150,11 @@ describe("provider discovery contract", () => { }); it("keeps qwen portal oauth marker fallback provider-owned", async () => { - const provider = requireQwenPortalProvider(); setQwenPortalOauthSnapshot(); await expect( runCatalog({ - provider, + provider: qwenPortalProvider, }), ).resolves.toEqual({ provider: { @@ -164,12 +170,11 @@ describe("provider discovery contract", () => { }); it("keeps qwen portal env api keys higher priority than oauth markers", async () => { - const provider = requireQwenPortalProvider(); setQwenPortalOauthSnapshot(); await expect( runCatalog({ - provider, + provider: qwenPortalProvider, env: { QWEN_PORTAL_API_KEY: "env-key" } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: "env-key" }), }), @@ -181,18 +186,15 @@ describe("provider discovery contract", () => { }); it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => { - const provider = requireGithubCopilotProvider(); - - await expect(runCatalog({ provider })).resolves.toBeNull(); + await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull(); }); it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => { - const provider = requireGithubCopilotProvider(); setGithubCopilotProfileSnapshot(); await expect( runCatalog({ - provider, + provider: githubCopilotProvider, }), ).resolves.toEqual({ provider: { @@ -203,7 +205,6 @@ describe("provider discovery contract", () => { }); it("keeps GitHub Copilot env-token base URL resolution provider-owned", async () => { - const provider = requireGithubCopilotProvider(); resolveCopilotApiTokenMock.mockResolvedValueOnce({ token: "copilot-api-token", baseUrl: "https://copilot-proxy.example.com", @@ -212,7 +213,7 @@ describe("provider discovery contract", () => { await expect( runCatalog({ - provider, + provider: githubCopilotProvider, env: { GITHUB_TOKEN: "github-env-token", } as NodeJS.ProcessEnv, @@ -233,11 +234,9 @@ describe("provider discovery contract", () => { }); it("keeps Ollama explicit catalog normalization provider-owned", async () => { - const provider = requireProvider(registerProviders(ollamaPlugin), "ollama"); - await expect( runProviderCatalog({ - provider, + provider: ollamaProvider, config: { models: { providers: { @@ -263,7 +262,6 @@ describe("provider discovery contract", () => { }); it("keeps Ollama empty autodiscovery disabled without keys or explicit config", async () => { - const provider = requireProvider(registerProviders(ollamaPlugin), "ollama"); buildOllamaProviderMock.mockResolvedValueOnce({ baseUrl: "http://127.0.0.1:11434", api: "ollama", @@ -272,7 +270,7 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: ollamaProvider, config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), @@ -282,7 +280,6 @@ describe("provider discovery contract", () => { }); it("keeps vLLM self-hosted discovery provider-owned", async () => { - const provider = requireProvider(registerProviders(vllmPlugin), "vllm"); buildVllmProviderMock.mockResolvedValueOnce({ baseUrl: "http://127.0.0.1:8000/v1", api: "openai-completions", @@ -291,7 +288,7 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: vllmProvider, config: {}, env: { VLLM_API_KEY: "env-vllm-key", @@ -315,7 +312,6 @@ describe("provider discovery contract", () => { }); it("keeps SGLang self-hosted discovery provider-owned", async () => { - const provider = requireProvider(registerProviders(sglangPlugin), "sglang"); buildSglangProviderMock.mockResolvedValueOnce({ baseUrl: "http://127.0.0.1:30000/v1", api: "openai-completions", @@ -324,7 +320,7 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: sglangProvider, config: {}, env: { SGLANG_API_KEY: "env-sglang-key", @@ -348,11 +344,9 @@ describe("provider discovery contract", () => { }); it("keeps MiniMax API catalog provider-owned", async () => { - const provider = requireProvider(registerProviders(minimaxPlugin), "minimax"); - await expect( runProviderCatalog({ - provider, + provider: minimaxProvider, config: {}, env: { MINIMAX_API_KEY: "minimax-key", @@ -374,7 +368,6 @@ describe("provider discovery contract", () => { }); it("keeps MiniMax portal oauth marker fallback provider-owned", async () => { - const provider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); replaceRuntimeAuthProfileStoreSnapshots([ { store: { @@ -394,7 +387,7 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: minimaxPortalProvider, config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), @@ -411,11 +404,9 @@ describe("provider discovery contract", () => { }); it("keeps MiniMax portal explicit base URL override provider-owned", async () => { - const provider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); - await expect( runProviderCatalog({ - provider, + provider: minimaxPortalProvider, config: { models: { providers: { @@ -439,11 +430,9 @@ describe("provider discovery contract", () => { }); it("keeps Model Studio catalog provider-owned", async () => { - const provider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio"); - await expect( runProviderCatalog({ - provider, + provider: modelStudioProvider, config: { models: { providers: { @@ -473,14 +462,9 @@ describe("provider discovery contract", () => { }); it("keeps Cloudflare AI Gateway catalog disabled without stored metadata", async () => { - const provider = requireProvider( - registerProviders(cloudflareAiGatewayPlugin), - "cloudflare-ai-gateway", - ); - await expect( runProviderCatalog({ - provider, + provider: cloudflareAiGatewayProvider, config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), @@ -489,10 +473,6 @@ describe("provider discovery contract", () => { }); it("keeps Cloudflare AI Gateway env-managed catalog provider-owned", async () => { - const provider = requireProvider( - registerProviders(cloudflareAiGatewayPlugin), - "cloudflare-ai-gateway", - ); replaceRuntimeAuthProfileStoreSnapshots([ { store: { @@ -518,7 +498,7 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: cloudflareAiGatewayProvider, config: {}, env: { CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value", From b8861b4815600e0744a77aa26c53a093fd4ca35d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:22:03 +0000 Subject: [PATCH 021/393] test: merge context lookup warmup cases --- src/agents/context.lookup.test.ts | 87 ++++++++++++------------------- 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 0f33ada0d1b..df0e67e6c68 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -50,9 +50,13 @@ function createContextOverrideConfig(provider: string, model: string, contextWin }; } +async function flushAsyncWarmup() { + await new Promise((r) => setTimeout(r, 0)); +} + async function importResolveContextTokensForModel() { const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); return resolveContextTokensForModel; } @@ -76,57 +80,34 @@ describe("lookupContextTokens", () => { expect(lookupContextTokens("openrouter/claude-sonnet")).toBe(321_000); }); - it("does not skip eager warmup when --profile is followed by -- terminator", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - + it("only warms eagerly for startup commands that need model metadata", async () => { const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"]; try { - await import("./context.js"); - expect(loadConfigMock).toHaveBeenCalledTimes(1); - } finally { - process.argv = argvSnapshot; - } - }); - - it("skips eager warmup for logs commands that do not need model metadata at startup", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - - const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "logs", "--limit", "5"]; - try { - await import("./context.js"); - expect(loadConfigMock).not.toHaveBeenCalled(); - } finally { - process.argv = argvSnapshot; - } - }); - - it("skips eager warmup for status commands that only read model metadata opportunistically", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - - const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "status", "--json"]; - try { - await import("./context.js"); - expect(loadConfigMock).not.toHaveBeenCalled(); - } finally { - process.argv = argvSnapshot; - } - }); - - it("skips eager warmup for gateway commands that do not need model metadata at startup", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - - const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "gateway", "status", "--json"]; - try { - await import("./context.js"); - expect(loadConfigMock).not.toHaveBeenCalled(); + for (const scenario of [ + { + argv: ["node", "openclaw", "--profile", "--", "config", "validate"], + expectedCalls: 1, + }, + { + argv: ["node", "openclaw", "logs", "--limit", "5"], + expectedCalls: 0, + }, + { + argv: ["node", "openclaw", "status", "--json"], + expectedCalls: 0, + }, + { + argv: ["node", "openclaw", "gateway", "status", "--json"], + expectedCalls: 0, + }, + ]) { + vi.resetModules(); + const loadConfigMock = vi.fn(() => ({ models: {} })); + mockContextModuleDeps(loadConfigMock); + process.argv = scenario.argv; + await import("./context.js"); + expect(loadConfigMock).toHaveBeenCalledTimes(scenario.expectedCalls); + } } finally { process.argv = argvSnapshot; } @@ -176,7 +157,7 @@ describe("lookupContextTokens", () => { const { lookupContextTokens } = await import("./context.js"); // Trigger async cache population. - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); // Conservative minimum: bare-id cache feeds runtime flush/compaction paths. expect(lookupContextTokens("gemini-3.1-pro-preview")).toBe(128_000); }); @@ -191,7 +172,7 @@ describe("lookupContextTokens", () => { ]); const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); // With provider specified and no config override, bare lookup finds the // provider-qualified discovery entry. @@ -277,7 +258,7 @@ describe("lookupContextTokens", () => { }; const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); // Exact key "qwen" wins over the alias-normalized match "qwen-portal". const qwenResult = resolveContextTokensForModel({ From dc3cb9349a7c8fb712e77524107d054a623c2920 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:23:21 +0000 Subject: [PATCH 022/393] test: trim lightweight status and capability suites --- .../plugins/message-capability-matrix.test.ts | 105 ++++++++---------- src/commands/status.summary.test.ts | 11 +- 2 files changed, 54 insertions(+), 62 deletions(-) diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index b8d289aa56b..9ab42ad4c51 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -75,18 +75,15 @@ describe("channel action capability matrix", () => { expect(result).toEqual(["interactive", "buttons"]); expect(telegramGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); - }); - - it("forwards Discord action capabilities through the channel wrapper", () => { discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); - const result = discordPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); + const discordResult = discordPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); - expect(result).toEqual(["interactive", "components"]); + expect(discordResult).toEqual(["interactive", "components"]); expect(discordGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); }); - it("exposes Mattermost buttons only when an account is configured", () => { + it("exposes configured channel capabilities only when required credentials are present", () => { const configuredCfg = { channels: { mattermost: { @@ -103,61 +100,57 @@ describe("channel action capability matrix", () => { }, }, } as OpenClawConfig; + const configuredFeishuCfg = { + channels: { + feishu: { + enabled: true, + appId: "cli_a", + appSecret: "secret", + }, + }, + } as OpenClawConfig; + const disabledFeishuCfg = { + channels: { + feishu: { + enabled: false, + appId: "cli_a", + appSecret: "secret", + }, + }, + } as OpenClawConfig; + const configuredMsteamsCfg = { + channels: { + msteams: { + enabled: true, + tenantId: "tenant", + appId: "app", + appPassword: "secret", + }, + }, + } as OpenClawConfig; + const disabledMsteamsCfg = { + channels: { + msteams: { + enabled: false, + tenantId: "tenant", + appId: "app", + appPassword: "secret", + }, + }, + } as OpenClawConfig; expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual([ "buttons", ]); expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: unconfiguredCfg })).toEqual([]); - }); - - it("exposes Feishu cards only when credentials are configured", () => { - const configuredCfg = { - channels: { - feishu: { - enabled: true, - appId: "cli_a", - appSecret: "secret", - }, - }, - } as OpenClawConfig; - const disabledCfg = { - channels: { - feishu: { - enabled: false, - appId: "cli_a", - appSecret: "secret", - }, - }, - } as OpenClawConfig; - - expect(feishuPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual(["cards"]); - expect(feishuPlugin.actions?.getCapabilities?.({ cfg: disabledCfg })).toEqual([]); - }); - - it("exposes MSTeams cards only when credentials are configured", () => { - const configuredCfg = { - channels: { - msteams: { - enabled: true, - tenantId: "tenant", - appId: "app", - appPassword: "secret", - }, - }, - } as OpenClawConfig; - const disabledCfg = { - channels: { - msteams: { - enabled: false, - tenantId: "tenant", - appId: "app", - appPassword: "secret", - }, - }, - } as OpenClawConfig; - - expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual(["cards"]); - expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: disabledCfg })).toEqual([]); + expect(feishuPlugin.actions?.getCapabilities?.({ cfg: configuredFeishuCfg })).toEqual([ + "cards", + ]); + expect(feishuPlugin.actions?.getCapabilities?.({ cfg: disabledFeishuCfg })).toEqual([]); + expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: configuredMsteamsCfg })).toEqual([ + "cards", + ]); + expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: disabledMsteamsCfg })).toEqual([]); }); it("keeps Zalo actions on the empty capability set", () => { diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 12ce55844c3..2f4f9ce260f 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -77,14 +77,17 @@ vi.mock("./status.link-channel.js", () => ({ resolveLinkChannelContext: vi.fn(async () => undefined), })); +const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); +const { buildChannelSummary } = await import("../infra/channel-summary.js"); +const { resolveLinkChannelContext } = await import("./status.link-channel.js"); +const { getStatusSummary } = await import("./status.summary.js"); + describe("getStatusSummary", () => { beforeEach(() => { vi.clearAllMocks(); }); it("includes runtimeVersion in the status payload", async () => { - const { getStatusSummary } = await import("./status.summary.js"); - const summary = await getStatusSummary(); expect(summary.runtimeVersion).toBe("2026.3.8"); @@ -93,11 +96,7 @@ describe("getStatusSummary", () => { }); it("skips channel summary imports when no channels are configured", async () => { - const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); vi.mocked(hasPotentialConfiguredChannels).mockReturnValue(false); - const { buildChannelSummary } = await import("../infra/channel-summary.js"); - const { resolveLinkChannelContext } = await import("./status.link-channel.js"); - const { getStatusSummary } = await import("./status.summary.js"); const summary = await getStatusSummary(); From 47a78a03a3046af4b8c30db5ffc6c4b2aa2810e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:25:50 +0000 Subject: [PATCH 023/393] test: merge telegram action matrix cases --- src/channels/plugins/actions/actions.test.ts | 273 +++++++++---------- 1 file changed, 131 insertions(+), 142 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 322e0f618f4..0e90c5bd5e0 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -540,84 +540,93 @@ describe("handleDiscordMessageAction", () => { }); describe("telegramMessageActions", () => { - it("lists poll when telegram is configured", () => { - const actions = telegramMessageActions.listActions?.({ cfg: telegramCfg() }) ?? []; - - expect(actions).toContain("poll"); - }); - - it("lists topic-edit when telegram topic edits are enabled", () => { - const cfg = { - channels: { - telegram: { - botToken: "tok", - actions: { editForumTopic: true }, - }, + it("computes poll/topic action availability from telegram config gates", () => { + for (const testCase of [ + { + name: "configured telegram enables poll", + cfg: telegramCfg(), + expectPoll: true, + expectTopicEdit: true, }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("topic-edit"); - }); - - it("omits poll when sendMessage is disabled", () => { - const cfg = { - channels: { - telegram: { - botToken: "tok", - actions: { sendMessage: false }, - }, - }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("poll"); - }); - - it("omits poll when poll actions are disabled", () => { - const cfg = { - channels: { - telegram: { - botToken: "tok", - actions: { poll: false }, - }, - }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("poll"); - }); - - it("omits poll when sendMessage and poll are split across accounts", () => { - const cfg = { - channels: { - telegram: { - accounts: { - senderOnly: { - botToken: "tok-send", - actions: { - sendMessage: true, - poll: false, - }, + { + name: "topic edit gate enables topic-edit", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { editForumTopic: true }, }, - pollOnly: { - botToken: "tok-poll", - actions: { - sendMessage: false, - poll: true, + }, + } as OpenClawConfig, + expectPoll: true, + expectTopicEdit: true, + }, + { + name: "sendMessage disabled hides poll", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { sendMessage: false }, + }, + }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, + }, + { + name: "poll gate disabled hides poll", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { poll: false }, + }, + }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, + }, + { + name: "split account gates do not expose poll", + cfg: { + channels: { + telegram: { + accounts: { + senderOnly: { + botToken: "tok-send", + actions: { + sendMessage: true, + poll: false, + }, + }, + pollOnly: { + botToken: "tok-poll", + actions: { + sendMessage: false, + poll: true, + }, + }, }, }, }, - }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("poll"); + ]) { + const actions = telegramMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + if (testCase.expectPoll) { + expect(actions, testCase.name).toContain("poll"); + } else { + expect(actions, testCase.name).not.toContain("poll"); + } + if (testCase.expectTopicEdit) { + expect(actions, testCase.name).toContain("topic-edit"); + } else { + expect(actions, testCase.name).not.toContain("topic-edit"); + } + } }); it("lists sticker actions only when enabled by config", () => { @@ -910,82 +919,62 @@ describe("telegramMessageActions", () => { expect(actions).not.toContain("react"); }); - it("accepts numeric messageId and channelId for reactions", async () => { + it("normalizes telegram reaction message identifiers before dispatch", async () => { const cfg = telegramCfg(); - - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: { - channelId: 123, - messageId: 456, - emoji: "ok", + for (const testCase of [ + { + name: "numeric channelId/messageId", + params: { + channelId: 123, + messageId: 456, + emoji: "ok", + }, + toolContext: undefined, + expectedChatId: "123", + expectedMessageId: "456", }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); - } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(String(callPayload.chatId)).toBe("123"); - expect(String(callPayload.messageId)).toBe("456"); - expect(callPayload.emoji).toBe("ok"); - }); - - it("accepts snake_case message_id for reactions", async () => { - const cfg = telegramCfg(); - - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: { - channelId: 123, - message_id: "456", - emoji: "ok", + { + name: "snake_case message_id", + params: { + channelId: 123, + message_id: "456", + emoji: "ok", + }, + toolContext: undefined, + expectedChatId: "123", + expectedMessageId: "456", }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); - } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(String(callPayload.chatId)).toBe("123"); - expect(String(callPayload.messageId)).toBe("456"); - }); - - it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { - const cfg = telegramCfg(); - - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: { - chatId: "123", - emoji: "ok", + { + name: "toolContext fallback", + params: { + chatId: "123", + emoji: "ok", + }, + toolContext: { currentMessageId: "9001" }, + expectedChatId: "123", + expectedMessageId: "9001", }, - cfg, - accountId: undefined, - toolContext: { currentMessageId: "9001" }, - }); + ] as const) { + handleTelegramAction.mockClear(); + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: testCase.params, + cfg, + accountId: undefined, + toolContext: testCase.toolContext, + }); - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); + expect(handleTelegramAction, testCase.name).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action, testCase.name).toBe("react"); + expect(String(callPayload.chatId), testCase.name).toBe(testCase.expectedChatId); + expect(String(callPayload.messageId), testCase.name).toBe(testCase.expectedMessageId); } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(String(callPayload.messageId)).toBe("9001"); }); it("forwards missing reaction messageId to telegram-actions for soft-fail handling", async () => { From 2f9e2f500f928f08c33cf478b2d2856fc0f5624c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:28:15 +0000 Subject: [PATCH 024/393] test: merge embeddings provider selection cases --- src/memory/embeddings.test.ts | 198 +++++++++++++++++----------------- 1 file changed, 101 insertions(+), 97 deletions(-) diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index e9a533f4f9d..911ca01f884 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -302,41 +302,6 @@ describe("embedding provider remote overrides", () => { }); describe("embedding provider auto selection", () => { - it("prefers openai when a key resolves", async () => { - vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { - if (provider === "openai") { - return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" }; - } - throw new Error(`No API key found for provider "${provider}".`); - }); - - const result = await createAutoProvider(); - expectAutoSelectedProvider(result, "openai"); - }); - - it("uses gemini when openai is missing", async () => { - const fetchMock = createGeminiFetchMock(); - vi.stubGlobal("fetch", fetchMock); - mockPublicPinnedHostname(); - vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { - if (provider === "openai") { - throw new Error('No API key found for provider "openai".'); - } - if (provider === "google") { - return { apiKey: "gemini-key", source: "env: GEMINI_API_KEY", mode: "api-key" }; - } - throw new Error(`Unexpected provider ${provider}`); - }); - - const result = await createAutoProvider(); - const provider = expectAutoSelectedProvider(result, "gemini"); - await provider.embedQuery("hello"); - const [url] = fetchMock.mock.calls[0] ?? []; - expect(url).toBe( - `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_GEMINI_EMBEDDING_MODEL}:embedContent`, - ); - }); - it("keeps explicit model when openai is selected", async () => { const fetchMock = vi.fn(async (_input?: unknown, _init?: unknown) => ({ ok: true, @@ -370,22 +335,63 @@ describe("embedding provider auto selection", () => { expect(payload.model).toBe("text-embedding-3-small"); }); - it("uses mistral when openai/gemini/voyage are missing", async () => { - const fetchMock = createFetchMock(); - vi.stubGlobal("fetch", fetchMock); - mockPublicPinnedHostname(); - vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { - if (provider === "mistral") { - return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; // pragma: allowlist secret - } - throw new Error(`No API key found for provider "${provider}".`); - }); + it("selects the first available remote provider in auto mode", async () => { + for (const testCase of [ + { + name: "openai first", + expectedProvider: "openai" as const, + fetchMockFactory: createFetchMock, + resolveApiKey(provider: string) { + if (provider === "openai") { + return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" }; + } + throw new Error(`No API key found for provider "${provider}".`); + }, + expectedUrl: "https://api.openai.com/v1/embeddings", + }, + { + name: "gemini fallback", + expectedProvider: "gemini" as const, + fetchMockFactory: createGeminiFetchMock, + resolveApiKey(provider: string) { + if (provider === "openai") { + throw new Error('No API key found for provider "openai".'); + } + if (provider === "google") { + return { apiKey: "gemini-key", source: "env: GEMINI_API_KEY", mode: "api-key" }; + } + throw new Error(`Unexpected provider ${provider}`); + }, + expectedUrl: `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_GEMINI_EMBEDDING_MODEL}:embedContent`, + }, + { + name: "mistral after earlier misses", + expectedProvider: "mistral" as const, + fetchMockFactory: createFetchMock, + resolveApiKey(provider: string) { + if (provider === "mistral") { + return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; + } + throw new Error(`No API key found for provider "${provider}".`); + }, + expectedUrl: "https://api.mistral.ai/v1/embeddings", + }, + ]) { + vi.resetAllMocks(); + vi.unstubAllGlobals(); + const fetchMock = testCase.fetchMockFactory(); + vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); + vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => + testCase.resolveApiKey(provider), + ); - const result = await createAutoProvider(); - const provider = expectAutoSelectedProvider(result, "mistral"); - await provider.embedQuery("hello"); - const [url] = fetchMock.mock.calls[0] ?? []; - expect(url).toBe("https://api.mistral.ai/v1/embeddings"); + const result = await createAutoProvider(); + const provider = expectAutoSelectedProvider(result, testCase.expectedProvider); + await provider.embedQuery("hello"); + const [url] = fetchMock.mock.calls[0] ?? []; + expect(url, testCase.name).toBe(testCase.expectedUrl); + } }); }); @@ -661,56 +667,54 @@ describe("local embedding ensureContext concurrency", () => { }); describe("FTS-only fallback when no provider available", () => { - it("returns null provider with reason when auto mode finds no providers", async () => { - vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( - new Error('No API key found for provider "openai"'), - ); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "auto", - model: "", - fallback: "none", - }); - - expect(result.provider).toBeNull(); - expect(result.requestedProvider).toBe("auto"); - expect(result.providerUnavailableReason).toBeDefined(); - expect(result.providerUnavailableReason).toContain("No API key"); - }); - - it("returns null provider when explicit provider fails with missing API key", async () => { - vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( - new Error('No API key found for provider "openai"'), - ); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "openai", - model: "text-embedding-3-small", - fallback: "none", - }); - - expect(result.provider).toBeNull(); - expect(result.requestedProvider).toBe("openai"); - expect(result.providerUnavailableReason).toBeDefined(); - }); - - it("returns null provider when both primary and fallback fail with missing API keys", async () => { + it("returns null provider when all requested auth paths fail", async () => { vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( new Error("No API key found for provider"), ); - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "openai", - model: "text-embedding-3-small", - fallback: "gemini", - }); - - expect(result.provider).toBeNull(); - expect(result.requestedProvider).toBe("openai"); - expect(result.fallbackFrom).toBe("openai"); - expect(result.providerUnavailableReason).toContain("Fallback to gemini failed"); + for (const testCase of [ + { + name: "auto mode", + options: { + config: {} as never, + provider: "auto" as const, + model: "", + fallback: "none" as const, + }, + requestedProvider: "auto", + fallbackFrom: undefined, + reasonIncludes: "No API key", + }, + { + name: "explicit provider only", + options: { + config: {} as never, + provider: "openai" as const, + model: "text-embedding-3-small", + fallback: "none" as const, + }, + requestedProvider: "openai", + fallbackFrom: undefined, + reasonIncludes: "No API key", + }, + { + name: "primary and fallback", + options: { + config: {} as never, + provider: "openai" as const, + model: "text-embedding-3-small", + fallback: "gemini" as const, + }, + requestedProvider: "openai", + fallbackFrom: "openai", + reasonIncludes: "Fallback to gemini failed", + }, + ]) { + const result = await createEmbeddingProvider(testCase.options); + expect(result.provider, testCase.name).toBeNull(); + expect(result.requestedProvider, testCase.name).toBe(testCase.requestedProvider); + expect(result.fallbackFrom, testCase.name).toBe(testCase.fallbackFrom); + expect(result.providerUnavailableReason, testCase.name).toContain(testCase.reasonIncludes); + } }); }); From 5f0c46614624b95326e266d05902b82d5bb211b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:29:34 +0000 Subject: [PATCH 025/393] test: preload inbound contract fixtures --- .../contracts/inbound.contract.test.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts index aeb231cb628..f4f3ffa0a87 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -62,6 +62,18 @@ vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => deliverWebReply: vi.fn(async () => {}), })); +const { processDiscordMessage } = + await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); +const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = + await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); +const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); +const { prepareSlackMessage } = + await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); +const { createInboundSlackTestContext } = + await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"); +const { buildTelegramMessageContextForTest } = + await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js"); + function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { return { accountId: "default", @@ -94,10 +106,6 @@ describe("channel inbound contract", () => { }); it("keeps Discord inbound context finalized", async () => { - const { processDiscordMessage } = - await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); - const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = - await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); const messageCtx = await createBaseDiscordMessageContext({ cfg: { messages: {} }, ackReactionScope: "direct", @@ -111,7 +119,6 @@ describe("channel inbound contract", () => { }); it("keeps Signal inbound context finalized", async () => { - const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", @@ -139,10 +146,6 @@ describe("channel inbound contract", () => { }); it("keeps Slack inbound context finalized", async () => { - const { prepareSlackMessage } = - await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); - const { createInboundSlackTestContext } = - await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"); const ctx = createInboundSlackTestContext({ cfg: { channels: { slack: { enabled: true } }, @@ -163,9 +166,6 @@ describe("channel inbound contract", () => { }); it("keeps Telegram inbound context finalized", async () => { - const { buildTelegramMessageContextForTest } = - await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js"); - const context = await buildTelegramMessageContextForTest({ cfg: { agents: { @@ -200,7 +200,6 @@ describe("channel inbound contract", () => { }); it("keeps WhatsApp inbound context finalized", async () => { - const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", From 604c2636b97270b13118a36cc6790704f6f7f13a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:31:34 +0000 Subject: [PATCH 026/393] test: merge message action media sandbox cases --- .../message-action-runner.media.test.ts | 156 +++++++++--------- 1 file changed, 75 insertions(+), 81 deletions(-) diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index ba24bdb15df..fbbb9e6e2c8 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -239,68 +239,63 @@ describe("runMessageAction media behavior", () => { ); }); - it("rewrites sandboxed media paths for sendAttachment", async () => { - await withSandbox(async (sandboxDir) => { - await runMessageAction({ - cfg, - action: "sendAttachment", - params: { - channel: "bluebubbles", - target: "+15551234567", - media: "./data/pic.png", - message: "caption", - }, - sandboxRoot: sandboxDir, + it("enforces sandboxed attachment paths for attachment actions", async () => { + for (const testCase of [ + { + name: "sendAttachment rewrite", + action: "sendAttachment" as const, + target: "+15551234567", + media: "./data/pic.png", + message: "caption", + expectedPath: path.join("data", "pic.png"), + }, + { + name: "setGroupIcon rewrite", + action: "setGroupIcon" as const, + target: "group:123", + media: "./icons/group.png", + expectedPath: path.join("icons", "group.png"), + }, + ]) { + vi.mocked(loadWebMedia).mockClear(); + await withSandbox(async (sandboxDir) => { + await runMessageAction({ + cfg, + action: testCase.action, + params: { + channel: "bluebubbles", + target: testCase.target, + media: testCase.media, + ...(testCase.message ? { message: testCase.message } : {}), + }, + sandboxRoot: sandboxDir, + }); + + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[0], testCase.name).toBe(path.join(sandboxDir, testCase.expectedPath)); + expect(call?.[1], testCase.name).toEqual( + expect.objectContaining({ + sandboxValidated: true, + }), + ); }); + } - const call = vi.mocked(loadWebMedia).mock.calls[0]; - expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); - expect(call?.[1]).toEqual( - expect.objectContaining({ - sandboxValidated: true, - }), - ); - }); - }); - - it("rewrites sandboxed media paths for setGroupIcon", async () => { - await withSandbox(async (sandboxDir) => { - await runMessageAction({ - cfg, - action: "setGroupIcon", - params: { - channel: "bluebubbles", - target: "group:123", - media: "./icons/group.png", - }, - sandboxRoot: sandboxDir, - }); - - const call = vi.mocked(loadWebMedia).mock.calls[0]; - expect(call?.[0]).toBe(path.join(sandboxDir, "icons", "group.png")); - expect(call?.[1]).toEqual( - expect.objectContaining({ - sandboxValidated: true, - }), - ); - }); - }); - - it("rejects local absolute path for sendAttachment when sandboxRoot is missing", async () => { - await expectRejectsLocalAbsolutePathWithoutSandbox({ - action: "sendAttachment", - target: "+15551234567", - message: "caption", - tempPrefix: "msg-attachment-", - }); - }); - - it("rejects local absolute path for setGroupIcon when sandboxRoot is missing", async () => { - await expectRejectsLocalAbsolutePathWithoutSandbox({ - action: "setGroupIcon", - target: "group:123", - tempPrefix: "msg-group-icon-", - }); + for (const testCase of [ + { + action: "sendAttachment" as const, + target: "+15551234567", + message: "caption", + tempPrefix: "msg-attachment-", + }, + { + action: "setGroupIcon" as const, + target: "group:123", + tempPrefix: "msg-group-icon-", + }, + ]) { + await expectRejectsLocalAbsolutePathWithoutSandbox(testCase); + } }); }); @@ -356,36 +351,35 @@ describe("runMessageAction media behavior", () => { ).rejects.toThrow(/data:/i); }); - it("rewrites sandbox-relative media paths", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, + it("rewrites in-sandbox media references before dry send", async () => { + for (const testCase of [ + { + name: "relative media path", media: "./data/file.txt", message: "", expectedRelativePath: path.join("data", "file.txt"), - }); - }); - }); - - it("rewrites /workspace media paths to host sandbox root", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, + }, + { + name: "/workspace media path", media: "/workspace/data/file.txt", message: "", expectedRelativePath: path.join("data", "file.txt"), - }); - }); - }); - - it("rewrites MEDIA directives under sandbox", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, + }, + { + name: "MEDIA directive", message: "Hello\nMEDIA: ./data/note.ogg", expectedRelativePath: path.join("data", "note.ogg"), + }, + ]) { + await withSandbox(async (sandboxDir) => { + await expectSandboxMediaRewrite({ + sandboxDir, + media: testCase.media, + message: testCase.message, + expectedRelativePath: testCase.expectedRelativePath, + }); }); - }); + } }); it("allows media paths under preferred OpenClaw tmp root", async () => { From 74cc748ff7dace597a247111b2be8acef31aba7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:31:45 +0000 Subject: [PATCH 027/393] test: merge pid alive linux stat cases --- src/shared/pid-alive.test.ts | 59 ++++++++++-------------------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/src/shared/pid-alive.test.ts b/src/shared/pid-alive.test.ts index 88066f1a794..70eaaadc5a5 100644 --- a/src/shared/pid-alive.test.ts +++ b/src/shared/pid-alive.test.ts @@ -77,17 +77,27 @@ describe("isPidAlive", () => { }); describe("getProcessStartTime", () => { - it("returns a number on Linux for the current process", async () => { - // Simulate a realistic /proc//stat line - const fakeStat = `${process.pid} (node) S 1 ${process.pid} ${process.pid} 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 98765 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`; + it("parses linux /proc stat start times and rejects malformed variants", async () => { + const fakeStatPrefix = "42 (node) S 1 42 42 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 "; + const fakeStatSuffix = + " 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0"; mockProcReads({ - [`/proc/${process.pid}/stat`]: fakeStat, + [`/proc/${process.pid}/stat`]: `${process.pid} (node) S 1 ${process.pid} ${process.pid} 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 98765 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`, + "/proc/42/stat": `${fakeStatPrefix}55555${fakeStatSuffix}`, + "/proc/43/stat": "43 node S malformed", + "/proc/44/stat": `44 (My App (v2)) S 1 44 44 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 66666 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`, + "/proc/45/stat": `${fakeStatPrefix}-1${fakeStatSuffix}`, + "/proc/46/stat": `${fakeStatPrefix}1.5${fakeStatSuffix}`, }); await withLinuxProcessPlatform(async () => { const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - const starttime = fresh(process.pid); - expect(starttime).toBe(98765); + expect(fresh(process.pid)).toBe(98765); + expect(fresh(42)).toBe(55555); + expect(fresh(43)).toBeNull(); + expect(fresh(44)).toBe(66666); + expect(fresh(45)).toBeNull(); + expect(fresh(46)).toBeNull(); }); }); @@ -107,41 +117,4 @@ describe("getProcessStartTime", () => { expect(getProcessStartTime(Number.NaN)).toBeNull(); expect(getProcessStartTime(Number.POSITIVE_INFINITY)).toBeNull(); }); - - it("returns null for malformed /proc stat content", async () => { - mockProcReads({ - "/proc/42/stat": "42 node S malformed", - }); - await withLinuxProcessPlatform(async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(42)).toBeNull(); - }); - }); - - it("handles comm fields containing spaces and parentheses", async () => { - // comm field with spaces and nested parens: "(My App (v2))" - const fakeStat = `42 (My App (v2)) S 1 42 42 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 55555 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`; - mockProcReads({ - "/proc/42/stat": fakeStat, - }); - await withLinuxProcessPlatform(async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(42)).toBe(55555); - }); - }); - - it("returns null for negative or non-integer start times", async () => { - const fakeStatPrefix = "42 (node) S 1 42 42 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 "; - const fakeStatSuffix = - " 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0"; - mockProcReads({ - "/proc/42/stat": `${fakeStatPrefix}-1${fakeStatSuffix}`, - "/proc/43/stat": `${fakeStatPrefix}1.5${fakeStatSuffix}`, - }); - await withLinuxProcessPlatform(async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(42)).toBeNull(); - expect(fresh(43)).toBeNull(); - }); - }); }); From eef0f5bfbc8e1208f99af354c67c77a94d027f68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:33:05 +0000 Subject: [PATCH 028/393] test: merge tts config gating cases --- src/tts/tts.test.ts | 151 ++++++++++++++++++++++++-------------------- 1 file changed, 81 insertions(+), 70 deletions(-) diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 05e902ef20c..ade83c0b30a 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -596,49 +596,54 @@ describe("tts", () => { messages: { tts: {} }, }; - it("defaults to the official OpenAI endpoint", () => { - withEnv({ OPENAI_TTS_BASE_URL: undefined }, () => { - const config = resolveTtsConfig(baseCfg); - expect(config.openai.baseUrl).toBe("https://api.openai.com/v1"); - }); - }); - - it("picks up OPENAI_TTS_BASE_URL env var when no config baseUrl is set", () => { - withEnv({ OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, () => { - const config = resolveTtsConfig(baseCfg); - expect(config.openai.baseUrl).toBe("http://localhost:8880/v1"); - }); - }); - - it("config baseUrl takes precedence over env var", () => { - const cfg: OpenClawConfig = { - ...baseCfg, - messages: { - tts: { openai: { baseUrl: "http://my-server:9000/v1" } }, + it("resolves openai.baseUrl from config/env with config precedence and slash trimming", () => { + for (const testCase of [ + { + name: "default endpoint", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: undefined }, + expected: "https://api.openai.com/v1", }, - }; - withEnv({ OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, () => { - const config = resolveTtsConfig(cfg); - expect(config.openai.baseUrl).toBe("http://my-server:9000/v1"); - }); - }); - - it("strips trailing slashes from the resolved baseUrl", () => { - const cfg: OpenClawConfig = { - ...baseCfg, - messages: { - tts: { openai: { baseUrl: "http://my-server:9000/v1///" } }, + { + name: "env override", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, + expected: "http://localhost:8880/v1", }, - }; - const config = resolveTtsConfig(cfg); - expect(config.openai.baseUrl).toBe("http://my-server:9000/v1"); - }); - - it("strips trailing slashes from env var baseUrl", () => { - withEnv({ OPENAI_TTS_BASE_URL: "http://localhost:8880/v1/" }, () => { - const config = resolveTtsConfig(baseCfg); - expect(config.openai.baseUrl).toBe("http://localhost:8880/v1"); - }); + { + name: "config wins over env", + cfg: { + ...baseCfg, + messages: { + tts: { openai: { baseUrl: "http://my-server:9000/v1" } }, + }, + } as OpenClawConfig, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, + expected: "http://my-server:9000/v1", + }, + { + name: "config slash trimming", + cfg: { + ...baseCfg, + messages: { + tts: { openai: { baseUrl: "http://my-server:9000/v1///" } }, + }, + } as OpenClawConfig, + env: { OPENAI_TTS_BASE_URL: undefined }, + expected: "http://my-server:9000/v1", + }, + { + name: "env slash trimming", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1/" }, + expected: "http://localhost:8880/v1", + }, + ] as const) { + withEnv(testCase.env, () => { + const config = resolveTtsConfig(testCase.cfg); + expect(config.openai.baseUrl, testCase.name).toBe(testCase.expected); + }); + } }); }); @@ -678,12 +683,13 @@ describe("tts", () => { }); } - it("omits instructions for unsupported speech models", async () => { - await expectTelephonyInstructions("tts-1", undefined); - }); - - it("includes instructions for gpt-4o-mini-tts", async () => { - await expectTelephonyInstructions("gpt-4o-mini-tts", "Speak warmly"); + it("only includes instructions for supported telephony models", async () => { + for (const testCase of [ + { model: "tts-1", expectedInstructions: undefined }, + { model: "gpt-4o-mini-tts", expectedInstructions: "Speak warmly" }, + ] as const) { + await expectTelephonyInstructions(testCase.model, testCase.expectedInstructions); + } }); }); @@ -769,31 +775,36 @@ describe("tts", () => { } }); - it("skips auto-TTS in tagged mode unless a tts tag is present", async () => { - await withMockedAutoTtsFetch(async (fetchMock) => { - const payload = { text: "Hello world" }; - const result = await maybeApplyTtsToPayload({ - payload, - cfg: taggedCfg, - kind: "final", - }); - - expect(result).toBe(payload); - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - it("runs auto-TTS in tagged mode when tags are present", async () => { - await withMockedAutoTtsFetch(async (fetchMock) => { - const result = await maybeApplyTtsToPayload({ + it("respects tagged-mode auto-TTS gating", async () => { + for (const testCase of [ + { + name: "plain text is skipped", + payload: { text: "Hello world" }, + expectedFetchCalls: 0, + expectSamePayload: true, + }, + { + name: "tagged text is synthesized", payload: { text: "[[tts:text]]Hello world[[/tts:text]]" }, - cfg: taggedCfg, - kind: "final", - }); + expectedFetchCalls: 1, + expectSamePayload: false, + }, + ] as const) { + await withMockedAutoTtsFetch(async (fetchMock) => { + const result = await maybeApplyTtsToPayload({ + payload: testCase.payload, + cfg: taggedCfg, + kind: "final", + }); - expect(result.mediaUrl).toBeDefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls); + if (testCase.expectSamePayload) { + expect(result, testCase.name).toBe(testCase.payload); + } else { + expect(result.mediaUrl, testCase.name).toBeDefined(); + } + }); + } }); }); }); From d1df3f37a674f4535d27d4e62dde61b0a5a4ba45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:34:05 +0000 Subject: [PATCH 029/393] test: trim signal and slack action cases --- src/channels/plugins/actions/actions.test.ts | 81 ++++++++++---------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 0e90c5bd5e0..dc79ba3247e 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1130,52 +1130,55 @@ describe("signalMessageActions", () => { ); }); - it("rejects reaction when neither messageId nor toolContext.currentMessageId is provided", async () => { + it("rejects invalid signal reaction inputs before dispatch", async () => { const cfg = { channels: { signal: { account: "+15550001111" } }, } as OpenClawConfig; - await expectSignalActionRejected( - { to: "+15559999999", emoji: "✅" }, - /messageId.*required/, - cfg, - ); - }); - - it("requires targetAuthor for group reactions", async () => { - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - await expectSignalActionRejected( - { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, - /targetAuthor/, - cfg, - ); + for (const testCase of [ + { + params: { to: "+15559999999", emoji: "✅" }, + error: /messageId.*required/, + }, + { + params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, + error: /targetAuthor/, + }, + ] as const) { + await expectSignalActionRejected(testCase.params, testCase.error, cfg); + } }); }); describe("slack actions adapter", () => { - it("forwards threadId for read", async () => { - await runSlackAction("read", { - channelId: "C1", - threadId: "171234.567", - }); - - expectFirstSlackAction({ - action: "readMessages", - channelId: "C1", - threadId: "171234.567", - }); - }); - - it("forwards normalized limit for emoji-list", async () => { - await runSlackAction("emoji-list", { - limit: "2.9", - }); - - expectFirstSlackAction({ - action: "emojiList", - limit: 2, - }); + it("forwards simple slack action params", async () => { + for (const testCase of [ + { + action: "read" as const, + params: { + channelId: "C1", + threadId: "171234.567", + }, + expected: { + action: "readMessages", + channelId: "C1", + threadId: "171234.567", + }, + }, + { + action: "emoji-list" as const, + params: { + limit: "2.9", + }, + expected: { + action: "emojiList", + limit: 2, + }, + }, + ] as const) { + handleSlackAction.mockClear(); + await runSlackAction(testCase.action, testCase.params); + expectFirstSlackAction(testCase.expected); + } }); it("forwards blocks for send/edit actions", async () => { From 61a7d856e7f8eee5463d6bddad1fda78a69f500a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:41:15 +0000 Subject: [PATCH 030/393] test: harden commands test module seams --- src/auto-reply/reply/commands.test.ts | 205 +++++++++++++------------- 1 file changed, 99 insertions(+), 106 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 5ed9919b7e8..bd59a708fa7 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2,28 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { abortEmbeddedPiRun, compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; -import { - addSubagentRunForTests, - listSubagentRunsForRequester, - resetSubagentRegistryForTests, -} from "../../agents/subagent-registry.js"; -import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import { updateSessionStore, type SessionEntry } from "../../config/sessions.js"; -import * as internalHooks from "../../hooks/internal-hooks.js"; -import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import type { MsgContext } from "../templating.js"; -import { resetBashChatCommandForTests } from "./bash-command.js"; -import { handleCompactCommand } from "./commands-compact.js"; -import { buildCommandsPaginationKeyboard } from "./commands-info.js"; -import { extractMessageText } from "./commands-subagents.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; -import { parseConfigCommand } from "./config-commands.js"; -import { parseDebugCommand } from "./debug-commands.js"; -import { parseInlineDirectives } from "./directive-handling.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); @@ -101,13 +84,12 @@ vi.mock("./session-updates.js", () => ({ incrementCompactionCount: vi.fn(), })); -const callGatewayMock = vi.fn(); +const callGatewayMock = vi.hoisted(() => vi.fn()); vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), + callGateway: callGatewayMock, })); import type { HandleCommandsParams } from "./commands-types.js"; -import { buildCommandContext, handleCommands } from "./commands.js"; // Avoid expensive workspace scans during /context tests. vi.mock("./commands-context-report.js", () => ({ @@ -123,6 +105,26 @@ vi.mock("./commands-context-report.js", () => ({ }, })); +vi.resetModules(); + +const { addSubagentRunForTests, listSubagentRunsForRequester, resetSubagentRegistryForTests } = + await import("../../agents/subagent-registry.js"); +const { setDefaultChannelPluginRegistryForTests } = + await import("../../commands/channel-test-helpers.js"); +const internalHooks = await import("../../hooks/internal-hooks.js"); +const { clearPluginCommands, registerPluginCommand } = await import("../../plugins/commands.js"); +const { abortEmbeddedPiRun, compactEmbeddedPiSession } = + await import("../../agents/pi-embedded.js"); +const { resetBashChatCommandForTests } = await import("./bash-command.js"); +const { handleCompactCommand } = await import("./commands-compact.js"); +const { buildCommandsPaginationKeyboard } = await import("./commands-info.js"); +const { extractMessageText } = await import("./commands-subagents.js"); +const { buildCommandTestParams } = await import("./commands.test-harness.js"); +const { parseConfigCommand } = await import("./config-commands.js"); +const { parseDebugCommand } = await import("./debug-commands.js"); +const { parseInlineDirectives } = await import("./directive-handling.js"); +const { buildCommandContext, handleCommands } = await import("./commands.js"); + let testWorkspaceDir = os.tmpdir(); beforeAll(async () => { @@ -323,6 +325,24 @@ describe("/approve command", () => { vi.clearAllMocks(); }); + function createTelegramApproveCfg( + execApprovals: { + enabled: true; + approvers: string[]; + target: "dm"; + } | null = { enabled: true, approvers: ["123"], target: "dm" }, + ): OpenClawConfig { + return { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + ...(execApprovals ? { execApprovals } : {}), + }, + }, + } as OpenClawConfig; + } + it("rejects invalid usage", async () => { const cfg = { commands: { text: true }, @@ -355,15 +375,7 @@ describe("/approve command", () => { }); it("accepts Telegram command mentions for /approve", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, - }, - }, - } as OpenClawConfig; + const cfg = createTelegramApproveCfg(); const params = buildParams("/approve@bot abc12345 allow-once", cfg, { BotUsername: "bot", Provider: "telegram", @@ -384,90 +396,71 @@ describe("/approve command", () => { ); }); - it("rejects Telegram /approve mentions targeting a different bot", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + it("rejects unauthorized or invalid Telegram /approve variants", async () => { + for (const testCase of [ + { + name: "different bot mention", + cfg: createTelegramApproveCfg(), + commandBody: "/approve@otherbot abc12345 allow-once", + ctx: { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", }, + setup: undefined, + expectedText: "targets a different Telegram bot", + expectGatewayCalls: 0, }, - } as OpenClawConfig; - const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, { - BotUsername: "bot", - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("targets a different Telegram bot"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("surfaces unknown or expired approval id errors", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + { + name: "unknown approval id", + cfg: createTelegramApproveCfg(), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", }, + setup: () => callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")), + expectedText: "unknown or expired approval id", + expectGatewayCalls: 1, }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("unknown or expired approval id"); - }); - - it("rejects Telegram /approve when telegram exec approvals are disabled", async () => { - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Telegram exec approvals are not enabled"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("rejects Telegram /approve from non-approvers", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["999"], target: "dm" }, + { + name: "telegram approvals disabled", + cfg: createTelegramApproveCfg(null), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", }, + setup: undefined, + expectedText: "Telegram exec approvals are not enabled", + expectGatewayCalls: 0, }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); + { + name: "non approver", + cfg: createTelegramApproveCfg({ enabled: true, approvers: ["999"], target: "dm" }), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }, + setup: undefined, + expectedText: "not authorized to approve", + expectGatewayCalls: 0, + }, + ] as const) { + callGatewayMock.mockReset(); + testCase.setup?.(); + const params = buildParams(testCase.commandBody, testCase.cfg, testCase.ctx); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("not authorized to approve"); - expect(callGatewayMock).not.toHaveBeenCalled(); + const result = await handleCommands(params); + expect(result.shouldContinue, testCase.name).toBe(false); + expect(result.reply?.text, testCase.name).toContain(testCase.expectedText); + expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectGatewayCalls); + } }); it("rejects gateway clients without approvals scope", async () => { From 7c3efaeccf33e66b071ead6bed3fc18ac868690d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:43:09 +0000 Subject: [PATCH 031/393] test: merge bundle loader fixture cases --- src/plugins/loader.test.ts | 313 ++++++++++++++++--------------------- 1 file changed, 136 insertions(+), 177 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 151a1ddaf59..e5ae16945ca 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -321,6 +321,48 @@ function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: return { root, srcFile, distFile }; } +function loadBundleFixture(params: { + pluginId: string; + build: (bundleRoot: string) => void; + env?: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; +}) { + useNoBundledPlugins(); + const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", params.pluginId); + params.build(bundleRoot); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, ...params.env }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: params.onlyPluginIds ?? [params.pluginId], + config: { + plugins: { + entries: { + [params.pluginId]: { + enabled: true, + }, + }, + }, + }, + cache: false, + }), + ); +} + +function expectNoUnwiredBundleDiagnostic( + registry: ReturnType, + pluginId: string, +) { + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === pluginId && + diag.message.includes("bundle capability detected but not wired"), + ), + ).toBe(false); +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -376,147 +418,113 @@ describe("bundle plugins", () => { expect(plugin?.bundleCapabilities).toContain("skills"); }); - it("treats Claude command roots and settings as supported bundle surfaces", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-skills"); - mkdirSafe(path.join(bundleRoot, "commands")); - fs.writeFileSync( - path.join(bundleRoot, "commands", "review.md"), - "---\ndescription: fixture\n---\n", - ); - fs.writeFileSync(path.join(bundleRoot, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); - - const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => - loadOpenClawPlugins({ - workspaceDir, - onlyPluginIds: ["claude-skills"], - config: { - plugins: { - entries: { - "claude-skills": { - enabled: true, + it.each([ + { + name: "treats Claude command roots and settings as supported bundle surfaces", + pluginId: "claude-skills", + expectedFormat: "claude", + expectedCapabilities: ["skills", "commands", "settings"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, "commands")); + fs.writeFileSync( + path.join(bundleRoot, "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + fs.writeFileSync( + path.join(bundleRoot, "settings.json"), + '{"hideThinkingBlock":true}', + "utf-8", + ); + }, + }, + { + name: "treats bundle MCP as a supported bundle surface", + pluginId: "claude-mcp", + expectedFormat: "claude", + expectedCapabilities: ["mcpServers"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, ".claude-plugin")); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + probe: { + command: "node", + args: ["./probe.mjs"], }, }, - }, - }, - cache: false, - }), - ); - - const plugin = registry.plugins.find((entry) => entry.id === "claude-skills"); - expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleFormat).toBe("claude"); - expect(plugin?.bundleCapabilities).toEqual( - expect.arrayContaining(["skills", "commands", "settings"]), - ); - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === "claude-skills" && - diag.message.includes("bundle capability detected but not wired"), - ), - ).toBe(false); - }); - - it("treats bundle MCP as a supported bundle surface", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp"); - mkdirSafe(path.join(bundleRoot, ".claude-plugin")); - fs.writeFileSync( - path.join(bundleRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "Claude MCP", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - probe: { - command: "node", - args: ["./probe.mjs"], - }, - }, - }), - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "claude-mcp": { - enabled: true, - }, - }, - }, + }), + "utf-8", + ); }, - cache: false, - }); + }, + { + name: "treats Cursor command roots as supported bundle skill surfaces", + pluginId: "cursor-skills", + expectedFormat: "cursor", + expectedCapabilities: ["skills", "commands"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); + mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); + fs.writeFileSync( + path.join(bundleRoot, ".cursor-plugin", "plugin.json"), + JSON.stringify({ + name: "Cursor Skills", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".cursor", "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + }, + }, + ])("$name", ({ pluginId, expectedFormat, expectedCapabilities, build }) => { + const registry = loadBundleFixture({ pluginId, build }); + const plugin = registry.plugins.find((entry) => entry.id === pluginId); - const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp"); expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleFormat).toBe("claude"); - expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"])); - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === "claude-mcp" && - diag.message.includes("bundle capability detected but not wired"), - ), - ).toBe(false); + expect(plugin?.bundleFormat).toBe(expectedFormat); + expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(expectedCapabilities)); + expectNoUnwiredBundleDiagnostic(registry, pluginId); }); it("warns when bundle MCP only declares unsupported non-stdio transports", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp-url"); - fs.mkdirSync(path.join(bundleRoot, ".claude-plugin"), { recursive: true }); - fs.writeFileSync( - path.join(bundleRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "Claude MCP URL", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - remoteProbe: { - url: "http://127.0.0.1:8787/mcp", - }, - }, - }), - "utf-8", - ); - - const registry = withEnv( - { + const registry = loadBundleFixture({ + pluginId: "claude-mcp-url", + env: { OPENCLAW_HOME: stateDir, - OPENCLAW_STATE_DIR: stateDir, }, - () => - loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "claude-mcp-url": { - enabled: true, - }, + build: (bundleRoot) => { + mkdirSafe(path.join(bundleRoot, ".claude-plugin")); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP URL", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + remoteProbe: { + url: "http://127.0.0.1:8787/mcp", }, }, - }, - cache: false, - }), - ); + }), + "utf-8", + ); + }, + }); const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp-url"); expect(plugin?.status).toBe("loaded"); @@ -530,55 +538,6 @@ describe("bundle plugins", () => { ), ).toBe(true); }); - - it("treats Cursor command roots as supported bundle skill surfaces", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "cursor-skills"); - mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); - mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); - fs.writeFileSync( - path.join(bundleRoot, ".cursor-plugin", "plugin.json"), - JSON.stringify({ - name: "Cursor Skills", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".cursor", "commands", "review.md"), - "---\ndescription: fixture\n---\n", - ); - - const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => - loadOpenClawPlugins({ - workspaceDir, - onlyPluginIds: ["cursor-skills"], - config: { - plugins: { - entries: { - "cursor-skills": { - enabled: true, - }, - }, - }, - }, - cache: false, - }), - ); - - const plugin = registry.plugins.find((entry) => entry.id === "cursor-skills"); - expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleFormat).toBe("cursor"); - expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["skills", "commands"])); - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === "cursor-skills" && - diag.message.includes("bundle capability detected but not wired"), - ), - ).toBe(false); - }); }); afterAll(() => { From 34460f24b8288b3494d981bf0dc9ade3eee0d3aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:44:27 +0000 Subject: [PATCH 032/393] test: merge loader cache partition cases --- src/plugins/loader.test.ts | 313 +++++++++++++++++++++---------------- 1 file changed, 179 insertions(+), 134 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index e5ae16945ca..1351aae774b 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -363,6 +363,44 @@ function expectNoUnwiredBundleDiagnostic( ).toBe(false); } +function resolveLoadedPluginSource( + registry: ReturnType, + pluginId: string, +) { + return fs.realpathSync(registry.plugins.find((entry) => entry.id === pluginId)?.source ?? ""); +} + +function expectCachePartitionByPluginSource(params: { + pluginId: string; + loadFirst: () => ReturnType; + loadSecond: () => ReturnType; + expectedFirstSource: string; + expectedSecondSource: string; +}) { + const first = params.loadFirst(); + const second = params.loadSecond(); + + expect(second).not.toBe(first); + expect(resolveLoadedPluginSource(first, params.pluginId)).toBe( + fs.realpathSync(params.expectedFirstSource), + ); + expect(resolveLoadedPluginSource(second, params.pluginId)).toBe( + fs.realpathSync(params.expectedSecondSource), + ); +} + +function expectCacheMissThenHit(params: { + loadFirst: () => ReturnType; + loadVariant: () => ReturnType; +}) { + const first = params.loadFirst(); + const second = params.loadVariant(); + const third = params.loadVariant(); + + expect(second).not.toBe(first); + expect(third).toBe(second); +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -885,117 +923,131 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s resetGlobalHookRunner(); }); - it("does not reuse cached bundled plugin registries across env changes", () => { - const bundledA = makeTempDir(); - const bundledB = makeTempDir(); - const pluginA = writePlugin({ - id: "cache-root", - dir: path.join(bundledA, "cache-root"), - filename: "index.cjs", - body: `module.exports = { id: "cache-root", register() {} };`, - }); - const pluginB = writePlugin({ - id: "cache-root", - dir: path.join(bundledB, "cache-root"), - filename: "index.cjs", - body: `module.exports = { id: "cache-root", register() {} };`, - }); + it.each([ + { + name: "does not reuse cached bundled plugin registries across env changes", + pluginId: "cache-root", + setup: () => { + const bundledA = makeTempDir(); + const bundledB = makeTempDir(); + const pluginA = writePlugin({ + id: "cache-root", + dir: path.join(bundledA, "cache-root"), + filename: "index.cjs", + body: `module.exports = { id: "cache-root", register() {} };`, + }); + const pluginB = writePlugin({ + id: "cache-root", + dir: path.join(bundledB, "cache-root"), + filename: "index.cjs", + body: `module.exports = { id: "cache-root", register() {} };`, + }); - const options = { - config: { - plugins: { - allow: ["cache-root"], - entries: { - "cache-root": { enabled: true }, + const options = { + config: { + plugins: { + allow: ["cache-root"], + entries: { + "cache-root": { enabled: true }, + }, + }, }, - }, + }; + + return { + expectedFirstSource: pluginA.file, + expectedSecondSource: pluginB.file, + loadFirst: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA, + }, + }), + loadSecond: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB, + }, + }), + }; }, - }; + }, + { + name: "does not reuse cached load-path plugin registries across env home changes", + pluginId: "demo", + setup: () => { + const homeA = makeTempDir(); + const homeB = makeTempDir(); + const stateDir = makeTempDir(); + const bundledDir = makeTempDir(); + const pluginA = writePlugin({ + id: "demo", + dir: path.join(homeA, "plugins", "demo"), + filename: "index.cjs", + body: `module.exports = { id: "demo", register() {} };`, + }); + const pluginB = writePlugin({ + id: "demo", + dir: path.join(homeB, "plugins", "demo"), + filename: "index.cjs", + body: `module.exports = { id: "demo", register() {} };`, + }); - const first = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA, - }, - }); - const second = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB, - }, - }); - - expect(second).not.toBe(first); - expect( - fs.realpathSync(first.plugins.find((entry) => entry.id === "cache-root")?.source ?? ""), - ).toBe(fs.realpathSync(pluginA.file)); - expect( - fs.realpathSync(second.plugins.find((entry) => entry.id === "cache-root")?.source ?? ""), - ).toBe(fs.realpathSync(pluginB.file)); - }); - - it("does not reuse cached load-path plugin registries across env home changes", () => { - const homeA = makeTempDir(); - const homeB = makeTempDir(); - const stateDir = makeTempDir(); - const bundledDir = makeTempDir(); - const pluginA = writePlugin({ - id: "demo", - dir: path.join(homeA, "plugins", "demo"), - filename: "index.cjs", - body: `module.exports = { id: "demo", register() {} };`, - }); - const pluginB = writePlugin({ - id: "demo", - dir: path.join(homeB, "plugins", "demo"), - filename: "index.cjs", - body: `module.exports = { id: "demo", register() {} };`, - }); - - const options = { - config: { - plugins: { - allow: ["demo"], - entries: { - demo: { enabled: true }, + const options = { + config: { + plugins: { + allow: ["demo"], + entries: { + demo: { enabled: true }, + }, + load: { + paths: ["~/plugins/demo"], + }, + }, }, - load: { - paths: ["~/plugins/demo"], - }, - }, - }, - }; + }; - const first = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - HOME: homeA, - OPENCLAW_HOME: undefined, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + return { + expectedFirstSource: pluginA.file, + expectedSecondSource: pluginB.file, + loadFirst: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + HOME: homeA, + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + }, + }), + loadSecond: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + HOME: homeB, + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + }, + }), + }; }, + }, + ])("$name", ({ pluginId, setup }) => { + const { expectedFirstSource, expectedSecondSource, loadFirst, loadSecond } = setup(); + expectCachePartitionByPluginSource({ + pluginId, + loadFirst, + loadSecond, + expectedFirstSource, + expectedSecondSource, }); - const second = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - HOME: homeB, - OPENCLAW_HOME: undefined, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - }, - }); - - expect(second).not.toBe(first); - expect(fs.realpathSync(first.plugins.find((entry) => entry.id === "demo")?.source ?? "")).toBe( - fs.realpathSync(pluginA.file), - ); - expect(fs.realpathSync(second.plugins.find((entry) => entry.id === "demo")?.source ?? "")).toBe( - fs.realpathSync(pluginB.file), - ); }); it("does not reuse cached registries when env-resolved install paths change", () => { @@ -1028,17 +1080,6 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s }, }; - const first = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - OPENCLAW_HOME: openclawHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - }); const secondHome = makeTempDir(); const secondOptions = { ...options, @@ -1051,11 +1092,21 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", }, }; - const second = loadOpenClawPlugins(secondOptions); - const third = loadOpenClawPlugins(secondOptions); - - expect(second).not.toBe(first); - expect(third).toBe(second); + expectCacheMissThenHit({ + loadFirst: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_HOME: openclawHome, + HOME: ignoredHome, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + }), + loadVariant: () => loadOpenClawPlugins(secondOptions), + }); }); it("does not reuse cached registries across gateway subagent binding modes", () => { @@ -1078,22 +1129,16 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s }, }; - const defaultRegistry = loadOpenClawPlugins(options); - const gatewayBindableRegistry = loadOpenClawPlugins({ - ...options, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, + expectCacheMissThenHit({ + loadFirst: () => loadOpenClawPlugins(options), + loadVariant: () => + loadOpenClawPlugins({ + ...options, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }), }); - const gatewayBindableAgain = loadOpenClawPlugins({ - ...options, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }); - - expect(gatewayBindableRegistry).not.toBe(defaultRegistry); - expect(gatewayBindableAgain).toBe(gatewayBindableRegistry); }); it("evicts least recently used registries when the loader cache exceeds its cap", () => { From 9c086f26a04525156a752063601e05771eb94496 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:46:32 +0000 Subject: [PATCH 033/393] test: merge loader setup entry matrix --- src/plugins/loader.test.ts | 631 +++++++++++++------------------------ 1 file changed, 220 insertions(+), 411 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 1351aae774b..21c2df6b158 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -401,6 +401,114 @@ function expectCacheMissThenHit(params: { expect(third).toBe(second); } +function createSetupEntryChannelPluginFixture(params: { + id: string; + label: string; + packageName: string; + fullBlurb: string; + setupBlurb: string; + configured: boolean; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; +}) { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + const listAccountIds = params.configured ? '["default"]' : "[]"; + const resolveAccount = params.configured + ? '({ accountId: "default", token: "configured" })' + : '({ accountId: "default" })'; + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: params.packageName, + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + ...(params.startupDeferConfiguredChannelFullLoadUntilAfterListen + ? { + startup: { + deferConfiguredChannelFullLoadUntilAfterListen: true, + }, + } + : {}), + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: params.id, + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: [params.id], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: ${JSON.stringify(params.id)}, + register(api) { + api.registerChannel({ + plugin: { + id: ${JSON.stringify(params.id)}, + meta: { + id: ${JSON.stringify(params.id)}, + label: ${JSON.stringify(params.label)}, + selectionLabel: ${JSON.stringify(params.label)}, + docsPath: ${JSON.stringify(`/channels/${params.id}`)}, + blurb: ${JSON.stringify(params.fullBlurb)}, + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ${listAccountIds}, + resolveAccount: () => ${resolveAccount}, + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: ${JSON.stringify(params.id)}, + meta: { + id: ${JSON.stringify(params.id)}, + label: ${JSON.stringify(params.label)}, + selectionLabel: ${JSON.stringify(params.label)}, + docsPath: ${JSON.stringify(`/channels/${params.id}`)}, + blurb: ${JSON.stringify(params.setupBlurb)}, + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ${listAccountIds}, + resolveAccount: () => ${resolveAccount}, + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + return { pluginDir, fullMarker, setupMarker }; +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -2023,429 +2131,130 @@ module.exports = { ); }); - it("uses package setupEntry for setup-only channel loads", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - const setupMarker = path.join(pluginDir, "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-entry-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-entry-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-entry-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-entry-test", - register(api) { - api.registerChannel({ - plugin: { + it.each([ + { + name: "uses package setupEntry for setup-only channel loads", + fixture: { id: "setup-entry-test", - meta: { - id: "setup-entry-test", - label: "Setup Entry Test", - selectionLabel: "Setup Entry Test", - docsPath: "/channels/setup-entry-test", - blurb: "full entry should not run in setup-only mode", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, + label: "Setup Entry Test", + packageName: "@openclaw/setup-entry-test", + fullBlurb: "full entry should not run in setup-only mode", + setupBlurb: "setup entry", + configured: false, }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-entry-test", - meta: { - id: "setup-entry-test", - label: "Setup Entry Test", - selectionLabel: "Setup Entry Test", - docsPath: "/channels/setup-entry-test", - blurb: "setup entry", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const setupRegistry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-entry-test"], - entries: { - "setup-entry-test": { enabled: false }, - }, - }, - }, - includeSetupOnlyChannelPlugins: true, - }); - - expect(fs.existsSync(setupMarker)).toBe(true); - expect(fs.existsSync(fullMarker)).toBe(false); - expect(setupRegistry.channelSetups).toHaveLength(1); - expect(setupRegistry.channels).toHaveLength(0); - }); - - it("uses package setupEntry for enabled but unconfigured channel loads", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - const setupMarker = path.join(pluginDir, "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-runtime-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-runtime-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-runtime-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-runtime-test", - register(api) { - api.registerChannel({ - plugin: { - id: "setup-runtime-test", - meta: { - id: "setup-runtime-test", - label: "Setup Runtime Test", - selectionLabel: "Setup Runtime Test", - docsPath: "/channels/setup-runtime-test", - blurb: "full entry should not run while unconfigured", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-runtime-test", - meta: { - id: "setup-runtime-test", - label: "Setup Runtime Test", - selectionLabel: "Setup Runtime Test", - docsPath: "/channels/setup-runtime-test", - blurb: "setup runtime", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-runtime-test"], - }, - }, - }); - - expect(fs.existsSync(setupMarker)).toBe(true); - expect(fs.existsSync(fullMarker)).toBe(false); - expect(registry.channelSetups).toHaveLength(1); - expect(registry.channels).toHaveLength(1); - }); - - it("can prefer setupEntry for configured channel loads during startup", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - const setupMarker = path.join(pluginDir, "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-runtime-preferred-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - startup: { - deferConfiguredChannelFullLoadUntilAfterListen: true, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-entry-test"], + entries: { + "setup-entry-test": { enabled: false }, + }, }, }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-runtime-preferred-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-runtime-preferred-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-runtime-preferred-test", - register(api) { - api.registerChannel({ - plugin: { + includeSetupOnlyChannelPlugins: true, + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 0, + }, + { + name: "uses package setupEntry for enabled but unconfigured channel loads", + fixture: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + packageName: "@openclaw/setup-runtime-test", + fullBlurb: "full entry should not run while unconfigured", + setupBlurb: "setup runtime", + configured: false, + }, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-test"], + }, + }, + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 1, + }, + { + name: "can prefer setupEntry for configured channel loads during startup", + fixture: { id: "setup-runtime-preferred-test", - meta: { - id: "setup-runtime-preferred-test", - label: "Setup Runtime Preferred Test", - selectionLabel: "Setup Runtime Preferred Test", - docsPath: "/channels/setup-runtime-preferred-test", - blurb: "full entry should be deferred while startup is still cold", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, + label: "Setup Runtime Preferred Test", + packageName: "@openclaw/setup-runtime-preferred-test", + fullBlurb: "full entry should be deferred while startup is still cold", + setupBlurb: "setup runtime preferred", + configured: true, + startupDeferConfiguredChannelFullLoadUntilAfterListen: true, }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-runtime-preferred-test", - meta: { - id: "setup-runtime-preferred-test", - label: "Setup Runtime Preferred Test", - selectionLabel: "Setup Runtime Preferred Test", - docsPath: "/channels/setup-runtime-preferred-test", - blurb: "setup runtime preferred", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - preferSetupRuntimeForChannelPlugins: true, - config: { - channels: { - "setup-runtime-preferred-test": { - enabled: true, - token: "configured", + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-preferred-test": { + enabled: true, + token: "configured", + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-preferred-test"], + }, }, - }, - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-runtime-preferred-test"], - }, - }, - }); - - expect(fs.existsSync(setupMarker)).toBe(true); - expect(fs.existsSync(fullMarker)).toBe(false); - expect(registry.channelSetups).toHaveLength(1); - expect(registry.channels).toHaveLength(1); - }); - - it("does not prefer setupEntry for configured channel loads without startup opt-in", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(makeTempDir(), "full-loaded.txt"); - const setupMarker = path.join(makeTempDir(), "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-runtime-not-preferred-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-runtime-not-preferred-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-runtime-not-preferred-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-runtime-not-preferred-test", - register(api) { - api.registerChannel({ - plugin: { + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 1, + }, + { + name: "does not prefer setupEntry for configured channel loads without startup opt-in", + fixture: { id: "setup-runtime-not-preferred-test", - meta: { - id: "setup-runtime-not-preferred-test", - label: "Setup Runtime Not Preferred Test", - selectionLabel: "Setup Runtime Not Preferred Test", - docsPath: "/channels/setup-runtime-not-preferred-test", - blurb: "full entry should still load without explicit startup opt-in", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, + label: "Setup Runtime Not Preferred Test", + packageName: "@openclaw/setup-runtime-not-preferred-test", + fullBlurb: "full entry should still load without explicit startup opt-in", + setupBlurb: "setup runtime not preferred", + configured: true, }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-runtime-not-preferred-test", - meta: { - id: "setup-runtime-not-preferred-test", - label: "Setup Runtime Not Preferred Test", - selectionLabel: "Setup Runtime Not Preferred Test", - docsPath: "/channels/setup-runtime-not-preferred-test", - blurb: "setup runtime not preferred", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - preferSetupRuntimeForChannelPlugins: true, - config: { - channels: { - "setup-runtime-not-preferred-test": { - enabled: true, - token: "configured", + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-not-preferred-test": { + enabled: true, + token: "configured", + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-not-preferred-test"], + }, }, - }, - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-runtime-not-preferred-test"], - }, - }, - }); + }), + expectFullLoaded: true, + expectSetupLoaded: false, + expectedChannels: 1, + }, + ])("$name", ({ fixture, load, expectFullLoaded, expectSetupLoaded, expectedChannels }) => { + const built = createSetupEntryChannelPluginFixture(fixture); + const registry = load({ pluginDir: built.pluginDir }); - expect(fs.existsSync(fullMarker)).toBe(true); - expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(built.fullMarker)).toBe(expectFullLoaded); + expect(fs.existsSync(built.setupMarker)).toBe(expectSetupLoaded); expect(registry.channelSetups).toHaveLength(1); - expect(registry.channels).toHaveLength(1); + expect(registry.channels).toHaveLength(expectedChannels); }); it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { From 1038990bdddb359c647e515fbbf2cd2850ef821d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:48:34 +0000 Subject: [PATCH 034/393] test: merge discord audit allowlist cases --- src/security/audit.test.ts | 299 +++++++++++++++++-------------------- 1 file changed, 140 insertions(+), 159 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index dedc789773c..648636e709b 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -171,6 +171,18 @@ function expectNoFinding(res: SecurityAuditReport, checkId: string): void { expect(hasFinding(res, checkId)).toBe(false); } +async function runChannelSecurityAudit( + cfg: OpenClawConfig, + plugins: ChannelPlugin[], +): Promise { + return runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins, + }); +} + describe("security audit", () => { let fixtureRoot = ""; let caseId = 0; @@ -2240,13 +2252,16 @@ description: test skill }); }); - it("warns when Discord allowlists contain name-based entries", async () => { - await withChannelSecurityStateDir(async (tmp) => { - await fs.writeFile( - path.join(tmp, "credentials", "discord-allowFrom.json"), - JSON.stringify({ version: 1, allowFrom: ["team.owner"] }), - ); - const cfg: OpenClawConfig = { + it.each([ + { + name: "warns when Discord allowlists contain name-based entries", + setup: async (tmp: string) => { + await fs.writeFile( + path.join(tmp, "credentials", "discord-allowFrom.json"), + JSON.stringify({ version: 1, allowFrom: ["team.owner"] }), + ); + }, + cfg: { channels: { discord: { enabled: true, @@ -2264,35 +2279,20 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("channels.discord.allowFrom:Alice#1234"); - expect(finding?.detail).toContain("channels.discord.guilds.123.users:trusted.operator"); - expect(finding?.detail).toContain( + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNameBasedSeverity: "warn", + detailIncludes: [ + "channels.discord.allowFrom:Alice#1234", + "channels.discord.guilds.123.users:trusted.operator", "channels.discord.guilds.123.channels.general.users:security-team", - ); - expect(finding?.detail).toContain( "~/.openclaw/credentials/discord-allowFrom.json:team.owner", - ); - expect(finding?.detail).not.toContain("<@123456789012345678>"); - }); - }); - - it("marks Discord name-based allowlists as break-glass when dangerous matching is enabled", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + ], + detailExcludes: ["<@123456789012345678>"], + }, + { + name: "marks Discord name-based allowlists as break-glass when dangerous matching is enabled", + cfg: { channels: { discord: { enabled: true, @@ -2301,35 +2301,18 @@ description: test skill allowFrom: ["Alice#1234"], }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("info"); - expect(finding?.detail).toContain("out-of-scope"); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - severity: "info", - }), - ]), - ); - }); - }); - - it("audits non-default Discord accounts for dangerous name matching", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNameBasedSeverity: "info", + detailIncludes: ["out-of-scope"], + expectFindingMatch: { + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + severity: "info", + }, + }, + { + name: "audits non-default Discord accounts for dangerous name matching", + cfg: { channels: { discord: { enabled: true, @@ -2343,24 +2326,101 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - title: expect.stringContaining("(account: beta)"), - severity: "info", - }), - ]), + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNoNameBasedFinding: true, + expectFindingMatch: { + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + title: expect.stringContaining("(account: beta)"), + severity: "info", + }, + }, + { + name: "audits name-based allowlists on non-default Discord accounts", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + accounts: { + alpha: { + token: "a", + allowFrom: ["123456789012345678"], + }, + beta: { + token: "b", + allowFrom: ["Alice#1234"], + }, + }, + }, + }, + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNameBasedSeverity: "warn", + detailIncludes: ["channels.discord.accounts.beta.allowFrom:Alice#1234"], + }, + { + name: "does not warn when Discord allowlists use ID-style entries only", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + allowFrom: [ + "123456789012345678", + "<@223456789012345678>", + "user:323456789012345678", + "discord:423456789012345678", + "pk:member-123", + ], + guilds: { + "123": { + users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"], + channels: { + general: { + users: ["723456789012345678", "user:823456789012345678"], + }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNoNameBasedFinding: true, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async (tmp) => { + await testCase.setup?.(tmp); + const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); + const nameBasedFinding = res.findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", ); + + if (testCase.expectNoNameBasedFinding) { + expect(nameBasedFinding).toBeUndefined(); + } else if ( + testCase.expectNameBasedSeverity || + testCase.detailIncludes?.length || + testCase.detailExcludes?.length + ) { + expect(nameBasedFinding).toBeDefined(); + if (testCase.expectNameBasedSeverity) { + expect(nameBasedFinding?.severity).toBe(testCase.expectNameBasedSeverity); + } + for (const snippet of testCase.detailIncludes ?? []) { + expect(nameBasedFinding?.detail).toContain(snippet); + } + for (const snippet of testCase.detailExcludes ?? []) { + expect(nameBasedFinding?.detail).not.toContain(snippet); + } + } + + if (testCase.expectFindingMatch) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]), + ); + } }); }); @@ -2409,42 +2469,6 @@ description: test skill }); }); - it("audits name-based allowlists on non-default Discord accounts", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - accounts: { - alpha: { - token: "a", - allowFrom: ["123456789012345678"], - }, - beta: { - token: "b", - allowFrom: ["Alice#1234"], - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.detail).toContain("channels.discord.accounts.beta.allowFrom:Alice#1234"); - }); - }); - it("warns when Zalouser group routing contains mutable group entries", async () => { await withChannelSecurityStateDir(async () => { const cfg: OpenClawConfig = { @@ -2514,49 +2538,6 @@ description: test skill }); }); - it("does not warn when Discord allowlists use ID-style entries only", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - allowFrom: [ - "123456789012345678", - "<@223456789012345678>", - "user:323456789012345678", - "discord:423456789012345678", - "pk:member-123", - ], - guilds: { - "123": { - users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"], - channels: { - general: { - users: ["723456789012345678", "user:823456789012345678"], - }, - }, - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "channels.discord.allowFrom.name_based_entries" }), - ]), - ); - }); - }); - it("flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", async () => { await withChannelSecurityStateDir(async () => { const cfg: OpenClawConfig = { From 0c070ccd530112fef93d172ed99ae79bcb04fae0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:49:26 +0000 Subject: [PATCH 035/393] test: merge zalouser audit group cases --- src/security/audit.test.ts | 80 +++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 648636e709b..6c436ad08ac 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2469,9 +2469,10 @@ description: test skill }); }); - it("warns when Zalouser group routing contains mutable group entries", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + it.each([ + { + name: "warns when Zalouser group routing contains mutable group entries", + cfg: { channels: { zalouser: { enabled: true, @@ -2481,28 +2482,14 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [zalouserPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("channels.zalouser.groups:Ops Room"); - expect(finding?.detail).not.toContain("group:g-123"); - }); - }); - - it("marks Zalouser mutable group routing as break-glass when dangerous matching is enabled", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + expectedSeverity: "warn", + detailIncludes: ["channels.zalouser.groups:Ops Room"], + detailExcludes: ["group:g-123"], + }, + { + name: "marks Zalouser mutable group routing as break-glass when dangerous matching is enabled", + cfg: { channels: { zalouser: { enabled: true, @@ -2512,29 +2499,34 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [zalouserPlugin], - }); - + } satisfies OpenClawConfig, + expectedSeverity: "info", + detailIncludes: ["out-of-scope"], + expectFindingMatch: { + checkId: "channels.zalouser.allowFrom.dangerous_name_matching_enabled", + severity: "info", + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const res = await runChannelSecurityAudit(testCase.cfg, [zalouserPlugin]); const finding = res.findings.find( (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries", ); + expect(finding).toBeDefined(); - expect(finding?.severity).toBe("info"); - expect(finding?.detail).toContain("out-of-scope"); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.zalouser.allowFrom.dangerous_name_matching_enabled", - severity: "info", - }), - ]), - ); + expect(finding?.severity).toBe(testCase.expectedSeverity); + for (const snippet of testCase.detailIncludes) { + expect(finding?.detail).toContain(snippet); + } + for (const snippet of testCase.detailExcludes ?? []) { + expect(finding?.detail).not.toContain(snippet); + } + if (testCase.expectFindingMatch) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]), + ); + } }); }); From 03b405659b77276c0fa260702e652814dcadf0ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:50:11 +0000 Subject: [PATCH 036/393] test: merge audit auth precedence cases --- src/security/audit.test.ts | 47 ++++++++++---------------------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 6c436ad08ac..55df636e497 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3594,28 +3594,28 @@ description: test skill return probeEnv; }; - it("applies token precedence across local/remote gateway modes", async () => { + it("applies gateway auth precedence across local/remote modes", async () => { const cases: Array<{ name: string; cfg: OpenClawConfig; - env?: { token?: string }; - expectedToken: string; + env?: { token?: string; password?: string }; + expectedAuth: { token?: string; password?: string }; }> = [ { name: "uses local auth when gateway.mode is local", cfg: { gateway: { mode: "local", auth: { token: "local-token-abc123" } } }, - expectedToken: "local-token-abc123", + expectedAuth: { token: "local-token-abc123" }, }, { name: "prefers env token over local config token", cfg: { gateway: { mode: "local", auth: { token: "local-token" } } }, env: { token: "env-token" }, - expectedToken: "env-token", + expectedAuth: { token: "env-token" }, }, { name: "uses local auth when gateway.mode is undefined (default)", cfg: { gateway: { auth: { token: "default-local-token" } } }, - expectedToken: "default-local-token", + expectedAuth: { token: "default-local-token" }, }, { name: "uses remote auth when gateway.mode is remote with URL", @@ -3626,7 +3626,7 @@ description: test skill remote: { url: "wss://remote.example.com:18789", token: "remote-token-xyz789" }, }, }, - expectedToken: "remote-token-xyz789", + expectedAuth: { token: "remote-token-xyz789" }, }, { name: "ignores env token when gateway.mode is remote", @@ -3638,7 +3638,7 @@ description: test skill }, }, env: { token: "env-token" }, - expectedToken: "remote-token", + expectedAuth: { token: "remote-token" }, }, { name: "falls back to local auth when gateway.mode is remote but URL is missing", @@ -3649,31 +3649,8 @@ description: test skill remote: { token: "remote-token-should-not-use" }, }, }, - expectedToken: "fallback-local-token", + expectedAuth: { token: "fallback-local-token" }, }, - ]; - - await Promise.all( - cases.map(async (testCase) => { - const { probeGatewayFn, getAuth } = makeProbeCapture(); - await audit(testCase.cfg, { - deep: true, - deepTimeoutMs: 50, - probeGatewayFn, - env: makeProbeEnv(testCase.env), - }); - expect(getAuth()?.token, testCase.name).toBe(testCase.expectedToken); - }), - ); - }); - - it("applies password precedence for remote gateways", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - env?: { password?: string }; - expectedPassword: string; - }> = [ { name: "uses remote password when env is unset", cfg: { @@ -3682,7 +3659,7 @@ description: test skill remote: { url: "wss://remote.example.com:18789", password: "remote-pass" }, }, }, - expectedPassword: "remote-pass", + expectedAuth: { password: "remote-pass" }, }, { name: "prefers env password over remote password", @@ -3693,7 +3670,7 @@ description: test skill }, }, env: { password: "env-pass" }, - expectedPassword: "env-pass", + expectedAuth: { password: "env-pass" }, }, ]; @@ -3706,7 +3683,7 @@ description: test skill probeGatewayFn, env: makeProbeEnv(testCase.env), }); - expect(getAuth()?.password, testCase.name).toBe(testCase.expectedPassword); + expect(getAuth(), testCase.name).toEqual(testCase.expectedAuth); }), ); }); From 2ef7b139620cd72a80f6372b05e680600d843476 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:51:19 +0000 Subject: [PATCH 037/393] test: merge channel command audit cases --- src/security/audit.test.ts | 131 +++++++++++++------------------------ 1 file changed, 45 insertions(+), 86 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 55df636e497..fce242e8fdb 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2530,9 +2530,10 @@ description: test skill }); }); - it("flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + it.each([ + { + name: "flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", + cfg: { commands: { useAccessGroups: false }, channels: { discord: { @@ -2548,29 +2549,16 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.unrestricted", - severity: "critical", - }), - ]), - ); - }); - }); - - it("flags Slack slash commands without a channel users allowlist", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectedFinding: { + checkId: "channels.discord.commands.native.unrestricted", + severity: "critical", + }, + }, + { + name: "flags Slack slash commands without a channel users allowlist", + cfg: { channels: { slack: { enabled: true, @@ -2580,29 +2568,16 @@ description: test skill slashCommand: { enabled: true }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [slackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.no_allowlists", - severity: "warn", - }), - ]), - ); - }); - }); - - it("flags Slack slash commands when access-group enforcement is disabled", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [slackPlugin], + expectedFinding: { + checkId: "channels.slack.commands.slash.no_allowlists", + severity: "warn", + }, + }, + { + name: "flags Slack slash commands when access-group enforcement is disabled", + cfg: { commands: { useAccessGroups: false }, channels: { slack: { @@ -2613,29 +2588,16 @@ description: test skill slashCommand: { enabled: true }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [slackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.useAccessGroups_off", - severity: "critical", - }), - ]), - ); - }); - }); - - it("flags Telegram group commands without a sender allowlist", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [slackPlugin], + expectedFinding: { + checkId: "channels.slack.commands.slash.useAccessGroups_off", + severity: "critical", + }, + }, + { + name: "flags Telegram group commands without a sender allowlist", + cfg: { channels: { telegram: { enabled: true, @@ -2644,22 +2606,19 @@ description: test skill groups: { "-100123": {} }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [telegramPlugin], - }); + } satisfies OpenClawConfig, + plugins: [telegramPlugin], + expectedFinding: { + checkId: "channels.telegram.groups.allowFrom.missing", + severity: "critical", + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.telegram.groups.allowFrom.missing", - severity: "critical", - }), - ]), + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), ); }); }); From 5f0f69b2c788b495e5f3d1384e1e0ba5306a30be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:52:04 +0000 Subject: [PATCH 038/393] test: merge browser control audit cases --- src/security/audit.test.ts | 164 ++++++++++++++++++------------------- 1 file changed, 80 insertions(+), 84 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index fce242e8fdb..7ab76003527 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1360,97 +1360,93 @@ description: test skill expectFinding(res, "tools.elevated.allowFrom.whatsapp.wildcard", "critical"); }); - it("flags browser control without auth when browser is enabled", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { enabled: false }, - auth: {}, - }, - browser: { - enabled: true, - }, - }; - - const res = await audit(cfg, { env: {} }); - - expectFinding(res, "browser.control_no_auth", "critical"); - }); - - it("does not flag browser control auth when gateway token is configured", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { enabled: false }, - auth: { token: "very-long-browser-token-0123456789" }, - }, - browser: { - enabled: true, - }, - }; - - const res = await audit(cfg, { env: {} }); - - expectNoFinding(res, "browser.control_no_auth"); - }); - - it("does not flag browser control auth when gateway password uses SecretRef", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { enabled: false }, - auth: { - password: { - source: "env", - provider: "default", - id: "OPENCLAW_GATEWAY_PASSWORD", + it.each([ + { + name: "flags browser control without auth when browser is enabled", + cfg: { + gateway: { + controlUi: { enabled: false }, + auth: {}, + }, + browser: { + enabled: true, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "browser.control_no_auth", severity: "critical" }, + }, + { + name: "does not flag browser control auth when gateway token is configured", + cfg: { + gateway: { + controlUi: { enabled: false }, + auth: { token: "very-long-browser-token-0123456789" }, + }, + browser: { + enabled: true, + }, + } satisfies OpenClawConfig, + expectedNoFinding: "browser.control_no_auth", + }, + { + name: "does not flag browser control auth when gateway password uses SecretRef", + cfg: { + gateway: { + controlUi: { enabled: false }, + auth: { + password: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_PASSWORD", + }, }, }, - }, - browser: { - enabled: true, - }, - }; - - const res = await audit(cfg, { env: {} }); - expectNoFinding(res, "browser.control_no_auth"); - }); - - it("warns when remote CDP uses HTTP", async () => { - const cfg: OpenClawConfig = { - browser: { - profiles: { - remote: { cdpUrl: "http://example.com:9222", color: "#0066CC" }, + browser: { + enabled: true, }, - }, - }; - - const res = await audit(cfg); - - expectFinding(res, "browser.remote_cdp_http", "warn"); - }); - - it("warns when remote CDP targets a private/internal host", async () => { - const cfg: OpenClawConfig = { - browser: { - profiles: { - remote: { - cdpUrl: - "http://169.254.169.254:9222/json/version?token=supersecrettokenvalue1234567890", - color: "#0066CC", + } satisfies OpenClawConfig, + expectedNoFinding: "browser.control_no_auth", + }, + { + name: "warns when remote CDP uses HTTP", + cfg: { + browser: { + profiles: { + remote: { cdpUrl: "http://example.com:9222", color: "#0066CC" }, }, }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "browser.remote_cdp_http", severity: "warn" }, + }, + { + name: "warns when remote CDP targets a private/internal host", + cfg: { + browser: { + profiles: { + remote: { + cdpUrl: + "http://169.254.169.254:9222/json/version?token=supersecrettokenvalue1234567890", + color: "#0066CC", + }, + }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "browser.remote_cdp_private_host", + severity: "warn", + detail: expect.stringContaining("token=supers…7890"), }, - }; + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg, { env: {} }); - const res = await audit(cfg); - - expectFinding(res, "browser.remote_cdp_private_host", "warn"); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "browser.remote_cdp_private_host", - detail: expect.stringContaining("token=supers…7890"), - }), - ]), - ); + if (testCase.expectedFinding) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + } + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); it("warns when control UI allows insecure auth", async () => { From 7e1bc4677f3d31f4f34a12bfbe27194b353fec1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:52:49 +0000 Subject: [PATCH 039/393] test: merge control ui audit cases --- src/security/audit.test.ts | 143 ++++++++++++++++++++----------------- 1 file changed, 77 insertions(+), 66 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 7ab76003527..d6a5f7414a7 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1449,49 +1449,43 @@ description: test skill } }); - it("warns when control UI allows insecure auth", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { allowInsecureAuth: true }, + it.each([ + { + name: "warns when control UI allows insecure auth", + cfg: { + gateway: { + controlUi: { allowInsecureAuth: true }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.insecure_auth", + severity: "warn", }, - }; - - const res = await audit(cfg); + expectedDangerousFlag: "gateway.controlUi.allowInsecureAuth=true", + }, + { + name: "warns when control UI device auth is disabled", + cfg: { + gateway: { + controlUi: { dangerouslyDisableDeviceAuth: true }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.device_auth_disabled", + severity: "critical", + }, + expectedDangerousFlag: "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg); expect(res.findings).toEqual( expect.arrayContaining([ - expect.objectContaining({ - checkId: "gateway.control_ui.insecure_auth", - severity: "warn", - }), + expect.objectContaining(testCase.expectedFinding), expect.objectContaining({ checkId: "config.insecure_or_dangerous_flags", severity: "warn", - detail: expect.stringContaining("gateway.controlUi.allowInsecureAuth=true"), - }), - ]), - ); - }); - - it("warns when control UI device auth is disabled", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { dangerouslyDisableDeviceAuth: true }, - }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "gateway.control_ui.device_auth_disabled", - severity: "critical", - }), - expect.objectContaining({ - checkId: "config.insecure_or_dangerous_flags", - severity: "warn", - detail: expect.stringContaining("gateway.controlUi.dangerouslyDisableDeviceAuth=true"), + detail: expect.stringContaining(testCase.expectedDangerousFlag), }), ]), ); @@ -1522,39 +1516,56 @@ description: test skill expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false"); }); - it("flags non-loopback Control UI without allowed origins", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { mode: "token", token: "very-long-browser-token-0123456789" }, + it.each([ + { + name: "flags non-loopback Control UI without allowed origins", + cfg: { + gateway: { + bind: "lan", + auth: { mode: "token", token: "very-long-browser-token-0123456789" }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.allowed_origins_required", + severity: "critical", }, - }; - - const res = await audit(cfg); - expectFinding(res, "gateway.control_ui.allowed_origins_required", "critical"); - }); - - it("flags wildcard Control UI origins by exposure level", async () => { - const loopbackCfg: OpenClawConfig = { - gateway: { - bind: "loopback", - controlUi: { allowedOrigins: ["*"] }, + }, + { + name: "flags wildcard Control UI origins by exposure level on loopback", + cfg: { + gateway: { + bind: "loopback", + controlUi: { allowedOrigins: ["*"] }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.allowed_origins_wildcard", + severity: "warn", }, - }; - const exposedCfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { mode: "token", token: "very-long-browser-token-0123456789" }, - controlUi: { allowedOrigins: ["*"] }, + }, + { + name: "flags wildcard Control UI origins by exposure level when exposed", + cfg: { + gateway: { + bind: "lan", + auth: { mode: "token", token: "very-long-browser-token-0123456789" }, + controlUi: { allowedOrigins: ["*"] }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.allowed_origins_wildcard", + severity: "critical", }, - }; - - const loopback = await audit(loopbackCfg); - const exposed = await audit(exposedCfg); - - expectFinding(loopback, "gateway.control_ui.allowed_origins_wildcard", "warn"); - expectFinding(exposed, "gateway.control_ui.allowed_origins_wildcard", "critical"); - expectNoFinding(exposed, "gateway.control_ui.allowed_origins_required"); + expectedNoFinding: "gateway.control_ui.allowed_origins_required", + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg); + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); it("flags dangerous host-header origin fallback and suppresses missing allowed-origins finding", async () => { From 3aa76a8ce77a1e221758993d774ad5324f049692 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:54:03 +0000 Subject: [PATCH 040/393] test: merge feishu audit doc cases --- src/security/audit.test.ts | 89 ++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index d6a5f7414a7..9ea843376e3 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1588,51 +1588,56 @@ description: test skill ); }); - it("warns when Feishu doc tool is enabled because create can grant requester access", async () => { - const cfg: OpenClawConfig = { - channels: { - feishu: { - appId: "cli_test", - appSecret: "secret_test", // pragma: allowlist secret - }, - }, - }; - - const res = await audit(cfg); - expectFinding(res, "channels.feishu.doc_owner_open_id", "warn"); - }); - - it("treats Feishu SecretRef appSecret as configured for doc tool risk detection", async () => { - const cfg: OpenClawConfig = { - channels: { - feishu: { - appId: "cli_test", - appSecret: { - source: "env", - provider: "default", - id: "FEISHU_APP_SECRET", + it.each([ + { + name: "warns when Feishu doc tool is enabled because create can grant requester access", + cfg: { + channels: { + feishu: { + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret }, }, - }, - }; - - const res = await audit(cfg); - expectFinding(res, "channels.feishu.doc_owner_open_id", "warn"); - }); - - it("does not warn for Feishu doc grant risk when doc tools are disabled", async () => { - const cfg: OpenClawConfig = { - channels: { - feishu: { - appId: "cli_test", - appSecret: "secret_test", // pragma: allowlist secret - tools: { doc: false }, + } satisfies OpenClawConfig, + expectedFinding: "channels.feishu.doc_owner_open_id", + }, + { + name: "treats Feishu SecretRef appSecret as configured for doc tool risk detection", + cfg: { + channels: { + feishu: { + appId: "cli_test", + appSecret: { + source: "env", + provider: "default", + id: "FEISHU_APP_SECRET", + }, + }, }, - }, - }; - - const res = await audit(cfg); - expectNoFinding(res, "channels.feishu.doc_owner_open_id"); + } satisfies OpenClawConfig, + expectedFinding: "channels.feishu.doc_owner_open_id", + }, + { + name: "does not warn for Feishu doc grant risk when doc tools are disabled", + cfg: { + channels: { + feishu: { + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + tools: { doc: false }, + }, + }, + } satisfies OpenClawConfig, + expectedNoFinding: "channels.feishu.doc_owner_open_id", + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg); + if (testCase.expectedFinding) { + expectFinding(res, testCase.expectedFinding, "warn"); + } + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); it("scores X-Real-IP fallback risk by gateway exposure", async () => { From 4fd17021f2e9dbc8d78ff534829cb958fd2e7e26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:55:04 +0000 Subject: [PATCH 041/393] test: merge hooks audit risk cases --- src/security/audit.test.ts | 94 ++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 50 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 9ea843376e3..6a20bd8adc5 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2751,43 +2751,50 @@ description: test skill ); }); - it("warns when hooks token looks short", async () => { - const cfg: OpenClawConfig = { - hooks: { enabled: true, token: "short" }, - }; - - const res = await audit(cfg); - - expectFinding(res, "hooks.token_too_short", "warn"); - }); - - it("flags hooks token reuse of the gateway env token as critical", async () => { - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "shared-gateway-token-1234567890"; - const cfg: OpenClawConfig = { - hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, - }; - - try { - const res = await audit(cfg); - expectFinding(res, "hooks.token_reuse_gateway_token", "critical"); - } finally { - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - } - }); - - it("warns when hooks.defaultSessionKey is unset", async () => { - const cfg: OpenClawConfig = { - hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, - }; - - const res = await audit(cfg); - - expectFinding(res, "hooks.default_session_key_unset", "warn"); + it.each([ + { + name: "warns when hooks token looks short", + cfg: { + hooks: { enabled: true, token: "short" }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.token_too_short", + expectedSeverity: "warn" as const, + }, + { + name: "flags hooks token reuse of the gateway env token as critical", + cfg: { + hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, + } satisfies OpenClawConfig, + env: { + OPENCLAW_GATEWAY_TOKEN: "shared-gateway-token-1234567890", + }, + expectedFinding: "hooks.token_reuse_gateway_token", + expectedSeverity: "critical" as const, + }, + { + name: "warns when hooks.defaultSessionKey is unset", + cfg: { + hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.default_session_key_unset", + expectedSeverity: "warn" as const, + }, + { + name: "treats wildcard hooks.allowedAgentIds as unrestricted routing", + cfg: { + hooks: { + enabled: true, + token: "shared-gateway-token-1234567890", + defaultSessionKey: "hook:ingress", + allowedAgentIds: ["*"], + }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.allowed_agent_ids_unrestricted", + expectedSeverity: "warn" as const, + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg, testCase.env ? { env: testCase.env } : undefined); + expectFinding(res, testCase.expectedFinding, testCase.expectedSeverity); }); it("scores unrestricted hooks.allowedAgentIds by gateway exposure", async () => { @@ -2823,19 +2830,6 @@ description: test skill ); }); - it("treats wildcard hooks.allowedAgentIds as unrestricted routing", async () => { - const res = await audit({ - hooks: { - enabled: true, - token: "shared-gateway-token-1234567890", - defaultSessionKey: "hook:ingress", - allowedAgentIds: ["*"], - }, - }); - - expectFinding(res, "hooks.allowed_agent_ids_unrestricted", "warn"); - }); - it("scores hooks request sessionKey override by gateway exposure", async () => { const baseHooks = { enabled: true, From 167a6ebed9004f2cfa8c42d9d5e45fa302f5d2f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:55:56 +0000 Subject: [PATCH 042/393] test: merge gateway http audit cases --- src/security/audit.test.ts | 143 ++++++++++++++++++------------------- 1 file changed, 70 insertions(+), 73 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 6a20bd8adc5..c4a0cb27afd 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2869,85 +2869,82 @@ description: test skill ); }); - it("scores gateway HTTP no-auth findings by exposure", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - detailIncludes?: string[]; - }> = [ - { - name: "loopback no-auth", - cfg: { - gateway: { - bind: "loopback", - auth: { mode: "none" }, - http: { endpoints: { chatCompletions: { enabled: true } } }, + it.each([ + { + name: "scores loopback gateway HTTP no-auth as warn", + cfg: { + gateway: { + bind: "loopback", + auth: { mode: "none" }, + http: { endpoints: { chatCompletions: { enabled: true } } }, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.http.no_auth", severity: "warn" }, + detailIncludes: ["/tools/invoke", "/v1/chat/completions"], + auditOptions: { env: {} }, + }, + { + name: "scores remote gateway HTTP no-auth as critical", + cfg: { + gateway: { + bind: "lan", + auth: { mode: "none" }, + http: { endpoints: { responses: { enabled: true } } }, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.http.no_auth", severity: "critical" }, + auditOptions: { env: {} }, + }, + { + name: "does not report gateway.http.no_auth when auth mode is token", + cfg: { + gateway: { + bind: "loopback", + auth: { mode: "token", token: "secret" }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + responses: { enabled: true }, + }, }, }, - expectedSeverity: "warn", - detailIncludes: ["/tools/invoke", "/v1/chat/completions"], - }, - { - name: "remote no-auth", - cfg: { - gateway: { - bind: "lan", - auth: { mode: "none" }, - http: { endpoints: { responses: { enabled: true } } }, + } satisfies OpenClawConfig, + expectedNoFinding: "gateway.http.no_auth", + auditOptions: { env: {} }, + }, + { + name: "reports HTTP API session-key override surfaces when enabled", + cfg: { + gateway: { + http: { + endpoints: { + chatCompletions: { enabled: true }, + responses: { enabled: true }, + }, }, }, - expectedSeverity: "critical", - }, - ]; + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.http.session_key_override_enabled", severity: "info" }, + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg, testCase.auditOptions); - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg, { env: {} }); - expectFinding(res, "gateway.http.no_auth", testCase.expectedSeverity); - if (testCase.detailIncludes) { - const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth"); - for (const text of testCase.detailIncludes) { - expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); - } + if (testCase.expectedFinding) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + if (testCase.detailIncludes) { + const finding = res.findings.find( + (entry) => entry.checkId === testCase.expectedFinding?.checkId, + ); + for (const text of testCase.detailIncludes) { + expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); } - }), - ); - }); - - it("does not report gateway.http.no_auth when auth mode is token", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "loopback", - auth: { mode: "token", token: "secret" }, - http: { - endpoints: { - chatCompletions: { enabled: true }, - responses: { enabled: true }, - }, - }, - }, - }; - - const res = await audit(cfg, { env: {} }); - expectNoFinding(res, "gateway.http.no_auth"); - }); - - it("reports HTTP API session-key override surfaces when enabled", async () => { - const cfg: OpenClawConfig = { - gateway: { - http: { - endpoints: { - chatCompletions: { enabled: true }, - responses: { enabled: true }, - }, - }, - }, - }; - - const res = await audit(cfg); - - expectFinding(res, "gateway.http.session_key_override_enabled", "info"); + } + } + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); it("warns when state/config look like a synced folder", async () => { From 85c5ec8065b41d3c22f00e223ef65521f7ab8a04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:56:54 +0000 Subject: [PATCH 043/393] test: share audit exposure severity helper --- src/security/audit.test.ts | 42 ++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index c4a0cb27afd..d9136d80309 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -171,6 +171,22 @@ function expectNoFinding(res: SecurityAuditReport, checkId: string): void { expect(hasFinding(res, checkId)).toBe(false); } +async function expectSeverityByExposureCases(params: { + checkId: string; + cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + }>; +}) { + await Promise.all( + params.cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect(hasFinding(res, params.checkId, testCase.expectedSeverity), testCase.name).toBe(true); + }), + ); +} + async function runChannelSecurityAudit( cfg: OpenClawConfig, plugins: ChannelPlugin[], @@ -1712,15 +1728,10 @@ description: test skill }, ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "gateway.real_ip_fallback_enabled", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - }), - ); + await expectSeverityByExposureCases({ + checkId: "gateway.real_ip_fallback_enabled", + cases, + }); }); it("scores mDNS full mode risk by gateway bind mode", async () => { @@ -1763,15 +1774,10 @@ description: test skill }, ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "discovery.mdns_full_mode", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - }), - ); + await expectSeverityByExposureCases({ + checkId: "discovery.mdns_full_mode", + cases, + }); }); it("evaluates trusted-proxy auth guardrails", async () => { From 790747478e121cd627f9c07f2a3d56c473101c2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:57:53 +0000 Subject: [PATCH 044/393] test: merge loader provenance path cases --- src/plugins/loader.test.ts | 125 +++++++++++++++---------------------- 1 file changed, 51 insertions(+), 74 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 21c2df6b158..8a3f2316cb7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -509,6 +509,30 @@ module.exports = { return { pluginDir, fullMarker, setupMarker }; } +function createEnvResolvedPluginFixture(pluginId: string) { + useNoBundledPlugins(); + const openclawHome = makeTempDir(); + const ignoredHome = makeTempDir(); + const stateDir = makeTempDir(); + const pluginDir = path.join(openclawHome, "plugins", pluginId); + mkdirSafe(pluginDir); + const plugin = writePlugin({ + id: pluginId, + dir: pluginDir, + filename: "index.cjs", + body: `module.exports = { id: ${JSON.stringify(pluginId)}, register() {} };`, + }); + const env = { + ...process.env, + OPENCLAW_HOME: openclawHome, + HOME: ignoredHome, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }; + return { plugin, env }; +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -2820,92 +2844,45 @@ module.exports = { }); }); - it("does not warn about missing provenance for env-resolved load paths", () => { - useNoBundledPlugins(); - const openclawHome = makeTempDir(); - const ignoredHome = makeTempDir(); - const stateDir = makeTempDir(); - const pluginDir = path.join(openclawHome, "plugins", "tracked-load-path"); - mkdirSafe(pluginDir); - const plugin = writePlugin({ - id: "tracked-load-path", - dir: pluginDir, - filename: "index.cjs", - body: `module.exports = { id: "tracked-load-path", register() {} };`, - }); - - const warnings: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - env: { - ...process.env, - OPENCLAW_HOME: openclawHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - config: { + it.each([ + { + name: "does not warn about missing provenance for env-resolved load paths", + pluginId: "tracked-load-path", + buildConfig: (plugin: TempPlugin) => ({ plugins: { load: { paths: ["~/plugins/tracked-load-path"] }, - allow: ["tracked-load-path"], + allow: [plugin.id], }, - }, - }); - - expect(registry.plugins.find((entry) => entry.id === "tracked-load-path")?.source).toBe( - plugin.file, - ); - expect( - warnings.some((msg) => msg.includes("loaded without install/load-path provenance")), - ).toBe(false); - }); - - it("does not warn about missing provenance for env-resolved install paths", () => { - useNoBundledPlugins(); - const openclawHome = makeTempDir(); - const ignoredHome = makeTempDir(); - const stateDir = makeTempDir(); - const pluginDir = path.join(openclawHome, "plugins", "tracked-install-path"); - mkdirSafe(pluginDir); - const plugin = writePlugin({ - id: "tracked-install-path", - dir: pluginDir, - filename: "index.cjs", - body: `module.exports = { id: "tracked-install-path", register() {} };`, - }); - - const warnings: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - env: { - ...process.env, - OPENCLAW_HOME: openclawHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - config: { + }), + }, + { + name: "does not warn about missing provenance for env-resolved install paths", + pluginId: "tracked-install-path", + buildConfig: (plugin: TempPlugin) => ({ plugins: { load: { paths: [plugin.file] }, - allow: ["tracked-install-path"], + allow: [plugin.id], installs: { - "tracked-install-path": { + [plugin.id]: { source: "path", - installPath: "~/plugins/tracked-install-path", - sourcePath: "~/plugins/tracked-install-path", + installPath: `~/plugins/${plugin.id}`, + sourcePath: `~/plugins/${plugin.id}`, }, }, }, - }, + }), + }, + ])("$name", ({ pluginId, buildConfig }) => { + const { plugin, env } = createEnvResolvedPluginFixture(pluginId); + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + env, + config: buildConfig(plugin), }); - expect(registry.plugins.find((entry) => entry.id === "tracked-install-path")?.source).toBe( - plugin.file, - ); + expect(registry.plugins.find((entry) => entry.id === plugin.id)?.source).toBe(plugin.file); expect( warnings.some((msg) => msg.includes("loaded without install/load-path provenance")), ).toBe(false); From 444e3eb9e32df4197ed7d885a9a19b478092404b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:58:43 +0000 Subject: [PATCH 045/393] test: merge loader escape path cases --- src/plugins/loader.test.ts | 115 +++++++++++++++++++------------------ 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 8a3f2316cb7..0fd0b7a3ca8 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -533,6 +533,48 @@ function createEnvResolvedPluginFixture(pluginId: string) { return { plugin, env }; } +function expectEscapingEntryRejected(params: { + id: string; + linkKind: "symlink" | "hardlink"; + sourceBody: string; +}) { + useNoBundledPlugins(); + const { outsideEntry, linkedEntry } = createEscapingEntryFixture({ + id: params.id, + sourceBody: params.sourceBody, + }); + try { + if (params.linkKind === "symlink") { + fs.symlinkSync(outsideEntry, linkedEntry); + } else { + fs.linkSync(outsideEntry, linkedEntry); + } + } catch (err) { + if (params.linkKind === "hardlink" && (err as NodeJS.ErrnoException).code === "EXDEV") { + return undefined; + } + if (params.linkKind === "symlink") { + return undefined; + } + throw err; + } + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [linkedEntry] }, + allow: [params.id], + }, + }, + }); + + const record = registry.plugins.find((entry) => entry.id === params.id); + expect(record?.status).not.toBe("loaded"); + expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true); + return registry; +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -2888,66 +2930,27 @@ module.exports = { ).toBe(false); }); - it("rejects plugin entry files that escape plugin root via symlink", () => { - useNoBundledPlugins(); - const { outsideEntry, linkedEntry } = createEscapingEntryFixture({ + it.each([ + { + name: "rejects plugin entry files that escape plugin root via symlink", id: "symlinked", - sourceBody: - 'module.exports = { id: "symlinked", register() { throw new Error("should not run"); } };', - }); - try { - fs.symlinkSync(outsideEntry, linkedEntry); - } catch { - return; - } - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [linkedEntry] }, - allow: ["symlinked"], - }, - }, - }); - - const record = registry.plugins.find((entry) => entry.id === "symlinked"); - expect(record?.status).not.toBe("loaded"); - expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true); - }); - - it("rejects plugin entry files that escape plugin root via hardlink", () => { - if (process.platform === "win32") { - return; - } - useNoBundledPlugins(); - const { outsideEntry, linkedEntry } = createEscapingEntryFixture({ + linkKind: "symlink" as const, + }, + { + name: "rejects plugin entry files that escape plugin root via hardlink", id: "hardlinked", - sourceBody: - 'module.exports = { id: "hardlinked", register() { throw new Error("should not run"); } };', - }); - try { - fs.linkSync(outsideEntry, linkedEntry); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EXDEV") { - return; - } - throw err; + linkKind: "hardlink" as const, + skip: process.platform === "win32", + }, + ])("$name", ({ id, linkKind, skip }) => { + if (skip) { + return; } - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [linkedEntry] }, - allow: ["hardlinked"], - }, - }, + expectEscapingEntryRejected({ + id, + linkKind, + sourceBody: `module.exports = { id: "${id}", register() { throw new Error("should not run"); } };`, }); - - const record = registry.plugins.find((entry) => entry.id === "hardlinked"); - expect(record?.status).not.toBe("loaded"); - expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true); }); it("allows bundled plugin entry files that are hardlinked aliases", () => { From bf22e9461e99b9a157e0d971e3b1fcd2bb1e4b52 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 08:59:57 +0000 Subject: [PATCH 046/393] test: merge loader alias resolution cases --- src/plugins/loader.test.ts | 293 +++++++++++++++++++++---------------- 1 file changed, 168 insertions(+), 125 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0fd0b7a3ca8..5948dab99bc 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -575,6 +575,53 @@ function expectEscapingEntryRejected(params: { return registry; } +function resolvePluginSdkAlias(params: { + root: string; + srcFile: string; + distFile: string; + modulePath: string; + argv1?: string; + env?: NodeJS.ProcessEnv; +}) { + const run = () => + __testing.resolvePluginSdkAliasFile({ + srcFile: params.srcFile, + distFile: params.distFile, + modulePath: params.modulePath, + argv1: params.argv1, + }); + return params.env ? withEnv(params.env, run) : run(); +} + +function listPluginSdkAliasCandidates(params: { + root: string; + srcFile: string; + distFile: string; + modulePath: string; + env?: NodeJS.ProcessEnv; +}) { + const run = () => + __testing.listPluginSdkAliasCandidates({ + srcFile: params.srcFile, + distFile: params.distFile, + modulePath: params.modulePath, + }); + return params.env ? withEnv(params.env, run) : run(); +} + +function resolvePluginRuntimeModule(params: { + modulePath: string; + argv1?: string; + env?: NodeJS.ProcessEnv; +}) { + const run = () => + __testing.resolvePluginRuntimeModulePath({ + modulePath: params.modulePath, + argv1: params.argv1, + }); + return params.env ? withEnv(params.env, run) : run(); +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -3088,56 +3135,112 @@ module.exports = { }); }); - it("prefers dist plugin-sdk alias when loader runs from dist", () => { - const { root, distFile } = createPluginSdkAliasFixture(); - - const resolved = __testing.resolvePluginSdkAliasFile({ + it.each([ + { + name: "prefers dist plugin-sdk alias when loader runs from dist", + buildFixture: () => createPluginSdkAliasFixture(), + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), srcFile: "index.ts", distFile: "index.js", - modulePath: path.join(root, "dist", "plugins", "loader.js"), + expected: "dist" as const, + }, + { + name: "prefers src plugin-sdk alias when loader runs from src in non-production", + buildFixture: () => createPluginSdkAliasFixture(), + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + srcFile: "index.ts", + distFile: "index.js", + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + { + name: "falls back to src plugin-sdk alias when dist is missing in production", + buildFixture: () => { + const fixture = createPluginSdkAliasFixture(); + fs.rmSync(fixture.distFile); + return fixture; + }, + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + srcFile: "index.ts", + distFile: "index.js", + env: { NODE_ENV: "production", VITEST: undefined }, + expected: "src" as const, + }, + { + name: "prefers dist root-alias shim when loader runs from dist", + buildFixture: () => + createPluginSdkAliasFixture({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + srcBody: "module.exports = {};\n", + distBody: "module.exports = {};\n", + }), + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + expected: "dist" as const, + }, + { + name: "prefers src root-alias shim when loader runs from src in non-production", + buildFixture: () => + createPluginSdkAliasFixture({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + srcBody: "module.exports = {};\n", + distBody: "module.exports = {};\n", + }), + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + { + name: "resolves plugin-sdk alias from package root when loader runs from transpiler cache path", + buildFixture: () => createPluginSdkAliasFixture(), + modulePath: () => "/tmp/tsx-cache/openclaw-loader.js", + argv1: (root: string) => path.join(root, "openclaw.mjs"), + srcFile: "index.ts", + distFile: "index.js", + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + ])("$name", ({ buildFixture, modulePath, argv1, srcFile, distFile, env, expected }) => { + const fixture = buildFixture(); + const resolved = resolvePluginSdkAlias({ + root: fixture.root, + srcFile, + distFile, + modulePath: modulePath(fixture.root), + argv1: argv1?.(fixture.root), + env, }); - expect(resolved).toBe(distFile); + expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); - it("prefers dist candidates first for production src runtime", () => { - const { root, srcFile, distFile } = createPluginSdkAliasFixture(); - - const candidates = withEnv({ NODE_ENV: "production", VITEST: undefined }, () => - __testing.listPluginSdkAliasCandidates({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - - expect(candidates.indexOf(distFile)).toBeLessThan(candidates.indexOf(srcFile)); - }); - - it("prefers src plugin-sdk alias when loader runs from src in non-production", () => { - const { root, srcFile } = createPluginSdkAliasFixture(); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("prefers src candidates first for non-production src runtime", () => { - const { root, srcFile, distFile } = createPluginSdkAliasFixture(); - - const candidates = withEnv({ NODE_ENV: undefined }, () => - __testing.listPluginSdkAliasCandidates({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - - expect(candidates.indexOf(srcFile)).toBeLessThan(candidates.indexOf(distFile)); + it.each([ + { + name: "prefers dist candidates first for production src runtime", + env: { NODE_ENV: "production", VITEST: undefined }, + expectedFirst: "dist" as const, + }, + { + name: "prefers src candidates first for non-production src runtime", + env: { NODE_ENV: undefined }, + expectedFirst: "src" as const, + }, + ])("$name", ({ env, expectedFirst }) => { + const fixture = createPluginSdkAliasFixture(); + const candidates = listPluginSdkAliasCandidates({ + root: fixture.root, + srcFile: "index.ts", + distFile: "index.js", + modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"), + env, + }); + const first = expectedFirst === "dist" ? fixture.distFile : fixture.srcFile; + const second = expectedFirst === "dist" ? fixture.srcFile : fixture.distFile; + expect(candidates.indexOf(first)).toBeLessThan(candidates.indexOf(second)); }); it("derives plugin-sdk subpaths from package exports", () => { @@ -3147,36 +3250,6 @@ module.exports = { expect(subpaths).not.toContain("root-alias"); }); - it("falls back to src plugin-sdk alias when dist is missing in production", () => { - const { root, srcFile, distFile } = createPluginSdkAliasFixture(); - fs.rmSync(distFile); - - const resolved = withEnv({ NODE_ENV: "production", VITEST: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("prefers dist root-alias shim when loader runs from dist", () => { - const { root, distFile } = createPluginSdkAliasFixture({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - srcBody: "module.exports = {};\n", - distBody: "module.exports = {};\n", - }); - - const resolved = __testing.resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath: path.join(root, "dist", "plugins", "loader.js"), - }); - expect(resolved).toBe(distFile); - }); - it("configures the plugin loader jiti boundary to prefer native dist modules", () => { const options = __testing.buildPluginLoaderJitiOptions({}); @@ -3187,56 +3260,26 @@ module.exports = { expect("alias" in options).toBe(false); }); - it("prefers src root-alias shim when loader runs from src in non-production", () => { - const { root, srcFile } = createPluginSdkAliasFixture({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - srcBody: "module.exports = {};\n", - distBody: "module.exports = {};\n", + it.each([ + { + name: "prefers dist plugin runtime module when loader runs from dist", + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), + expected: "dist" as const, + }, + { + name: "resolves plugin runtime module from package root when loader runs from transpiler cache path", + modulePath: () => "/tmp/tsx-cache/openclaw-loader.js", + argv1: (root: string) => path.join(root, "openclaw.mjs"), + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + ])("$name", ({ modulePath, argv1, env, expected }) => { + const fixture = createPluginRuntimeAliasFixture(); + const resolved = resolvePluginRuntimeModule({ + modulePath: modulePath(fixture.root), + argv1: argv1?.(fixture.root), + env, }); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("resolves plugin-sdk alias from package root when loader runs from transpiler cache path", () => { - const { root, srcFile } = createPluginSdkAliasFixture(); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: "/tmp/tsx-cache/openclaw-loader.js", - argv1: path.join(root, "openclaw.mjs"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("prefers dist plugin runtime module when loader runs from dist", () => { - const { root, distFile } = createPluginRuntimeAliasFixture(); - - const resolved = __testing.resolvePluginRuntimeModulePath({ - modulePath: path.join(root, "dist", "plugins", "loader.js"), - }); - expect(resolved).toBe(distFile); - }); - - it("resolves plugin runtime module from package root when loader runs from transpiler cache path", () => { - const { root, srcFile } = createPluginRuntimeAliasFixture(); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginRuntimeModulePath({ - modulePath: "/tmp/tsx-cache/openclaw-loader.js", - argv1: path.join(root, "openclaw.mjs"), - }), - ); - expect(resolved).toBe(srcFile); + expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); }); From 7efa79121aae66f501dc72cc88e03abbc9ed68e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:01:23 +0000 Subject: [PATCH 047/393] test: merge install metadata audit cases --- src/security/audit.test.ts | 150 +++++++++++++++++++------------------ 1 file changed, 76 insertions(+), 74 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index d9136d80309..c535a747432 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -199,6 +199,20 @@ async function runChannelSecurityAudit( }); } +async function runInstallMetadataAudit( + cfg: OpenClawConfig, + stateDir: string, +): Promise { + return runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + execDockerRawFn: execDockerRawUnavailable, + }); +} + describe("security audit", () => { let fixtureRoot = ""; let caseId = 0; @@ -3076,80 +3090,75 @@ description: test skill } }); - it("warns on unpinned npm install specs and missing integrity metadata", async () => { - const cfg: OpenClawConfig = { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call", - }, - }, - }, - hooks: { - internal: { + it.each([ + { + name: "warns on unpinned npm install specs and missing integrity metadata", + cfg: { + plugins: { installs: { - "test-hooks": { + "voice-call": { source: "npm", - spec: "@openclaw/test-hooks", + spec: "@openclaw/voice-call", }, }, }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir: sharedInstallMetadataStateDir, - configPath: path.join(sharedInstallMetadataStateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(hasFinding(res, "plugins.installs_unpinned_npm_specs", "warn")).toBe(true); - expect(hasFinding(res, "plugins.installs_missing_integrity", "warn")).toBe(true); - expect(hasFinding(res, "hooks.installs_unpinned_npm_specs", "warn")).toBe(true); - expect(hasFinding(res, "hooks.installs_missing_integrity", "warn")).toBe(true); - }); - - it("does not warn on pinned npm install specs with integrity metadata", async () => { - const cfg: OpenClawConfig = { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call@1.2.3", - integrity: "sha512-plugin", - }, - }, - }, - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks", + }, }, }, }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir: sharedInstallMetadataStateDir, - configPath: path.join(sharedInstallMetadataStateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(hasFinding(res, "plugins.installs_unpinned_npm_specs")).toBe(false); - expect(hasFinding(res, "plugins.installs_missing_integrity")).toBe(false); - expect(hasFinding(res, "hooks.installs_unpinned_npm_specs")).toBe(false); - expect(hasFinding(res, "hooks.installs_missing_integrity")).toBe(false); + } satisfies OpenClawConfig, + expectedPresent: [ + "plugins.installs_unpinned_npm_specs", + "plugins.installs_missing_integrity", + "hooks.installs_unpinned_npm_specs", + "hooks.installs_missing_integrity", + ], + }, + { + name: "does not warn on pinned npm install specs with integrity metadata", + cfg: { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.2.3", + integrity: "sha512-plugin", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectedAbsent: [ + "plugins.installs_unpinned_npm_specs", + "plugins.installs_missing_integrity", + "hooks.installs_unpinned_npm_specs", + "hooks.installs_missing_integrity", + ], + }, + ])("$name", async (testCase) => { + const res = await runInstallMetadataAudit(testCase.cfg, sharedInstallMetadataStateDir); + for (const checkId of testCase.expectedPresent ?? []) { + expect(hasFinding(res, checkId, "warn"), checkId).toBe(true); + } + for (const checkId of testCase.expectedAbsent ?? []) { + expect(hasFinding(res, checkId), checkId).toBe(false); + } }); it("warns when install records drift from installed package versions", async () => { @@ -3195,14 +3204,7 @@ description: test skill }, }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); + const res = await runInstallMetadataAudit(cfg, stateDir); expect(hasFinding(res, "plugins.installs_version_drift", "warn")).toBe(true); expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true); From d988e39fc7a0263aee1dbf6771c02b522ffab159 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:04:08 +0000 Subject: [PATCH 048/393] test: merge loader duplicate registration cases --- src/plugins/loader.test.ts | 229 ++++++++++++++----------------------- 1 file changed, 84 insertions(+), 145 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 5948dab99bc..62fbe163c8e 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -238,6 +238,22 @@ function loadRegistryFromSinglePlugin(params: { }); } +function loadRegistryFromAllowedPlugins( + plugins: TempPlugin[], + options?: Omit[0], "cache" | "config">, +) { + return loadOpenClawPlugins({ + cache: false, + ...options, + config: { + plugins: { + load: { paths: plugins.map((plugin) => plugin.file) }, + allow: plugins.map((plugin) => plugin.id), + }, + }, + }); +} + function createWarningLogger(warnings: string[]) { return { info: () => {}, @@ -1705,84 +1721,84 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(httpPlugin?.httpRoutes).toBe(1); }); - it("rejects duplicate plugin-visible hook names", () => { + it("rejects duplicate plugin registrations", () => { useNoBundledPlugins(); - const first = writePlugin({ - id: "hook-owner-a", - filename: "hook-owner-a.cjs", - body: `module.exports = { id: "hook-owner-a", register(api) { + const scenarios = [ + { + label: "plugin-visible hook names", + ownerA: "hook-owner-a", + ownerB: "hook-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); } };`, - }); - const second = writePlugin({ - id: "hook-owner-b", - filename: "hook-owner-b.cjs", - body: `module.exports = { id: "hook-owner-b", register(api) { - api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["hook-owner-a", "hook-owner-b"], - }, + selectCount: (registry: ReturnType) => + registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook").length, + duplicateMessage: "hook already registered: shared-hook (hook-owner-a)", }, - }); - - expect(registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook")).toHaveLength( - 1, - ); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "hook-owner-b" && - diag.message === "hook already registered: shared-hook (hook-owner-a)", - ), - ).toBe(true); - }); - - it("rejects duplicate plugin service ids", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "service-owner-a", - filename: "service-owner-a.cjs", - body: `module.exports = { id: "service-owner-a", register(api) { + { + label: "plugin service ids", + ownerA: "service-owner-a", + ownerB: "service-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { api.registerService({ id: "shared-service", start() {} }); } };`, - }); - const second = writePlugin({ - id: "service-owner-b", - filename: "service-owner-b.cjs", - body: `module.exports = { id: "service-owner-b", register(api) { - api.registerService({ id: "shared-service", start() {} }); + selectCount: (registry: ReturnType) => + registry.services.filter((entry) => entry.service.id === "shared-service").length, + duplicateMessage: "service already registered: shared-service (service-owner-a)", + }, + { + label: "plugin context engine ids", + ownerA: "context-engine-owner-a", + ownerB: "context-engine-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); } };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["service-owner-a", "service-owner-b"], + selectCount: () => 1, + duplicateMessage: + "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", + }, + { + label: "plugin CLI command roots", + ownerA: "cli-owner-a", + ownerB: "cli-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { + api.registerCli(() => {}, { commands: ["shared-cli"] }); +} };`, + selectCount: (registry: ReturnType) => + registry.cliRegistrars.length, + duplicateMessage: "cli command already registered: shared-cli (cli-owner-a)", + assertPrimaryOwner: (registry: ReturnType) => { + expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a"); }, }, - }); + ] as const; - expect(registry.services.filter((entry) => entry.service.id === "shared-service")).toHaveLength( - 1, - ); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "service-owner-b" && - diag.message === "service already registered: shared-service (service-owner-a)", - ), - ).toBe(true); + for (const scenario of scenarios) { + const first = writePlugin({ + id: scenario.ownerA, + filename: `${scenario.ownerA}.cjs`, + body: scenario.buildBody(scenario.ownerA), + }); + const second = writePlugin({ + id: scenario.ownerB, + filename: `${scenario.ownerB}.cjs`, + body: scenario.buildBody(scenario.ownerB), + }); + + const registry = loadRegistryFromAllowedPlugins([first, second]); + + expect(scenario.selectCount(registry), scenario.label).toBe(1); + scenario.assertPrimaryOwner?.(registry); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === scenario.ownerB && + diag.message === scenario.duplicateMessage, + ), + scenario.label, + ).toBe(true); + } }); it("rejects plugin context engine ids reserved by core", () => { @@ -1812,44 +1828,6 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s ).toBe(true); }); - it("rejects duplicate plugin context engine ids", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "context-engine-owner-a", - filename: "context-engine-owner-a.cjs", - body: `module.exports = { id: "context-engine-owner-a", register(api) { - api.registerContextEngine("shared-context-engine-loader-test", () => ({})); -} };`, - }); - const second = writePlugin({ - id: "context-engine-owner-b", - filename: "context-engine-owner-b.cjs", - body: `module.exports = { id: "context-engine-owner-b", register(api) { - api.registerContextEngine("shared-context-engine-loader-test", () => ({})); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["context-engine-owner-a", "context-engine-owner-b"], - }, - }, - }); - - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "context-engine-owner-b" && - diag.message === - "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", - ), - ).toBe(true); - }); - it("requires plugin CLI registrars to declare explicit command roots", () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -1878,45 +1856,6 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s ).toBe(true); }); - it("rejects duplicate plugin CLI command roots", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "cli-owner-a", - filename: "cli-owner-a.cjs", - body: `module.exports = { id: "cli-owner-a", register(api) { - api.registerCli(() => {}, { commands: ["shared-cli"] }); -} };`, - }); - const second = writePlugin({ - id: "cli-owner-b", - filename: "cli-owner-b.cjs", - body: `module.exports = { id: "cli-owner-b", register(api) { - api.registerCli(() => {}, { commands: ["shared-cli"] }); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["cli-owner-a", "cli-owner-b"], - }, - }, - }); - - expect(registry.cliRegistrars).toHaveLength(1); - expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a"); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "cli-owner-b" && - diag.message === "cli command already registered: shared-cli (cli-owner-a)", - ), - ).toBe(true); - }); - it("registers http routes", () => { useNoBundledPlugins(); const plugin = writePlugin({ From 2c073e7bcbf94a42b6d0abd7174ab6f58774a821 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:05:16 +0000 Subject: [PATCH 049/393] test: merge loader http route cases --- src/plugins/loader.test.ts | 316 +++++++++++++++++++------------------ 1 file changed, 164 insertions(+), 152 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 62fbe163c8e..2f1db1d46c3 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1690,35 +1690,53 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s ).toBe(true); }); - it("registers http routes with auth and match options", () => { + it("registers plugin http routes", () => { useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-demo", - filename: "http-demo.cjs", - body: `module.exports = { id: "http-demo", register(api) { - api.registerHttpRoute({ - path: "/webhook", - auth: "plugin", - match: "prefix", - handler: async () => false - }); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-demo"], + const scenarios = [ + { + label: "defaults exact match", + pluginId: "http-route-demo", + routeOptions: + '{ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }', + expectedPath: "/demo", + expectedAuth: "gateway", + expectedMatch: "exact", }, - }); + { + label: "keeps explicit auth and match options", + pluginId: "http-demo", + routeOptions: + '{ path: "/webhook", auth: "plugin", match: "prefix", handler: async () => false }', + expectedPath: "/webhook", + expectedAuth: "plugin", + expectedMatch: "prefix", + }, + ] as const; - const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-demo"); - expect(route).toBeDefined(); - expect(route?.path).toBe("/webhook"); - expect(route?.auth).toBe("plugin"); - expect(route?.match).toBe("prefix"); - const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo"); - expect(httpPlugin?.httpRoutes).toBe(1); + for (const scenario of scenarios) { + const plugin = writePlugin({ + id: scenario.pluginId, + filename: `${scenario.pluginId}.cjs`, + body: `module.exports = { id: "${scenario.pluginId}", register(api) { + api.registerHttpRoute(${scenario.routeOptions}); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: [scenario.pluginId], + }, + }); + + const route = registry.httpRoutes.find((entry) => entry.pluginId === scenario.pluginId); + expect(route, scenario.label).toBeDefined(); + expect(route?.path, scenario.label).toBe(scenario.expectedPath); + expect(route?.auth, scenario.label).toBe(scenario.expectedAuth); + expect(route?.match, scenario.label).toBe(scenario.expectedMatch); + const httpPlugin = registry.plugins.find((entry) => entry.id === scenario.pluginId); + expect(httpPlugin?.httpRoutes, scenario.label).toBe(1); + } }); it("rejects duplicate plugin registrations", () => { @@ -1941,146 +1959,140 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(loaded?.error).not.toContain("api.registerHttpHandler(...) was removed"); }); - it("rejects plugin http routes missing explicit auth", () => { + it("enforces plugin http route validation and conflict rules", () => { useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-missing-auth", - filename: "http-route-missing-auth.cjs", - body: `module.exports = { id: "http-route-missing-auth", register(api) { + const scenarios = [ + { + label: "missing auth is rejected", + buildPlugins: () => [ + writePlugin({ + id: "http-route-missing-auth", + filename: "http-route-missing-auth.cjs", + body: `module.exports = { id: "http-route-missing-auth", register(api) { api.registerHttpRoute({ path: "/demo", handler: async () => true }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-missing-auth"], - }, - }); - - expect(registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth")).toBe( - undefined, - ); - expect( - registry.diagnostics.some((diag) => - String(diag.message).includes("http route registration missing or invalid auth"), - ), - ).toBe(true); - }); - - it("allows explicit replaceExisting for same-plugin http route overrides", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-replace-self", - filename: "http-route-replace-self.cjs", - body: `module.exports = { id: "http-route-replace-self", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); - api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-replace-self"], - }, - }); - - const routes = registry.httpRoutes.filter( - (entry) => entry.pluginId === "http-route-replace-self", - ); - expect(routes).toHaveLength(1); - expect(routes[0]?.path).toBe("/demo"); - expect(registry.diagnostics).toEqual([]); - }); - - it("rejects http route replacement when another plugin owns the route", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "http-route-owner-a", - filename: "http-route-owner-a.cjs", - body: `module.exports = { id: "http-route-owner-a", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); -} };`, - }); - const second = writePlugin({ - id: "http-route-owner-b", - filename: "http-route-owner-b.cjs", - body: `module.exports = { id: "http-route-owner-b", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["http-route-owner-a", "http-route-owner-b"], + }), + ], + assert: (registry: ReturnType) => { + expect( + registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth"), + ).toBeUndefined(); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route registration missing or invalid auth"), + ), + ).toBe(true); }, }, - }); - - const route = registry.httpRoutes.find((entry) => entry.path === "/demo"); - expect(route?.pluginId).toBe("http-route-owner-a"); - expect( - registry.diagnostics.some((diag) => - String(diag.message).includes("http route replacement rejected"), - ), - ).toBe(true); - }); - - it("rejects mixed-auth overlapping http routes", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-overlap", - filename: "http-route-overlap.cjs", - body: `module.exports = { id: "http-route-overlap", register(api) { + { + label: "same plugin can replace its own route", + buildPlugins: () => [ + writePlugin({ + id: "http-route-replace-self", + filename: "http-route-replace-self.cjs", + body: `module.exports = { id: "http-route-replace-self", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); + api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); +} };`, + }), + ], + assert: (registry: ReturnType) => { + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-replace-self", + ); + expect(routes).toHaveLength(1); + expect(routes[0]?.path).toBe("/demo"); + expect(registry.diagnostics).toEqual([]); + }, + }, + { + label: "cross-plugin replaceExisting is rejected", + buildPlugins: () => [ + writePlugin({ + id: "http-route-owner-a", + filename: "http-route-owner-a.cjs", + body: `module.exports = { id: "http-route-owner-a", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); +} };`, + }), + writePlugin({ + id: "http-route-owner-b", + filename: "http-route-owner-b.cjs", + body: `module.exports = { id: "http-route-owner-b", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); +} };`, + }), + ], + assert: (registry: ReturnType) => { + const route = registry.httpRoutes.find((entry) => entry.path === "/demo"); + expect(route?.pluginId).toBe("http-route-owner-a"); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route replacement rejected"), + ), + ).toBe(true); + }, + }, + { + label: "mixed-auth overlaps are rejected", + buildPlugins: () => [ + writePlugin({ + id: "http-route-overlap", + filename: "http-route-overlap.cjs", + body: `module.exports = { id: "http-route-overlap", register(api) { api.registerHttpRoute({ path: "/plugin/secure", auth: "gateway", match: "prefix", handler: async () => true }); api.registerHttpRoute({ path: "/plugin/secure/report", auth: "plugin", match: "exact", handler: async () => true }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-overlap"], + }), + ], + assert: (registry: ReturnType) => { + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-overlap", + ); + expect(routes).toHaveLength(1); + expect(routes[0]?.path).toBe("/plugin/secure"); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route overlap rejected"), + ), + ).toBe(true); + }, }, - }); - - const routes = registry.httpRoutes.filter((entry) => entry.pluginId === "http-route-overlap"); - expect(routes).toHaveLength(1); - expect(routes[0]?.path).toBe("/plugin/secure"); - expect( - registry.diagnostics.some((diag) => - String(diag.message).includes("http route overlap rejected"), - ), - ).toBe(true); - }); - - it("allows same-auth overlapping http routes", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-overlap-same-auth", - filename: "http-route-overlap-same-auth.cjs", - body: `module.exports = { id: "http-route-overlap-same-auth", register(api) { + { + label: "same-auth overlaps are allowed", + buildPlugins: () => [ + writePlugin({ + id: "http-route-overlap-same-auth", + filename: "http-route-overlap-same-auth.cjs", + body: `module.exports = { id: "http-route-overlap-same-auth", register(api) { api.registerHttpRoute({ path: "/plugin/public", auth: "plugin", match: "prefix", handler: async () => true }); api.registerHttpRoute({ path: "/plugin/public/report", auth: "plugin", match: "exact", handler: async () => true }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-overlap-same-auth"], + }), + ], + assert: (registry: ReturnType) => { + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-overlap-same-auth", + ); + expect(routes).toHaveLength(2); + expect(registry.diagnostics).toEqual([]); + }, }, - }); + ] as const; - const routes = registry.httpRoutes.filter( - (entry) => entry.pluginId === "http-route-overlap-same-auth", - ); - expect(routes).toHaveLength(2); - expect(registry.diagnostics).toEqual([]); + for (const scenario of scenarios) { + const plugins = scenario.buildPlugins(); + const registry = + plugins.length === 1 + ? loadRegistryFromSinglePlugin({ + plugin: plugins[0], + pluginConfig: { + allow: [plugins[0].id], + }, + }) + : loadRegistryFromAllowedPlugins(plugins); + scenario.assert(registry); + } }); it("respects explicit disable in config", () => { From 588c8be6ffc3e644ea2287ccaa9b92ebdde87bd0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:08:28 +0000 Subject: [PATCH 050/393] test: merge audit extension and workspace cases --- src/security/audit.test.ts | 430 ++++++++++++++++++------------------- 1 file changed, 211 insertions(+), 219 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index c535a747432..9da4ea283db 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -250,6 +250,17 @@ describe("security audit", () => { ); }; + const runSharedExtensionsAudit = async (config: OpenClawConfig) => { + return runSecurityAudit({ + config, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir: sharedExtensionsStateDir, + configPath: path.join(sharedExtensionsStateDir, "openclaw.json"), + execDockerRawFn: execDockerRawUnavailable, + }); + }; + const createSharedCodeSafetyFixture = async () => { const stateDir = await makeTmpDir("audit-scanner-shared"); const workspaceDir = path.join(stateDir, "workspace"); @@ -642,52 +653,64 @@ description: test skill ); }); - it("warns for risky safeBinTrustedDirs entries", async () => { + it("evaluates safeBinTrustedDirs risk findings", async () => { const riskyGlobalTrustedDirs = process.platform === "win32" ? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`] : ["/usr/local/bin", "/tmp/openclaw-safe-bins"]; - const cfg: OpenClawConfig = { - tools: { - exec: { - safeBinTrustedDirs: riskyGlobalTrustedDirs, - }, - }, - agents: { - list: [ - { - id: "ops", - tools: { - exec: { - safeBinTrustedDirs: ["./relative-bin-dir"], - }, + const cases = [ + { + name: "warns for risky global and relative trusted dirs", + cfg: { + tools: { + exec: { + safeBinTrustedDirs: riskyGlobalTrustedDirs, }, }, - ], - }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]); - expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]); - expect(finding?.detail).toContain("agents.list.ops.tools.exec"); - }); - - it("does not warn for non-risky absolute safeBinTrustedDirs entries", async () => { - const cfg: OpenClawConfig = { - tools: { - exec: { - safeBinTrustedDirs: ["/usr/libexec"], + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBinTrustedDirs: ["./relative-bin-dir"], + }, + }, + }, + ], + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + const finding = res.findings.find( + (f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]); + expect(finding?.detail).toContain("agents.list.ops.tools.exec"); }, }, - }; + { + name: "ignores non-risky absolute dirs", + cfg: { + tools: { + exec: { + safeBinTrustedDirs: ["/usr/libexec"], + }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky"); + }, + }, + ] as const; - const res = await audit(cfg); - expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky"); + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + testCase.assert(res); + }), + ); }); it("evaluates loopback control UI and logging exposure findings", async () => { @@ -971,69 +994,80 @@ description: test skill expect(res.findings.some((f) => f.checkId === "fs.config.perms_group_readable")).toBe(false); }); - it("warns when workspace skill files resolve outside workspace root", async () => { - if (isWindows) { - return; + it("evaluates workspace skill path escape findings", async () => { + const cases = [ + { + name: "warns when workspace skill files resolve outside workspace root", + supported: !isWindows, + setup: async () => { + const tmp = await makeTmpDir("workspace-skill-symlink-escape"); + const stateDir = path.join(tmp, "state"); + const workspaceDir = path.join(tmp, "workspace"); + const outsideDir = path.join(tmp, "outside"); + await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); + await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + + const outsideSkillPath = path.join(outsideDir, "SKILL.md"); + await fs.writeFile(outsideSkillPath, "# outside\n", "utf-8"); + await fs.symlink(outsideSkillPath, path.join(workspaceDir, "skills", "leak", "SKILL.md")); + + return { stateDir, workspaceDir, outsideSkillPath }; + }, + assert: ( + res: SecurityAuditReport, + fixture: { stateDir: string; workspaceDir: string; outsideSkillPath: string }, + ) => { + const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape"); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain(fixture.outsideSkillPath); + }, + }, + { + name: "does not warn for workspace skills that stay inside workspace root", + supported: true, + setup: async () => { + const tmp = await makeTmpDir("workspace-skill-in-root"); + const stateDir = path.join(tmp, "state"); + const workspaceDir = path.join(tmp, "workspace"); + await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); + await fs.mkdir(path.join(workspaceDir, "skills", "safe"), { recursive: true }); + await fs.writeFile( + path.join(workspaceDir, "skills", "safe", "SKILL.md"), + "# in workspace\n", + "utf-8", + ); + return { stateDir, workspaceDir }; + }, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "skills.workspace.symlink_escape"); + }, + }, + ] as const; + + for (const testCase of cases) { + if (!testCase.supported) { + continue; + } + + const fixture = await testCase.setup(); + const configPath = path.join(fixture.stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); + if (!isWindows) { + await fs.chmod(configPath, 0o600); + } + + const res = await runSecurityAudit({ + config: { agents: { defaults: { workspace: fixture.workspaceDir } } }, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir: fixture.stateDir, + configPath, + execDockerRawFn: execDockerRawUnavailable, + }); + + testCase.assert(res, fixture); } - - const tmp = await makeTmpDir("workspace-skill-symlink-escape"); - const stateDir = path.join(tmp, "state"); - const workspaceDir = path.join(tmp, "workspace"); - const outsideDir = path.join(tmp, "outside"); - await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); - await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true }); - await fs.mkdir(outsideDir, { recursive: true }); - - const outsideSkillPath = path.join(outsideDir, "SKILL.md"); - await fs.writeFile(outsideSkillPath, "# outside\n", "utf-8"); - await fs.symlink(outsideSkillPath, path.join(workspaceDir, "skills", "leak", "SKILL.md")); - - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - await fs.chmod(configPath, 0o600); - - const res = await runSecurityAudit({ - config: { agents: { defaults: { workspace: workspaceDir } } }, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn: execDockerRawUnavailable, - }); - - const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape"); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain(outsideSkillPath); - }); - - it("does not warn for workspace skills that stay inside workspace root", async () => { - const tmp = await makeTmpDir("workspace-skill-in-root"); - const stateDir = path.join(tmp, "state"); - const workspaceDir = path.join(tmp, "workspace"); - await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); - await fs.mkdir(path.join(workspaceDir, "skills", "safe"), { recursive: true }); - await fs.writeFile( - path.join(workspaceDir, "skills", "safe", "SKILL.md"), - "# in workspace\n", - "utf-8", - ); - - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - if (!isWindows) { - await fs.chmod(configPath, 0o600); - } - - const res = await runSecurityAudit({ - config: { agents: { defaults: { workspace: workspaceDir } } }, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings.some((f) => f.checkId === "skills.workspace.symlink_escape")).toBe(false); }); it("scores small-model risk by tool/sandbox exposure", async () => { @@ -3210,131 +3244,89 @@ description: test skill expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true); }); - it("flags enabled extensions when tool policy can expose plugin tools", async () => { - const stateDir = sharedExtensionsStateDir; - - const cfg: OpenClawConfig = { - plugins: { allow: ["some-plugin"] }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "plugins.tools_reachable_permissive_policy", - severity: "warn", - }), - ]), - ); - }); - - it("does not flag plugin tool reachability when profile is restrictive", async () => { - const stateDir = sharedExtensionsStateDir; - - const cfg: OpenClawConfig = { - plugins: { allow: ["some-plugin"] }, - tools: { profile: "coding" }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect( - res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"), - ).toBe(false); - }); - - it("flags unallowlisted extensions as critical when native skill commands are exposed", async () => { - const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - const stateDir = sharedExtensionsStateDir; - - try { - const cfg: OpenClawConfig = { - channels: { - discord: { enabled: true, token: "t" }, + it("evaluates extension tool reachability findings", async () => { + const cases = [ + { + name: "flags enabled extensions when tool policy can expose plugin tools", + cfg: { + plugins: { allow: ["some-plugin"] }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.tools_reachable_permissive_policy", + severity: "warn", + }), + ]), + ); }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "plugins.extensions_no_allowlist", - severity: "critical", - }), - ]), - ); - } finally { - if (prevDiscordToken == null) { - delete process.env.DISCORD_BOT_TOKEN; - } else { - process.env.DISCORD_BOT_TOKEN = prevDiscordToken; - } - } - }); - - it("treats SecretRef channel credentials as configured for extension allowlist severity", async () => { - const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - const stateDir = sharedExtensionsStateDir; - - try { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: { - source: "env", - provider: "default", - id: "DISCORD_BOT_TOKEN", - } as unknown as string, + }, + { + name: "does not flag plugin tool reachability when profile is restrictive", + cfg: { + plugins: { allow: ["some-plugin"] }, + tools: { profile: "coding" }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"), + ).toBe(false); + }, + }, + { + name: "flags unallowlisted extensions as critical when native skill commands are exposed", + cfg: { + channels: { + discord: { enabled: true, token: "t" }, }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.extensions_no_allowlist", + severity: "critical", + }), + ]), + ); }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); + }, + { + name: "treats SecretRef channel credentials as configured for extension allowlist severity", + cfg: { + channels: { + discord: { + enabled: true, + token: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + } as unknown as string, + }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.extensions_no_allowlist", + severity: "critical", + }), + ]), + ); + }, + }, + ] as const; - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "plugins.extensions_no_allowlist", - severity: "critical", - }), - ]), + await withEnvAsync({ DISCORD_BOT_TOKEN: undefined }, async () => { + await Promise.all( + cases.map(async (testCase) => { + const res = await runSharedExtensionsAudit(testCase.cfg); + testCase.assert(res); + }), ); - } finally { - if (prevDiscordToken == null) { - delete process.env.DISCORD_BOT_TOKEN; - } else { - process.env.DISCORD_BOT_TOKEN = prevDiscordToken; - } - } + }); }); it("does not scan plugin code safety findings when deep audit is disabled", async () => { From 1a3bde81d88073d257a11967a58df1bfc311cd07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:09:28 +0000 Subject: [PATCH 051/393] test: merge loader single-plugin registration cases --- src/plugins/loader.test.ts | 197 +++++++++++++++---------------------- 1 file changed, 77 insertions(+), 120 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 2f1db1d46c3..7760b56def1 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1589,12 +1589,13 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s ).toBe(true); }); - it("registers channel plugins", () => { + it("handles single-plugin channel, context engine, and cli validation", () => { useNoBundledPlugins(); - const plugin = writePlugin({ - id: "channel-demo", - filename: "channel-demo.cjs", - body: `module.exports = { id: "channel-demo", register(api) { + const scenarios = [ + { + label: "registers channel plugins", + pluginId: "channel-demo", + body: `module.exports = { id: "channel-demo", register(api) { api.registerChannel({ plugin: { id: "demo", @@ -1614,25 +1615,15 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s } }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["channel-demo"], + assert: (registry: ReturnType) => { + const channel = registry.channels.find((entry) => entry.plugin.id === "demo"); + expect(channel).toBeDefined(); + }, }, - }); - - const channel = registry.channels.find((entry) => entry.plugin.id === "demo"); - expect(channel).toBeDefined(); - }); - - it("rejects duplicate channel ids during plugin registration", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "channel-dup", - filename: "channel-dup.cjs", - body: `module.exports = { id: "channel-dup", register(api) { + { + label: "rejects duplicate channel ids during plugin registration", + pluginId: "channel-dup", + body: `module.exports = { id: "channel-dup", register(api) { api.registerChannel({ plugin: { id: "demo", @@ -1670,24 +1661,71 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s } }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["channel-dup"], + assert: (registry: ReturnType) => { + expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); + expect( + registry.diagnostics.some( + (entry) => + entry.level === "error" && + entry.pluginId === "channel-dup" && + entry.message === "channel already registered: demo (channel-dup)", + ), + ).toBe(true); + }, }, - }); + { + label: "rejects plugin context engine ids reserved by core", + pluginId: "context-engine-core-collision", + body: `module.exports = { id: "context-engine-core-collision", register(api) { + api.registerContextEngine("legacy", () => ({})); +} };`, + assert: (registry: ReturnType) => { + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-core-collision" && + diag.message === "context engine id reserved by core: legacy", + ), + ).toBe(true); + }, + }, + { + label: "requires plugin CLI registrars to declare explicit command roots", + pluginId: "cli-missing-metadata", + body: `module.exports = { id: "cli-missing-metadata", register(api) { + api.registerCli(() => {}); +} };`, + assert: (registry: ReturnType) => { + expect(registry.cliRegistrars).toHaveLength(0); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "cli-missing-metadata" && + diag.message === "cli registration missing explicit commands metadata", + ), + ).toBe(true); + }, + }, + ] as const; - expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); - expect( - registry.diagnostics.some( - (entry) => - entry.level === "error" && - entry.pluginId === "channel-dup" && - entry.message === "channel already registered: demo (channel-dup)", - ), - ).toBe(true); + for (const scenario of scenarios) { + const plugin = writePlugin({ + id: scenario.pluginId, + filename: `${scenario.pluginId}.cjs`, + body: scenario.body, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: [scenario.pluginId], + }, + }); + + scenario.assert(registry); + } }); it("registers plugin http routes", () => { @@ -1819,87 +1857,6 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s } }); - it("rejects plugin context engine ids reserved by core", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "context-engine-core-collision", - filename: "context-engine-core-collision.cjs", - body: `module.exports = { id: "context-engine-core-collision", register(api) { - api.registerContextEngine("legacy", () => ({})); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["context-engine-core-collision"], - }, - }); - - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "context-engine-core-collision" && - diag.message === "context engine id reserved by core: legacy", - ), - ).toBe(true); - }); - - it("requires plugin CLI registrars to declare explicit command roots", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "cli-missing-metadata", - filename: "cli-missing-metadata.cjs", - body: `module.exports = { id: "cli-missing-metadata", register(api) { - api.registerCli(() => {}); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["cli-missing-metadata"], - }, - }); - - expect(registry.cliRegistrars).toHaveLength(0); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "cli-missing-metadata" && - diag.message === "cli registration missing explicit commands metadata", - ), - ).toBe(true); - }); - - it("registers http routes", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-demo", - filename: "http-route-demo.cjs", - body: `module.exports = { id: "http-route-demo", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-demo"], - }, - }); - - const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo"); - expect(route).toBeDefined(); - expect(route?.path).toBe("/demo"); - expect(route?.auth).toBe("gateway"); - expect(route?.match).toBe("exact"); - const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo"); - expect(httpPlugin?.httpRoutes).toBe(1); - }); - it("rewrites removed registerHttpHandler failures into migration diagnostics", () => { useNoBundledPlugins(); const plugin = writePlugin({ From c21654e1b9cd143ba7a4d6e3fdda0eff77d37f7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:10:26 +0000 Subject: [PATCH 052/393] test: merge loader precedence cases --- src/plugins/loader.test.ts | 245 ++++++++++++++++++++----------------- 1 file changed, 131 insertions(+), 114 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 7760b56def1..c7d1b99beaa 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2509,128 +2509,145 @@ module.exports = { expect(entry?.status).toBe("disabled"); }); - it("prefers higher-precedence plugins with the same id", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "shadow", - body: `module.exports = { id: "shadow", register() {} };`, - dir: bundledDir, - filename: "shadow.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + it("resolves duplicate plugin ids by source precedence", () => { + const scenarios = [ + { + label: "config load overrides bundled", + pluginId: "shadow", + bundledFilename: "shadow.cjs", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "shadow", + body: `module.exports = { id: "shadow", register() {} };`, + dir: bundledDir, + filename: "shadow.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - const override = writePlugin({ - id: "shadow", - body: `module.exports = { id: "shadow", register() {} };`, - }); + const override = writePlugin({ + id: "shadow", + body: `module.exports = { id: "shadow", register() {} };`, + }); - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [override.file] }, - entries: { - shadow: { enabled: true }, - }, - }, - }, - }); - - const entries = registry.plugins.filter((entry) => entry.id === "shadow"); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("config"); - expect(overridden?.origin).toBe("bundled"); - }); - - it("prefers bundled plugin over auto-discovered global duplicate ids", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "feishu", - body: `module.exports = { id: "feishu", register() {} };`, - dir: bundledDir, - filename: "index.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const stateDir = makeTempDir(); - withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { - const globalDir = path.join(stateDir, "extensions", "feishu"); - mkdirSafe(globalDir); - writePlugin({ - id: "feishu", - body: `module.exports = { id: "feishu", register() {} };`, - dir: globalDir, - filename: "index.cjs", - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - allow: ["feishu"], - entries: { - feishu: { enabled: true }, - }, - }, - }, - }); - - const entries = registry.plugins.filter((entry) => entry.id === "feishu"); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("bundled"); - expect(overridden?.origin).toBe("global"); - expect(overridden?.error).toContain("overridden by bundled plugin"); - }); - }); - - it("prefers an explicitly installed global plugin over a bundled duplicate", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "zalouser", - body: `module.exports = { id: "zalouser", register() {} };`, - dir: bundledDir, - filename: "index.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const stateDir = makeTempDir(); - withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { - const globalDir = path.join(stateDir, "extensions", "zalouser"); - mkdirSafe(globalDir); - writePlugin({ - id: "zalouser", - body: `module.exports = { id: "zalouser", register() {} };`, - dir: globalDir, - filename: "index.cjs", - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - allow: ["zalouser"], - installs: { - zalouser: { - source: "npm", - installPath: globalDir, + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [override.file] }, + entries: { + shadow: { enabled: true }, + }, }, }, - entries: { - zalouser: { enabled: true }, - }, - }, + }); }, - }); + expectedLoadedOrigin: "config", + expectedDisabledOrigin: "bundled", + }, + { + label: "bundled beats auto-discovered global duplicate", + pluginId: "feishu", + bundledFilename: "index.cjs", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "feishu", + body: `module.exports = { id: "feishu", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - const entries = registry.plugins.filter((entry) => entry.id === "zalouser"); + const stateDir = makeTempDir(); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { + const globalDir = path.join(stateDir, "extensions", "feishu"); + mkdirSafe(globalDir); + writePlugin({ + id: "feishu", + body: `module.exports = { id: "feishu", register() {} };`, + dir: globalDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + allow: ["feishu"], + entries: { + feishu: { enabled: true }, + }, + }, + }, + }); + }); + }, + expectedLoadedOrigin: "bundled", + expectedDisabledOrigin: "global", + expectedDisabledError: "overridden by bundled plugin", + }, + { + label: "installed global beats bundled duplicate", + pluginId: "zalouser", + bundledFilename: "index.cjs", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "zalouser", + body: `module.exports = { id: "zalouser", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const stateDir = makeTempDir(); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { + const globalDir = path.join(stateDir, "extensions", "zalouser"); + mkdirSafe(globalDir); + writePlugin({ + id: "zalouser", + body: `module.exports = { id: "zalouser", register() {} };`, + dir: globalDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + allow: ["zalouser"], + installs: { + zalouser: { + source: "npm", + installPath: globalDir, + }, + }, + entries: { + zalouser: { enabled: true }, + }, + }, + }, + }); + }); + }, + expectedLoadedOrigin: "global", + expectedDisabledOrigin: "bundled", + expectedDisabledError: "overridden by global plugin", + }, + ] as const; + + for (const scenario of scenarios) { + const registry = scenario.loadRegistry(); + const entries = registry.plugins.filter((entry) => entry.id === scenario.pluginId); const loaded = entries.find((entry) => entry.status === "loaded"); const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("global"); - expect(overridden?.origin).toBe("bundled"); - expect(overridden?.error).toContain("overridden by global plugin"); - }); + expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin); + expect(overridden?.origin, scenario.label).toBe(scenario.expectedDisabledOrigin); + if (scenario.expectedDisabledError) { + expect(overridden?.error, scenario.label).toContain(scenario.expectedDisabledError); + } + } }); it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => { From 17143ed878a34b8de10d7a2b79bdf3146a2ba592 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:11:18 +0000 Subject: [PATCH 053/393] test: merge audit exposure heuristic cases --- src/security/audit.test.ts | 230 ++++++++++++++++++++----------------- 1 file changed, 126 insertions(+), 104 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 9da4ea283db..f9a8cfdd286 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3410,124 +3410,146 @@ description: test skill } }); - it("flags open groupPolicy when tools.elevated is enabled", async () => { - const cfg: OpenClawConfig = { - tools: { elevated: { enabled: true, allowFrom: { whatsapp: ["+1"] } } }, - channels: { whatsapp: { groupPolicy: "open" } }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "security.exposure.open_groups_with_elevated", - severity: "critical", - }), - ]), - ); - }); - - it("flags open groupPolicy when runtime/filesystem tools are exposed without guards", async () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { groupPolicy: "open" } }, - tools: { elevated: { enabled: false } }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "security.exposure.open_groups_with_runtime_or_fs", - severity: "critical", - }), - ]), - ); - }); - - it("does not flag runtime/filesystem exposure for open groups when sandbox mode is all", async () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { groupPolicy: "open" } }, - tools: { - elevated: { enabled: false }, - profile: "coding", - }, - agents: { - defaults: { - sandbox: { mode: "all" }, + it("evaluates open-group exposure findings", async () => { + const cases = [ + { + name: "flags open groupPolicy when tools.elevated is enabled", + cfg: { + tools: { elevated: { enabled: true, allowFrom: { whatsapp: ["+1"] } } }, + channels: { whatsapp: { groupPolicy: "open" } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "security.exposure.open_groups_with_elevated", + severity: "critical", + }), + ]), + ); }, }, - }; - - const res = await audit(cfg); - - expect( - res.findings.some((f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs"), - ).toBe(false); - }); - - it("does not flag runtime/filesystem exposure for open groups when runtime is denied and fs is workspace-only", async () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { groupPolicy: "open" } }, - tools: { - elevated: { enabled: false }, - profile: "coding", - deny: ["group:runtime"], - fs: { workspaceOnly: true }, + { + name: "flags open groupPolicy when runtime/filesystem tools are exposed without guards", + cfg: { + channels: { whatsapp: { groupPolicy: "open" } }, + tools: { elevated: { enabled: false } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "security.exposure.open_groups_with_runtime_or_fs", + severity: "critical", + }), + ]), + ); + }, }, - }; + { + name: "does not flag runtime/filesystem exposure for open groups when sandbox mode is all", + cfg: { + channels: { whatsapp: { groupPolicy: "open" } }, + tools: { + elevated: { enabled: false }, + profile: "coding", + }, + agents: { + defaults: { + sandbox: { mode: "all" }, + }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some( + (f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs", + ), + ).toBe(false); + }, + }, + { + name: "does not flag runtime/filesystem exposure for open groups when runtime is denied and fs is workspace-only", + cfg: { + channels: { whatsapp: { groupPolicy: "open" } }, + tools: { + elevated: { enabled: false }, + profile: "coding", + deny: ["group:runtime"], + fs: { workspaceOnly: true }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some( + (f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs", + ), + ).toBe(false); + }, + }, + ] as const; - const res = await audit(cfg); - - expect( - res.findings.some((f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs"), - ).toBe(false); + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + testCase.assert(res); + }), + ); }); - it("warns when config heuristics suggest a likely multi-user setup", async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - groupPolicy: "allowlist", - guilds: { - "1234567890": { - channels: { - "7777777777": { allow: true }, + it("evaluates multi-user trust-model heuristics", async () => { + const cases = [ + { + name: "warns when config heuristics suggest a likely multi-user setup", + cfg: { + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "1234567890": { + channels: { + "7777777777": { allow: true }, + }, + }, }, }, }, + tools: { elevated: { enabled: false } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + const finding = res.findings.find( + (f) => f.checkId === "security.trust_model.multi_user_heuristic", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain( + 'channels.discord.groupPolicy="allowlist" with configured group targets', + ); + expect(finding?.detail).toContain("personal-assistant"); + expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); }, }, - tools: { elevated: { enabled: false } }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "security.trust_model.multi_user_heuristic", - ); - - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain( - 'channels.discord.groupPolicy="allowlist" with configured group targets', - ); - expect(finding?.detail).toContain("personal-assistant"); - expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); - }); - - it("does not warn for multi-user heuristic when no shared-user signals are configured", async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - groupPolicy: "allowlist", + { + name: "does not warn for multi-user heuristic when no shared-user signals are configured", + cfg: { + channels: { + discord: { + groupPolicy: "allowlist", + }, + }, + tools: { elevated: { enabled: false } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "security.trust_model.multi_user_heuristic"); }, }, - tools: { elevated: { enabled: false } }, - }; + ] as const; - const res = await audit(cfg); - - expectNoFinding(res, "security.trust_model.multi_user_heuristic"); + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + testCase.assert(res); + }), + ); }); describe("maybeProbeGateway auth selection", () => { From 909ec6b416924c4dad0bc898d48e0bcc5b74ad79 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:12:43 +0000 Subject: [PATCH 054/393] test: merge loader workspace warning cases --- src/plugins/loader.test.ts | 285 +++++++++++++++++++++---------------- 1 file changed, 159 insertions(+), 126 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index c7d1b99beaa..acd668aa373 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2650,105 +2650,180 @@ module.exports = { } }); - it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "warn-open-allow", - body: `module.exports = { id: "warn-open-allow", register() {} };`, - }); - const warnings: string[] = []; - loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - config: { - plugins: { - load: { paths: [plugin.file] }, - }, - }, - }); - expect( - warnings.some((msg) => msg.includes("plugins.allow is empty") && msg.includes(plugin.id)), - ).toBe(true); - }); - - it("dedupes the open allowlist warning for repeated loads of the same plugin set", () => { + it("warns about open allowlists for discoverable plugins once per plugin set", () => { useNoBundledPlugins(); clearPluginLoaderCache(); - const plugin = writePlugin({ - id: "warn-open-allow-once", - body: `module.exports = { id: "warn-open-allow-once", register() {} };`, - }); - const warnings: string[] = []; - const options = { - cache: false, - logger: createWarningLogger(warnings), - config: { - plugins: { - load: { paths: [plugin.file] }, - }, + const scenarios = [ + { + label: "single load warns", + pluginId: "warn-open-allow", + loads: 1, + expectedWarnings: 1, }, - }; + { + label: "repeated identical loads dedupe warning", + pluginId: "warn-open-allow-once", + loads: 2, + expectedWarnings: 1, + }, + ] as const; - loadOpenClawPlugins(options); - loadOpenClawPlugins(options); + for (const scenario of scenarios) { + const plugin = writePlugin({ + id: scenario.pluginId, + body: `module.exports = { id: "${scenario.pluginId}", register() {} };`, + }); + const warnings: string[] = []; + const options = { + cache: false, + logger: createWarningLogger(warnings), + config: { + plugins: { + load: { paths: [plugin.file] }, + }, + }, + }; - expect(warnings.filter((msg) => msg.includes("plugins.allow is empty"))).toHaveLength(1); + for (let index = 0; index < scenario.loads; index += 1) { + loadOpenClawPlugins(options); + } + + const openAllowWarnings = warnings.filter((msg) => msg.includes("plugins.allow is empty")); + expect(openAllowWarnings, scenario.label).toHaveLength(scenario.expectedWarnings); + expect( + openAllowWarnings.some((msg) => msg.includes(scenario.pluginId)), + scenario.label, + ).toBe(true); + } }); - it("does not auto-load workspace-discovered plugins unless explicitly trusted", () => { + it("handles workspace-discovered plugins according to trust and precedence", () => { useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-helper"); - mkdirSafe(workspaceExtDir); - writePlugin({ - id: "workspace-helper", - body: `module.exports = { id: "workspace-helper", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", - }); + const scenarios = [ + { + label: "untrusted workspace plugins stay disabled", + pluginId: "workspace-helper", + loadRegistry: () => { + const workspaceDir = makeTempDir(); + const workspaceExtDir = path.join( + workspaceDir, + ".openclaw", + "extensions", + "workspace-helper", + ); + mkdirSafe(workspaceExtDir); + writePlugin({ + id: "workspace-helper", + body: `module.exports = { id: "workspace-helper", register() {} };`, + dir: workspaceExtDir, + filename: "index.cjs", + }); - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir, - config: { - plugins: { - enabled: true, + return loadOpenClawPlugins({ + cache: false, + workspaceDir, + config: { + plugins: { + enabled: true, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); + expect(workspacePlugin?.origin).toBe("workspace"); + expect(workspacePlugin?.status).toBe("disabled"); + expect(workspacePlugin?.error).toContain("workspace plugin (disabled by default)"); }, }, - }); + { + label: "trusted workspace plugins load", + pluginId: "workspace-helper", + loadRegistry: () => { + const workspaceDir = makeTempDir(); + const workspaceExtDir = path.join( + workspaceDir, + ".openclaw", + "extensions", + "workspace-helper", + ); + mkdirSafe(workspaceExtDir); + writePlugin({ + id: "workspace-helper", + body: `module.exports = { id: "workspace-helper", register() {} };`, + dir: workspaceExtDir, + filename: "index.cjs", + }); - const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); - expect(workspacePlugin?.origin).toBe("workspace"); - expect(workspacePlugin?.status).toBe("disabled"); - expect(workspacePlugin?.error).toContain("workspace plugin (disabled by default)"); - }); - - it("loads workspace-discovered plugins when plugins.allow explicitly trusts them", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-helper"); - mkdirSafe(workspaceExtDir); - writePlugin({ - id: "workspace-helper", - body: `module.exports = { id: "workspace-helper", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", - }); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir, - config: { - plugins: { - enabled: true, - allow: ["workspace-helper"], + return loadOpenClawPlugins({ + cache: false, + workspaceDir, + config: { + plugins: { + enabled: true, + allow: ["workspace-helper"], + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); + expect(workspacePlugin?.origin).toBe("workspace"); + expect(workspacePlugin?.status).toBe("loaded"); }, }, - }); + { + label: "bundled plugins stay ahead of trusted workspace duplicates", + pluginId: "shadowed", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "shadowed", + body: `module.exports = { id: "shadowed", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); - expect(workspacePlugin?.origin).toBe("workspace"); - expect(workspacePlugin?.status).toBe("loaded"); + const workspaceDir = makeTempDir(); + const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "shadowed"); + mkdirSafe(workspaceExtDir); + writePlugin({ + id: "shadowed", + body: `module.exports = { id: "shadowed", register() {} };`, + dir: workspaceExtDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + workspaceDir, + config: { + plugins: { + enabled: true, + allow: ["shadowed"], + entries: { + shadowed: { enabled: true }, + }, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const entries = registry.plugins.filter((entry) => entry.id === "shadowed"); + const loaded = entries.find((entry) => entry.status === "loaded"); + const overridden = entries.find((entry) => entry.status === "disabled"); + expect(loaded?.origin).toBe("bundled"); + expect(overridden?.origin).toBe("workspace"); + expect(overridden?.error).toContain("overridden by bundled plugin"); + }, + }, + ] as const; + + for (const scenario of scenarios) { + const registry = scenario.loadRegistry(); + scenario.assert(registry); + } }); it("keeps scoped and unscoped plugin ids distinct", () => { @@ -2781,48 +2856,6 @@ module.exports = { ).toBe(false); }); - it("keeps bundled plugins ahead of trusted workspace duplicates with the same id", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "shadowed", - body: `module.exports = { id: "shadowed", register() {} };`, - dir: bundledDir, - filename: "index.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "shadowed"); - mkdirSafe(workspaceExtDir); - writePlugin({ - id: "shadowed", - body: `module.exports = { id: "shadowed", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", - }); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir, - config: { - plugins: { - enabled: true, - allow: ["shadowed"], - entries: { - shadowed: { enabled: true }, - }, - }, - }, - }); - - const entries = registry.plugins.filter((entry) => entry.id === "shadowed"); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("bundled"); - expect(overridden?.origin).toBe("workspace"); - expect(overridden?.error).toContain("overridden by bundled plugin"); - }); - it("warns when loaded non-bundled plugin has no install/load-path provenance", () => { useNoBundledPlugins(); const stateDir = makeTempDir(); From 23d700b090a72b54a3ddca79965bb229fa816b86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:13:49 +0000 Subject: [PATCH 055/393] test: merge audit hooks ingress cases --- src/security/audit.test.ts | 183 +++++++++++++++++-------------------- 1 file changed, 82 insertions(+), 101 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index f9a8cfdd286..16b4ba6bc61 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2805,119 +2805,100 @@ description: test skill ); }); - it.each([ - { - name: "warns when hooks token looks short", - cfg: { - hooks: { enabled: true, token: "short" }, - } satisfies OpenClawConfig, - expectedFinding: "hooks.token_too_short", - expectedSeverity: "warn" as const, - }, - { - name: "flags hooks token reuse of the gateway env token as critical", - cfg: { - hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, - } satisfies OpenClawConfig, - env: { - OPENCLAW_GATEWAY_TOKEN: "shared-gateway-token-1234567890", - }, - expectedFinding: "hooks.token_reuse_gateway_token", - expectedSeverity: "critical" as const, - }, - { - name: "warns when hooks.defaultSessionKey is unset", - cfg: { - hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, - } satisfies OpenClawConfig, - expectedFinding: "hooks.default_session_key_unset", - expectedSeverity: "warn" as const, - }, - { - name: "treats wildcard hooks.allowedAgentIds as unrestricted routing", - cfg: { - hooks: { - enabled: true, - token: "shared-gateway-token-1234567890", - defaultSessionKey: "hook:ingress", - allowedAgentIds: ["*"], - }, - } satisfies OpenClawConfig, - expectedFinding: "hooks.allowed_agent_ids_unrestricted", - expectedSeverity: "warn" as const, - }, - ])("$name", async (testCase) => { - const res = await audit(testCase.cfg, testCase.env ? { env: testCase.env } : undefined); - expectFinding(res, testCase.expectedFinding, testCase.expectedSeverity); - }); - - it("scores unrestricted hooks.allowedAgentIds by gateway exposure", async () => { - const baseHooks = { + it("evaluates hooks ingress auth and routing findings", async () => { + const unrestrictedBaseHooks = { enabled: true, token: "shared-gateway-token-1234567890", defaultSessionKey: "hook:ingress", } satisfies NonNullable; - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - }> = [ - { - name: "local exposure", - cfg: { hooks: baseHooks }, - expectedSeverity: "warn", - }, - { - name: "remote exposure", - cfg: { gateway: { bind: "lan" }, hooks: baseHooks }, - expectedSeverity: "critical", - }, - ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "hooks.allowed_agent_ids_unrestricted", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - }), - ); - }); - - it("scores hooks request sessionKey override by gateway exposure", async () => { - const baseHooks = { - enabled: true, - token: "shared-gateway-token-1234567890", - defaultSessionKey: "hook:ingress", + const requestSessionKeyHooks = { + ...unrestrictedBaseHooks, allowRequestSessionKey: true, } satisfies NonNullable; - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - expectsPrefixesMissing?: boolean; - }> = [ + const cases = [ { - name: "local exposure", - cfg: { hooks: baseHooks }, - expectedSeverity: "warn", - expectsPrefixesMissing: true, + name: "warns when hooks token looks short", + cfg: { + hooks: { enabled: true, token: "short" }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.token_too_short", + expectedSeverity: "warn" as const, }, { - name: "remote exposure", - cfg: { gateway: { bind: "lan" }, hooks: baseHooks }, - expectedSeverity: "critical", + name: "flags hooks token reuse of the gateway env token as critical", + cfg: { + hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, + } satisfies OpenClawConfig, + env: { + OPENCLAW_GATEWAY_TOKEN: "shared-gateway-token-1234567890", + }, + expectedFinding: "hooks.token_reuse_gateway_token", + expectedSeverity: "critical" as const, }, - ]; + { + name: "warns when hooks.defaultSessionKey is unset", + cfg: { + hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.default_session_key_unset", + expectedSeverity: "warn" as const, + }, + { + name: "treats wildcard hooks.allowedAgentIds as unrestricted routing", + cfg: { + hooks: { + enabled: true, + token: "shared-gateway-token-1234567890", + defaultSessionKey: "hook:ingress", + allowedAgentIds: ["*"], + }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.allowed_agent_ids_unrestricted", + expectedSeverity: "warn" as const, + }, + { + name: "scores unrestricted hooks.allowedAgentIds by local exposure", + cfg: { hooks: unrestrictedBaseHooks } satisfies OpenClawConfig, + expectedFinding: "hooks.allowed_agent_ids_unrestricted", + expectedSeverity: "warn" as const, + }, + { + name: "scores unrestricted hooks.allowedAgentIds by remote exposure", + cfg: { gateway: { bind: "lan" }, hooks: unrestrictedBaseHooks } satisfies OpenClawConfig, + expectedFinding: "hooks.allowed_agent_ids_unrestricted", + expectedSeverity: "critical" as const, + }, + { + name: "scores hooks request sessionKey override by local exposure", + cfg: { hooks: requestSessionKeyHooks } satisfies OpenClawConfig, + expectedFinding: "hooks.request_session_key_enabled", + expectedSeverity: "warn" as const, + expectedExtraFinding: { + checkId: "hooks.request_session_key_prefixes_missing", + severity: "warn" as const, + }, + }, + { + name: "scores hooks request sessionKey override by remote exposure", + cfg: { + gateway: { bind: "lan" }, + hooks: requestSessionKeyHooks, + } satisfies OpenClawConfig, + expectedFinding: "hooks.request_session_key_enabled", + expectedSeverity: "critical" as const, + }, + ] as const; + await Promise.all( cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "hooks.request_session_key_enabled", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - if (testCase.expectsPrefixesMissing) { - expect(hasFinding(res, "hooks.request_session_key_prefixes_missing", "warn")).toBe(true); + const res = await audit(testCase.cfg, testCase.env ? { env: testCase.env } : undefined); + expectFinding(res, testCase.expectedFinding, testCase.expectedSeverity); + if (testCase.expectedExtraFinding) { + expectFinding( + res, + testCase.expectedExtraFinding.checkId, + testCase.expectedExtraFinding.severity, + ); } }), ); From 97c481120f0a32c92a29ea8c002e3bcd09c652ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:14:43 +0000 Subject: [PATCH 056/393] test: merge audit extension allowlist severity cases --- src/security/audit.test.ts | 89 +++++++++++++------------------------- 1 file changed, 30 insertions(+), 59 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 16b4ba6bc61..614002096c7 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3054,57 +3054,6 @@ description: test skill ); }); - it("flags extensions without plugins.allow", async () => { - const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; - const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; - const prevSlackBotToken = process.env.SLACK_BOT_TOKEN; - const prevSlackAppToken = process.env.SLACK_APP_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - delete process.env.TELEGRAM_BOT_TOKEN; - delete process.env.SLACK_BOT_TOKEN; - delete process.env.SLACK_APP_TOKEN; - const stateDir = sharedExtensionsStateDir; - - try { - const cfg: OpenClawConfig = {}; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "plugins.extensions_no_allowlist", severity: "warn" }), - ]), - ); - } finally { - if (prevDiscordToken == null) { - delete process.env.DISCORD_BOT_TOKEN; - } else { - process.env.DISCORD_BOT_TOKEN = prevDiscordToken; - } - if (prevTelegramToken == null) { - delete process.env.TELEGRAM_BOT_TOKEN; - } else { - process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; - } - if (prevSlackBotToken == null) { - delete process.env.SLACK_BOT_TOKEN; - } else { - process.env.SLACK_BOT_TOKEN = prevSlackBotToken; - } - if (prevSlackAppToken == null) { - delete process.env.SLACK_APP_TOKEN; - } else { - process.env.SLACK_APP_TOKEN = prevSlackAppToken; - } - } - }); - it.each([ { name: "warns on unpinned npm install specs and missing integrity metadata", @@ -3227,6 +3176,20 @@ description: test skill it("evaluates extension tool reachability findings", async () => { const cases = [ + { + name: "flags extensions without plugins.allow", + cfg: {} satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.extensions_no_allowlist", + severity: "warn", + }), + ]), + ); + }, + }, { name: "flags enabled extensions when tool policy can expose plugin tools", cfg: { @@ -3300,14 +3263,22 @@ description: test skill }, ] as const; - await withEnvAsync({ DISCORD_BOT_TOKEN: undefined }, async () => { - await Promise.all( - cases.map(async (testCase) => { - const res = await runSharedExtensionsAudit(testCase.cfg); - testCase.assert(res); - }), - ); - }); + await withEnvAsync( + { + DISCORD_BOT_TOKEN: undefined, + TELEGRAM_BOT_TOKEN: undefined, + SLACK_BOT_TOKEN: undefined, + SLACK_APP_TOKEN: undefined, + }, + async () => { + await Promise.all( + cases.map(async (testCase) => { + const res = await runSharedExtensionsAudit(testCase.cfg); + testCase.assert(res); + }), + ); + }, + ); }); it("does not scan plugin code safety findings when deep audit is disabled", async () => { From 6372062be4e963387dadc87b475c9729454c1def Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:15:52 +0000 Subject: [PATCH 057/393] test: merge loader provenance warning cases --- src/plugins/loader.test.ts | 170 ++++++++++++++++++++++--------------- 1 file changed, 101 insertions(+), 69 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index acd668aa373..31e353e7ec9 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2856,83 +2856,115 @@ module.exports = { ).toBe(false); }); - it("warns when loaded non-bundled plugin has no install/load-path provenance", () => { + it("evaluates load-path provenance warnings", () => { useNoBundledPlugins(); - const stateDir = makeTempDir(); - withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { - const globalDir = path.join(stateDir, "extensions", "rogue"); - mkdirSafe(globalDir); - writePlugin({ - id: "rogue", - body: `module.exports = { id: "rogue", register() {} };`, - dir: globalDir, - filename: "index.cjs", - }); + const scenarios = [ + { + label: "warns when loaded non-bundled plugin has no install/load-path provenance", + loadRegistry: () => { + const stateDir = makeTempDir(); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { + const globalDir = path.join(stateDir, "extensions", "rogue"); + mkdirSafe(globalDir); + writePlugin({ + id: "rogue", + body: `module.exports = { id: "rogue", register() {} };`, + dir: globalDir, + filename: "index.cjs", + }); - const warnings: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - config: { - plugins: { - allow: ["rogue"], - }, + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + config: { + plugins: { + allow: ["rogue"], + }, + }, + }); + + return { registry, warnings, pluginId: "rogue", expectWarning: true }; + }); }, - }); + }, + { + label: "does not warn about missing provenance for env-resolved load paths", + loadRegistry: () => { + const { plugin, env } = createEnvResolvedPluginFixture("tracked-load-path"); + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + env, + config: { + plugins: { + load: { paths: ["~/plugins/tracked-load-path"] }, + allow: [plugin.id], + }, + }, + }); - const rogue = registry.plugins.find((entry) => entry.id === "rogue"); - expect(rogue?.status).toBe("loaded"); + return { + registry, + warnings, + pluginId: plugin.id, + expectWarning: false, + expectedSource: plugin.file, + }; + }, + }, + { + label: "does not warn about missing provenance for env-resolved install paths", + loadRegistry: () => { + const { plugin, env } = createEnvResolvedPluginFixture("tracked-install-path"); + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + env, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + installs: { + [plugin.id]: { + source: "path", + installPath: `~/plugins/${plugin.id}`, + sourcePath: `~/plugins/${plugin.id}`, + }, + }, + }, + }, + }); + + return { + registry, + warnings, + pluginId: plugin.id, + expectWarning: false, + expectedSource: plugin.file, + }; + }, + }, + ] as const; + + for (const scenario of scenarios) { + const { registry, warnings, pluginId, expectWarning, expectedSource } = + scenario.loadRegistry(); + const plugin = registry.plugins.find((entry) => entry.id === pluginId); + expect(plugin?.status, scenario.label).toBe("loaded"); + if (expectedSource) { + expect(plugin?.source, scenario.label).toBe(expectedSource); + } expect( warnings.some( (msg) => - msg.includes("rogue") && msg.includes("loaded without install/load-path provenance"), + msg.includes(pluginId) && msg.includes("loaded without install/load-path provenance"), ), - ).toBe(true); - }); - }); - - it.each([ - { - name: "does not warn about missing provenance for env-resolved load paths", - pluginId: "tracked-load-path", - buildConfig: (plugin: TempPlugin) => ({ - plugins: { - load: { paths: ["~/plugins/tracked-load-path"] }, - allow: [plugin.id], - }, - }), - }, - { - name: "does not warn about missing provenance for env-resolved install paths", - pluginId: "tracked-install-path", - buildConfig: (plugin: TempPlugin) => ({ - plugins: { - load: { paths: [plugin.file] }, - allow: [plugin.id], - installs: { - [plugin.id]: { - source: "path", - installPath: `~/plugins/${plugin.id}`, - sourcePath: `~/plugins/${plugin.id}`, - }, - }, - }, - }), - }, - ])("$name", ({ pluginId, buildConfig }) => { - const { plugin, env } = createEnvResolvedPluginFixture(pluginId); - const warnings: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - env, - config: buildConfig(plugin), - }); - - expect(registry.plugins.find((entry) => entry.id === plugin.id)?.source).toBe(plugin.file); - expect( - warnings.some((msg) => msg.includes("loaded without install/load-path provenance")), - ).toBe(false); + scenario.label, + ).toBe(expectWarning); + } }); it.each([ From d49c1688f759cfd387712535e14828e43138f426 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:16:36 +0000 Subject: [PATCH 058/393] test: merge loader bundled telegram cases --- src/plugins/loader.test.ts | 100 ++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 31e353e7ec9..a6045318e72 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -850,69 +850,69 @@ describe("loadOpenClawPlugins", () => { expect(bundled?.status).toBe("disabled"); }); - it("loads bundled telegram plugin when enabled", () => { + it("handles bundled telegram plugin enablement and override rules", () => { setupBundledTelegramPlugin(); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: cachedBundledTelegramDir, - config: { - plugins: { - allow: ["telegram"], - entries: { - telegram: { enabled: true }, + const cases = [ + { + name: "loads bundled telegram plugin when enabled", + config: { + plugins: { + allow: ["telegram"], + entries: { + telegram: { enabled: true }, + }, }, + } satisfies Parameters[0]["config"], + assert: (registry: ReturnType) => { + expectTelegramLoaded(registry); }, }, - }); - - expectTelegramLoaded(registry); - }); - - it("loads bundled channel plugins when channels..enabled=true", () => { - setupBundledTelegramPlugin(); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: cachedBundledTelegramDir, - config: { - channels: { - telegram: { + { + name: "loads bundled channel plugins when channels..enabled=true", + config: { + channels: { + telegram: { + enabled: true, + }, + }, + plugins: { enabled: true, }, - }, - plugins: { - enabled: true, + } satisfies Parameters[0]["config"], + assert: (registry: ReturnType) => { + expectTelegramLoaded(registry); }, }, - }); - - expectTelegramLoaded(registry); - }); - - it("still respects explicit disable via plugins.entries for bundled channels", () => { - setupBundledTelegramPlugin(); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: cachedBundledTelegramDir, - config: { - channels: { - telegram: { - enabled: true, + { + name: "still respects explicit disable via plugins.entries for bundled channels", + config: { + channels: { + telegram: { + enabled: true, + }, }, - }, - plugins: { - entries: { - telegram: { enabled: false }, + plugins: { + entries: { + telegram: { enabled: false }, + }, }, + } satisfies Parameters[0]["config"], + assert: (registry: ReturnType) => { + const telegram = registry.plugins.find((entry) => entry.id === "telegram"); + expect(telegram?.status).toBe("disabled"); + expect(telegram?.error).toBe("disabled in config"); }, }, - }); + ] as const; - const telegram = registry.plugins.find((entry) => entry.id === "telegram"); - expect(telegram?.status).toBe("disabled"); - expect(telegram?.error).toBe("disabled in config"); + for (const testCase of cases) { + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: cachedBundledTelegramDir, + config: testCase.config, + }); + testCase.assert(registry); + } }); it("preserves package.json metadata for bundled memory plugins", () => { From 477cea7709262b39b5d2114bbf05cb7bdddb30d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:17:35 +0000 Subject: [PATCH 059/393] test: merge loader memory slot cases --- src/plugins/loader.test.ts | 223 ++++++++++++++++++++----------------- 1 file changed, 121 insertions(+), 102 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a6045318e72..cd3a420baba 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2394,119 +2394,138 @@ module.exports = { ).toBe(true); }); - it("enforces memory slot selection", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; - const memoryA = writePlugin({ - id: "memory-a", - body: `module.exports = { id: "memory-a", kind: "memory", register() {} };`, - }); - const memoryB = writePlugin({ - id: "memory-b", - body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, - }); + it("enforces memory slot loading rules", () => { + const scenarios = [ + { + label: "enforces memory slot selection", + loadRegistry: () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const memoryA = writePlugin({ + id: "memory-a", + body: `module.exports = { id: "memory-a", kind: "memory", register() {} };`, + }); + const memoryB = writePlugin({ + id: "memory-b", + body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, + }); - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [memoryA.file, memoryB.file] }, - slots: { memory: "memory-b" }, + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [memoryA.file, memoryB.file] }, + slots: { memory: "memory-b" }, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const a = registry.plugins.find((entry) => entry.id === "memory-a"); + const b = registry.plugins.find((entry) => entry.id === "memory-b"); + expect(b?.status).toBe("loaded"); + expect(a?.status).toBe("disabled"); }, }, - }); + { + label: "skips importing bundled memory plugins that are disabled by memory slot", + loadRegistry: () => { + const bundledDir = makeTempDir(); + const memoryADir = path.join(bundledDir, "memory-a"); + const memoryBDir = path.join(bundledDir, "memory-b"); + mkdirSafe(memoryADir); + mkdirSafe(memoryBDir); + writePlugin({ + id: "memory-a", + dir: memoryADir, + filename: "index.cjs", + body: `throw new Error("memory-a should not be imported when slot selects memory-b");`, + }); + writePlugin({ + id: "memory-b", + dir: memoryBDir, + filename: "index.cjs", + body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, + }); + fs.writeFileSync( + path.join(memoryADir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-a", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(memoryBDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-b", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - const a = registry.plugins.find((entry) => entry.id === "memory-a"); - const b = registry.plugins.find((entry) => entry.id === "memory-b"); - expect(b?.status).toBe("loaded"); - expect(a?.status).toBe("disabled"); - }); - - it("skips importing bundled memory plugins that are disabled by memory slot", () => { - const bundledDir = makeTempDir(); - const memoryADir = path.join(bundledDir, "memory-a"); - const memoryBDir = path.join(bundledDir, "memory-b"); - mkdirSafe(memoryADir); - mkdirSafe(memoryBDir); - writePlugin({ - id: "memory-a", - dir: memoryADir, - filename: "index.cjs", - body: `throw new Error("memory-a should not be imported when slot selects memory-b");`, - }); - writePlugin({ - id: "memory-b", - dir: memoryBDir, - filename: "index.cjs", - body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, - }); - fs.writeFileSync( - path.join(memoryADir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "memory-a", - kind: "memory", - configSchema: EMPTY_PLUGIN_SCHEMA, + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + allow: ["memory-a", "memory-b"], + slots: { memory: "memory-b" }, + entries: { + "memory-a": { enabled: true }, + "memory-b": { enabled: true }, + }, + }, + }, + }); }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(memoryBDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "memory-b", - kind: "memory", - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - allow: ["memory-a", "memory-b"], - slots: { memory: "memory-b" }, - entries: { - "memory-a": { enabled: true }, - "memory-b": { enabled: true }, - }, + assert: (registry: ReturnType) => { + const a = registry.plugins.find((entry) => entry.id === "memory-a"); + const b = registry.plugins.find((entry) => entry.id === "memory-b"); + expect(a?.status).toBe("disabled"); + expect(String(a?.error ?? "")).toContain('memory slot set to "memory-b"'); + expect(b?.status).toBe("loaded"); }, }, - }); + { + label: "disables memory plugins when slot is none", + loadRegistry: () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const memory = writePlugin({ + id: "memory-off", + body: `module.exports = { id: "memory-off", kind: "memory", register() {} };`, + }); - const a = registry.plugins.find((entry) => entry.id === "memory-a"); - const b = registry.plugins.find((entry) => entry.id === "memory-b"); - expect(a?.status).toBe("disabled"); - expect(String(a?.error ?? "")).toContain('memory slot set to "memory-b"'); - expect(b?.status).toBe("loaded"); - }); - - it("disables memory plugins when slot is none", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; - const memory = writePlugin({ - id: "memory-off", - body: `module.exports = { id: "memory-off", kind: "memory", register() {} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [memory.file] }, - slots: { memory: "none" }, + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [memory.file] }, + slots: { memory: "none" }, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const entry = registry.plugins.find((item) => item.id === "memory-off"); + expect(entry?.status).toBe("disabled"); }, }, - }); + ] as const; - const entry = registry.plugins.find((item) => item.id === "memory-off"); - expect(entry?.status).toBe("disabled"); + for (const scenario of scenarios) { + const registry = scenario.loadRegistry(); + scenario.assert(registry); + } }); it("resolves duplicate plugin ids by source precedence", () => { From 5311d48c66dd5aef3d34225676c07e9bf3cf2982 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:19:17 +0000 Subject: [PATCH 060/393] test: merge loader scoped load cases --- src/plugins/loader.test.ts | 230 ++++++++++++++++++++----------------- 1 file changed, 125 insertions(+), 105 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index cd3a420baba..07576d8d872 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -932,130 +932,150 @@ describe("loadOpenClawPlugins", () => { expect(memory?.name).toBe("Memory (Core)"); expect(memory?.version).toBe("1.2.3"); }); - it("loads plugins from config paths", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; - const plugin = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { - id: "allowed", + it("handles config-path and scoped plugin loads", () => { + const scenarios = [ + { + label: "loads plugins from config paths", + run: () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const plugin = writePlugin({ + id: "allowed-config-path", + filename: "allowed-config-path.cjs", + body: `module.exports = { + id: "allowed-config-path", register(api) { - api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); + api.registerGatewayMethod("allowed-config-path.ping", ({ respond }) => respond(true, { ok: true })); }, };`, - }); + }); - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: plugin.dir, - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["allowed"], + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["allowed-config-path"], + }, + }, + }); + + const loaded = registry.plugins.find((entry) => entry.id === "allowed-config-path"); + expect(loaded?.status).toBe("loaded"); + expect(Object.keys(registry.gatewayHandlers)).toContain("allowed-config-path.ping"); }, }, - }); + { + label: "limits imports to the requested plugin ids", + run: () => { + useNoBundledPlugins(); + const allowed = writePlugin({ + id: "allowed-scoped-only", + filename: "allowed-scoped-only.cjs", + body: `module.exports = { id: "allowed-scoped-only", register() {} };`, + }); + const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt"); + const skipped = writePlugin({ + id: "skipped-scoped-only", + filename: "skipped-scoped-only.cjs", + body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8"); +module.exports = { id: "skipped-scoped-only", register() { throw new Error("skipped plugin should not load"); } };`, + }); - const loaded = registry.plugins.find((entry) => entry.id === "allowed"); - expect(loaded?.status).toBe("loaded"); - expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping"); - }); + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [allowed.file, skipped.file] }, + allow: ["allowed-scoped-only", "skipped-scoped-only"], + }, + }, + onlyPluginIds: ["allowed-scoped-only"], + }); - it("limits imports to the requested plugin ids", () => { - useNoBundledPlugins(); - const allowed = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { id: "allowed", register() {} };`, - }); - const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt"); - const skipped = writePlugin({ - id: "skipped", - filename: "skipped.cjs", - body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8"); -module.exports = { id: "skipped", register() { throw new Error("skipped plugin should not load"); } };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [allowed.file, skipped.file] }, - allow: ["allowed", "skipped"], + expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed-scoped-only"]); + expect(fs.existsSync(skippedMarker)).toBe(false); }, }, - onlyPluginIds: ["allowed"], - }); + { + label: "keeps scoped plugin loads in a separate cache entry", + run: () => { + useNoBundledPlugins(); + const allowed = writePlugin({ + id: "allowed-cache-scope", + filename: "allowed-cache-scope.cjs", + body: `module.exports = { id: "allowed-cache-scope", register() {} };`, + }); + const extra = writePlugin({ + id: "extra-cache-scope", + filename: "extra-cache-scope.cjs", + body: `module.exports = { id: "extra-cache-scope", register() {} };`, + }); + const options = { + config: { + plugins: { + load: { paths: [allowed.file, extra.file] }, + allow: ["allowed-cache-scope", "extra-cache-scope"], + }, + }, + }; - expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed"]); - expect(fs.existsSync(skippedMarker)).toBe(false); - }); + const full = loadOpenClawPlugins(options); + const scoped = loadOpenClawPlugins({ + ...options, + onlyPluginIds: ["allowed-cache-scope"], + }); + const scopedAgain = loadOpenClawPlugins({ + ...options, + onlyPluginIds: ["allowed-cache-scope"], + }); - it("keeps scoped plugin loads in a separate cache entry", () => { - useNoBundledPlugins(); - const allowed = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { id: "allowed", register() {} };`, - }); - const extra = writePlugin({ - id: "extra", - filename: "extra.cjs", - body: `module.exports = { id: "extra", register() {} };`, - }); - const options = { - config: { - plugins: { - load: { paths: [allowed.file, extra.file] }, - allow: ["allowed", "extra"], + expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual([ + "allowed-cache-scope", + "extra-cache-scope", + ]); + expect(scoped).not.toBe(full); + expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed-cache-scope"]); + expect(scopedAgain).toBe(scoped); }, }, - }; + { + label: "can load a scoped registry without replacing the active global registry", + run: () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "allowed-nonactivating-scope", + filename: "allowed-nonactivating-scope.cjs", + body: `module.exports = { id: "allowed-nonactivating-scope", register() {} };`, + }); + const previousRegistry = createEmptyPluginRegistry(); + setActivePluginRegistry(previousRegistry, "existing-registry"); + resetGlobalHookRunner(); - const full = loadOpenClawPlugins(options); - const scoped = loadOpenClawPlugins({ - ...options, - onlyPluginIds: ["allowed"], - }); - const scopedAgain = loadOpenClawPlugins({ - ...options, - onlyPluginIds: ["allowed"], - }); + const scoped = loadOpenClawPlugins({ + cache: false, + activate: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["allowed-nonactivating-scope"], + }, + }, + onlyPluginIds: ["allowed-nonactivating-scope"], + }); - expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual(["allowed", "extra"]); - expect(scoped).not.toBe(full); - expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]); - expect(scopedAgain).toBe(scoped); - }); - - it("can load a scoped registry without replacing the active global registry", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { id: "allowed", register() {} };`, - }); - const previousRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(previousRegistry, "existing-registry"); - resetGlobalHookRunner(); - - const scoped = loadOpenClawPlugins({ - cache: false, - activate: false, - workspaceDir: plugin.dir, - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["allowed"], + expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed-nonactivating-scope"]); + expect(getActivePluginRegistry()).toBe(previousRegistry); + expect(getActivePluginRegistryKey()).toBe("existing-registry"); + expect(getGlobalHookRunner()).toBeNull(); }, }, - onlyPluginIds: ["allowed"], - }); + ] as const; - expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]); - expect(getActivePluginRegistry()).toBe(previousRegistry); - expect(getActivePluginRegistryKey()).toBe("existing-registry"); - expect(getGlobalHookRunner()).toBeNull(); + for (const scenario of scenarios) { + scenario.run(); + } }); it("only publishes plugin commands to the global registry during activating loads", async () => { From 355051f40190f2482acc584bef1dd7e939f9fe07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:20:18 +0000 Subject: [PATCH 061/393] test: merge audit gateway auth presence cases --- src/security/audit.test.ts | 164 +++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 79 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 614002096c7..7666633cf18 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -348,92 +348,98 @@ description: test skill expect(summary?.detail).toContain("trust model: personal assistant"); }); - it("flags non-loopback bind without auth as critical", async () => { - // Clear env tokens so resolveGatewayAuth defaults to mode=none - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - const prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - try { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: {}, - }, - }; - - const res = await audit(cfg); - - expect(hasFinding(res, "gateway.bind_no_auth", "critical")).toBe(true); - } finally { - // Restore env - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - if (prevPassword === undefined) { - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - } else { - process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword; - } - } - }); - - it("does not flag non-loopback bind without auth when gateway password uses SecretRef", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { - password: { - source: "env", - provider: "default", - id: "OPENCLAW_GATEWAY_PASSWORD", - }, + it("evaluates non-loopback gateway auth presence", async () => { + const cases = [ + { + name: "flags non-loopback bind without auth as critical", + run: async () => + withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, + }, + async () => + audit({ + gateway: { + bind: "lan", + auth: {}, + }, + }), + ), + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "gateway.bind_no_auth", "critical")).toBe(true); }, }, - }; - - const res = await audit(cfg, { env: {} }); - expectNoFinding(res, "gateway.bind_no_auth"); - }); - - it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => { - const sourceConfig: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { - token: { - source: "env", - provider: "default", - id: "OPENCLAW_GATEWAY_TOKEN", - }, + { + name: "does not flag non-loopback bind without auth when gateway password uses SecretRef", + run: async () => + audit( + { + gateway: { + bind: "lan", + auth: { + password: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_PASSWORD", + }, + }, + }, + }, + { env: {} }, + ), + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "gateway.bind_no_auth"); }, }, - secrets: { - providers: { - default: { source: "env" }, + { + name: "does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", + run: async () => { + const sourceConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + secrets: sourceConfig.secrets, + }; + + return runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + }, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "gateway.bind_no_auth"); }, }, - }; - const resolvedConfig: OpenClawConfig = { - gateway: { - bind: "lan", - auth: {}, - }, - secrets: sourceConfig.secrets, - }; + ] as const; - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - env: {}, - includeFilesystem: false, - includeChannelSecurity: false, - }); - - expectNoFinding(res, "gateway.bind_no_auth"); + await Promise.all( + cases.map(async (testCase) => { + const res = await testCase.run(); + testCase.assert(res); + }), + ); }); it("evaluates gateway auth rate-limit warning based on configuration", async () => { From 2cfccf59c77ce6669c895e290433b754a92b9610 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:21:36 +0000 Subject: [PATCH 062/393] test: merge audit browser container cases --- src/security/audit.test.ts | 219 ++++++++++++++++++------------------- 1 file changed, 106 insertions(+), 113 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 7666633cf18..de7f001de74 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -850,120 +850,113 @@ description: test skill ).toBe(true); }); - it("warns when sandbox browser containers have missing or stale hash labels", async () => { - const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels"); + it("evaluates sandbox browser container findings", async () => { + const cases = [ + { + name: "warns when sandbox browser containers have missing or stale hash labels", + fixtureLabel: "browser-hash-labels", + execDockerRawFn: (async (args: string[]) => { + if (args[0] === "ps") { + return { + stdout: Buffer.from("openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") { + return { + stdout: Buffer.from("abc123\tepoch-v0\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") { + return { + stdout: Buffer.from("\t\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable, + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "sandbox.browser_container.hash_label_missing", "warn")).toBe( + true, + ); + expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale", "warn")).toBe(true); + const staleEpoch = res.findings.find( + (f) => f.checkId === "sandbox.browser_container.hash_epoch_stale", + ); + expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old"); + }, + }, + { + name: "skips sandbox browser hash label checks when docker inspect is unavailable", + fixtureLabel: "browser-hash-labels-skip", + execDockerRawFn: (async () => { + throw new Error("spawn docker ENOENT"); + }) as NonNullable, + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "sandbox.browser_container.hash_label_missing")).toBe(false); + expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale")).toBe(false); + }, + }, + { + name: "flags sandbox browser containers with non-loopback published ports", + fixtureLabel: "browser-non-loopback-publish", + execDockerRawFn: (async (args: string[]) => { + if (args[0] === "ps") { + return { + stdout: Buffer.from("openclaw-sbx-browser-exposed\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-exposed") { + return { + stdout: Buffer.from("hash123\t2026-02-21-novnc-auth-default\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "port" && args.at(-1) === "openclaw-sbx-browser-exposed") { + return { + stdout: Buffer.from("6080/tcp -> 0.0.0.0:49101\n9222/tcp -> 127.0.0.1:49100\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable, + assert: (res: SecurityAuditReport) => { + expect( + hasFinding(res, "sandbox.browser_container.non_loopback_publish", "critical"), + ).toBe(true); + }, + }, + ] as const; - const execDockerRawFn = (async (args: string[]) => { - if (args[0] === "ps") { - return { - stdout: Buffer.from("openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") { - return { - stdout: Buffer.from("abc123\tepoch-v0\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") { - return { - stdout: Buffer.from("\t\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.from("not found"), - code: 1, - }; - }) as NonNullable; - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn, - }); - - expect(hasFinding(res, "sandbox.browser_container.hash_label_missing", "warn")).toBe(true); - expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale", "warn")).toBe(true); - const staleEpoch = res.findings.find( - (f) => f.checkId === "sandbox.browser_container.hash_epoch_stale", - ); - expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old"); - }); - - it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => { - const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels-skip"); - - const execDockerRawFn = (async () => { - throw new Error("spawn docker ENOENT"); - }) as NonNullable; - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn, - }); - - expect(hasFinding(res, "sandbox.browser_container.hash_label_missing")).toBe(false); - expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale")).toBe(false); - }); - - it("flags sandbox browser containers with non-loopback published ports", async () => { - const { stateDir, configPath } = await createFilesystemAuditFixture( - "browser-non-loopback-publish", - ); - - const execDockerRawFn = (async (args: string[]) => { - if (args[0] === "ps") { - return { - stdout: Buffer.from("openclaw-sbx-browser-exposed\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-exposed") { - return { - stdout: Buffer.from("hash123\t2026-02-21-novnc-auth-default\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "port" && args.at(-1) === "openclaw-sbx-browser-exposed") { - return { - stdout: Buffer.from("6080/tcp -> 0.0.0.0:49101\n9222/tcp -> 127.0.0.1:49100\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.from("not found"), - code: 1, - }; - }) as NonNullable; - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn, - }); - - expect(hasFinding(res, "sandbox.browser_container.non_loopback_publish", "critical")).toBe( - true, + await Promise.all( + cases.map(async (testCase) => { + const { stateDir, configPath } = await createFilesystemAuditFixture(testCase.fixtureLabel); + const res = await runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn: testCase.execDockerRawFn, + }); + testCase.assert(res); + }), ); }); From 8ab2d886eba78cc0e9389cfb0e3114d34c9b838b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:22:37 +0000 Subject: [PATCH 063/393] test: merge audit windows acl cases --- src/security/audit.test.ts | 146 ++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 74 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index de7f001de74..450e2135178 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -771,83 +771,81 @@ description: test skill ); }); - it("treats Windows ACL-only perms as secure", async () => { - const tmp = await makeTmpDir("win"); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - - const user = "DESKTOP-TEST\\Tester"; - const execIcacls = async (_cmd: string, args: string[]) => ({ - stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, - stderr: "", - }); - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - platform: "win32", - env: windowsAuditEnv, - execIcacls, - execDockerRawFn: execDockerRawUnavailable, - }); - - const forbidden = new Set([ - "fs.state_dir.perms_world_writable", - "fs.state_dir.perms_group_writable", - "fs.state_dir.perms_readable", - "fs.config.perms_writable", - "fs.config.perms_world_readable", - "fs.config.perms_group_readable", - ]); - for (const id of forbidden) { - expect(res.findings.some((f) => f.checkId === id)).toBe(false); - } - }); - - it("flags Windows ACLs when Users can read the state dir", async () => { - const tmp = await makeTmpDir("win-open"); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - - const user = "DESKTOP-TEST\\Tester"; - const execIcacls = async (_cmd: string, args: string[]) => { - const target = args[0]; - if (target === stateDir) { - return { - stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`, + it("evaluates Windows ACL-derived filesystem findings", async () => { + const cases = [ + { + name: "treats Windows ACL-only perms as secure", + label: "win", + execIcacls: async (_cmd: string, args: string[]) => ({ + stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n DESKTOP-TEST\\Tester:(F)\n`, stderr: "", - }; - } - return { - stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, - stderr: "", - }; - }; + }), + assert: (res: SecurityAuditReport) => { + const forbidden = new Set([ + "fs.state_dir.perms_world_writable", + "fs.state_dir.perms_group_writable", + "fs.state_dir.perms_readable", + "fs.config.perms_writable", + "fs.config.perms_world_readable", + "fs.config.perms_group_readable", + ]); + for (const id of forbidden) { + expect( + res.findings.some((f) => f.checkId === id), + id, + ).toBe(false); + } + }, + }, + { + name: "flags Windows ACLs when Users can read the state dir", + label: "win-open", + execIcacls: async (_cmd: string, args: string[]) => { + const target = args[0]; + if (target.endsWith(`${path.sep}state`)) { + return { + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n DESKTOP-TEST\\Tester:(F)\n`, + stderr: "", + }; + } + return { + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n DESKTOP-TEST\\Tester:(F)\n`, + stderr: "", + }; + }, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some( + (f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn", + ), + ).toBe(true); + }, + }, + ] as const; - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - platform: "win32", - env: windowsAuditEnv, - execIcacls, - execDockerRawFn: execDockerRawUnavailable, - }); + await Promise.all( + cases.map(async (testCase) => { + const tmp = await makeTmpDir(testCase.label); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); - expect( - res.findings.some( - (f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn", - ), - ).toBe(true); + const res = await runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + platform: "win32", + env: windowsAuditEnv, + execIcacls: testCase.execIcacls, + execDockerRawFn: execDockerRawUnavailable, + }); + + testCase.assert(res); + }), + ); }); it("evaluates sandbox browser container findings", async () => { From a24325f40c39d2fd0fdf95bf40db76da5ece18e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:23:38 +0000 Subject: [PATCH 064/393] test: merge audit deny command cases --- src/security/audit.test.ts | 99 ++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 450e2135178..b285c9dce74 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1274,62 +1274,59 @@ description: test skill ); }); - it("flags ineffective gateway.nodes.denyCommands entries", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - denyCommands: ["system.*", "system.runx"], - }, + it("evaluates ineffective gateway.nodes.denyCommands entries", async () => { + const cases = [ + { + name: "flags ineffective gateway.nodes.denyCommands entries", + cfg: { + gateway: { + nodes: { + denyCommands: ["system.*", "system.runx"], + }, + }, + } satisfies OpenClawConfig, + detailIncludes: ["system.*", "system.runx", "did you mean", "system.run"], }, - }; - - const res = await audit(cfg); - - const finding = res.findings.find( - (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("system.*"); - expect(finding?.detail).toContain("system.runx"); - expect(finding?.detail).toContain("did you mean"); - expect(finding?.detail).toContain("system.run"); - }); - - it("suggests prefix-matching commands for unknown denyCommands entries", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - denyCommands: ["system.run.prep"], - }, + { + name: "suggests prefix-matching commands for unknown denyCommands entries", + cfg: { + gateway: { + nodes: { + denyCommands: ["system.run.prep"], + }, + }, + } satisfies OpenClawConfig, + detailIncludes: ["system.run.prep", "did you mean", "system.run.prepare"], }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("system.run.prep"); - expect(finding?.detail).toContain("did you mean"); - expect(finding?.detail).toContain("system.run.prepare"); - }); - - it("keeps unknown denyCommands entries without suggestions when no close command exists", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - denyCommands: ["zzzzzzzzzzzzzz"], - }, + { + name: "keeps unknown denyCommands entries without suggestions when no close command exists", + cfg: { + gateway: { + nodes: { + denyCommands: ["zzzzzzzzzzzzzz"], + }, + }, + } satisfies OpenClawConfig, + detailIncludes: ["zzzzzzzzzzzzzz"], + detailExcludes: ["did you mean"], }, - }; + ] as const; - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + const finding = res.findings.find( + (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", + ); + expect(finding?.severity, testCase.name).toBe("warn"); + for (const text of testCase.detailIncludes) { + expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); + } + for (const text of testCase.detailExcludes ?? []) { + expect(finding?.detail, `${testCase.name}:${text}`).not.toContain(text); + } + }), ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("zzzzzzzzzzzzzz"); - expect(finding?.detail).not.toContain("did you mean"); }); it("scores dangerous gateway.nodes.allowCommands by exposure", async () => { From fb4b6eef03f783422bb856ab64c0c43eaf507735 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:24:36 +0000 Subject: [PATCH 065/393] test: merge audit code safety failure cases --- src/security/audit.test.ts | 81 +++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index b285c9dce74..898cfb74535 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3314,45 +3314,52 @@ description: test skill expect(skillFinding?.detail).toMatch(/runner\.js:\d+/); }); - it("flags plugin extension entry path traversal in deep audit", async () => { - const tmpDir = await makeTmpDir("audit-scanner-escape"); - const pluginDir = path.join(tmpDir, "extensions", "escape-plugin"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "escape-plugin", - openclaw: { extensions: ["../outside.js"] }, - }), - ); - await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); + it("evaluates plugin code-safety scanner failure modes", async () => { + const cases = [ + { + name: "flags plugin extension entry path traversal in deep audit", + label: "audit-scanner-escape", + pluginName: "escape-plugin", + extensions: ["../outside.js"], + assert: (findings: Awaited>) => { + expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); + }, + }, + { + name: "reports scan_failed when plugin code scanner throws during deep audit", + label: "audit-scanner-throws", + pluginName: "scanfail-plugin", + extensions: ["index.js"], + beforeRun: () => + vi + .spyOn(skillScanner, "scanDirectoryWithSummary") + .mockRejectedValueOnce(new Error("boom")), + assert: (findings: Awaited>) => { + expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true); + }, + }, + ] as const; - const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); - expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); - }); + for (const testCase of cases) { + const scanSpy = testCase.beforeRun?.(); + try { + const tmpDir = await makeTmpDir(testCase.label); + const pluginDir = path.join(tmpDir, "extensions", testCase.pluginName); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: testCase.pluginName, + openclaw: { extensions: testCase.extensions }, + }), + ); + await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); - it("reports scan_failed when plugin code scanner throws during deep audit", async () => { - const scanSpy = vi - .spyOn(skillScanner, "scanDirectoryWithSummary") - .mockRejectedValueOnce(new Error("boom")); - - const tmpDir = await makeTmpDir("audit-scanner-throws"); - try { - const pluginDir = path.join(tmpDir, "extensions", "scanfail-plugin"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "scanfail-plugin", - openclaw: { extensions: ["index.js"] }, - }), - ); - await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); - - const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); - expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true); - } finally { - scanSpy.mockRestore(); + const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); + testCase.assert(findings); + } finally { + scanSpy?.mockRestore(); + } } }); From b7dc23b403d736e92a963759f764e57fe4f67694 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:26:16 +0000 Subject: [PATCH 066/393] test: merge loader cache miss cases --- src/plugins/loader.test.ts | 175 +++++++++++++++++++------------------ 1 file changed, 92 insertions(+), 83 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 07576d8d872..9e3e98cb821 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1307,95 +1307,104 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip }); }); - it("does not reuse cached registries when env-resolved install paths change", () => { - useNoBundledPlugins(); - const openclawHome = makeTempDir(); - const ignoredHome = makeTempDir(); - const stateDir = makeTempDir(); - const pluginDir = path.join(openclawHome, "plugins", "tracked-install-cache"); - mkdirSafe(pluginDir); - const plugin = writePlugin({ - id: "tracked-install-cache", - dir: pluginDir, - filename: "index.cjs", - body: `module.exports = { id: "tracked-install-cache", register() {} };`, - }); + it.each([ + { + name: "does not reuse cached registries when env-resolved install paths change", + setup: () => { + useNoBundledPlugins(); + const openclawHome = makeTempDir(); + const ignoredHome = makeTempDir(); + const stateDir = makeTempDir(); + const pluginDir = path.join(openclawHome, "plugins", "tracked-install-cache"); + mkdirSafe(pluginDir); + const plugin = writePlugin({ + id: "tracked-install-cache", + dir: pluginDir, + filename: "index.cjs", + body: `module.exports = { id: "tracked-install-cache", register() {} };`, + }); - const options = { - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["tracked-install-cache"], - installs: { - "tracked-install-cache": { - source: "path" as const, - installPath: "~/plugins/tracked-install-cache", - sourcePath: "~/plugins/tracked-install-cache", + const options = { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["tracked-install-cache"], + installs: { + "tracked-install-cache": { + source: "path" as const, + installPath: "~/plugins/tracked-install-cache", + sourcePath: "~/plugins/tracked-install-cache", + }, + }, }, }, - }, + }; + + const secondHome = makeTempDir(); + return { + loadFirst: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_HOME: openclawHome, + HOME: ignoredHome, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + }), + loadVariant: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_HOME: secondHome, + HOME: ignoredHome, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + }), + }; }, - }; + }, + { + name: "does not reuse cached registries across gateway subagent binding modes", + setup: () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "cache-gateway-bindable", + filename: "cache-gateway-bindable.cjs", + body: `module.exports = { id: "cache-gateway-bindable", register() {} };`, + }); - const secondHome = makeTempDir(); - const secondOptions = { - ...options, - env: { - ...process.env, - OPENCLAW_HOME: secondHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + const options = { + workspaceDir: plugin.dir, + config: { + plugins: { + allow: ["cache-gateway-bindable"], + load: { + paths: [plugin.file], + }, + }, + }, + }; + + return { + loadFirst: () => loadOpenClawPlugins(options), + loadVariant: () => + loadOpenClawPlugins({ + ...options, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }), + }; }, - }; - expectCacheMissThenHit({ - loadFirst: () => - loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - OPENCLAW_HOME: openclawHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - }), - loadVariant: () => loadOpenClawPlugins(secondOptions), - }); - }); - - it("does not reuse cached registries across gateway subagent binding modes", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "cache-gateway-bindable", - filename: "cache-gateway-bindable.cjs", - body: `module.exports = { id: "cache-gateway-bindable", register() {} };`, - }); - - const options = { - workspaceDir: plugin.dir, - config: { - plugins: { - allow: ["cache-gateway-bindable"], - load: { - paths: [plugin.file], - }, - }, - }, - }; - - expectCacheMissThenHit({ - loadFirst: () => loadOpenClawPlugins(options), - loadVariant: () => - loadOpenClawPlugins({ - ...options, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }), - }); + }, + ])("$name", ({ setup }) => { + expectCacheMissThenHit(setup()); }); it("evicts least recently used registries when the loader cache exceeds its cap", () => { From c4323db30f6a2eddda52a4ba07c806363af3ff54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:29:19 +0000 Subject: [PATCH 067/393] test: merge update cli service refresh cases --- src/cli/update-cli.test.ts | 213 +++++++++++++++++++------------------ 1 file changed, 111 insertions(+), 102 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 97074f1c29f..6c262ed04c6 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -265,6 +265,27 @@ describe("update-cli", () => { return tempDir; }; + const setupUpdatedRootRefresh = (params?: { + gatewayUpdateImpl?: () => Promise; + }) => { + const root = createCaseDir("openclaw-updated-root"); + const entryPath = path.join(root, "dist", "entry.js"); + pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); + if (params?.gatewayUpdateImpl) { + vi.mocked(runGatewayUpdate).mockImplementation(params.gatewayUpdateImpl); + } else { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }); + } + serviceLoaded.mockResolvedValue(true); + return { root, entryPath }; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); @@ -624,114 +645,102 @@ describe("update-cli", () => { expect(runDaemonRestart).not.toHaveBeenCalled(); }); - it("updateCommand refreshes service env from updated install root when available", async () => { - const root = createCaseDir("openclaw-updated-root"); - const entryPath = path.join(root, "dist", "entry.js"); - pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); - - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "npm", - root, - steps: [], - durationMs: 100, - }); - serviceLoaded.mockResolvedValue(true); - - await updateCommand({}); - - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ cwd: root, timeoutMs: 60_000 }), - ); - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).toHaveBeenCalled(); - }); - - it("updateCommand preserves invocation-relative service env overrides during refresh", async () => { - const root = createCaseDir("openclaw-updated-root"); - const entryPath = path.join(root, "dist", "entry.js"); - pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); - - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "npm", - root, - steps: [], - durationMs: 100, - }); - serviceLoaded.mockResolvedValue(true); - - await withEnvAsync( - { - OPENCLAW_STATE_DIR: "./state", - OPENCLAW_CONFIG_PATH: "./config/openclaw.json", - }, - async () => { + it.each([ + { + name: "updateCommand refreshes service env from updated install root when available", + invoke: async () => { await updateCommand({}); }, - ); + expectedOptions: (root: string) => expect.objectContaining({ cwd: root, timeoutMs: 60_000 }), + assertExtra: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).toHaveBeenCalled(); + }, + }, + { + name: "updateCommand preserves invocation-relative service env overrides during refresh", + invoke: async () => { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + OPENCLAW_CONFIG_PATH: "./config/openclaw.json", + }, + async () => { + await updateCommand({}); + }, + ); + }, + expectedOptions: (root: string) => + expect.objectContaining({ + cwd: root, + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve("./state"), + OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"), + }), + timeoutMs: 60_000, + }), + assertExtra: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + }, + }, + { + name: "updateCommand reuses the captured invocation cwd when process.cwd later fails", + invoke: async () => { + const originalCwd = process.cwd(); + let restoreCwd: (() => void) | undefined; + const { root } = setupUpdatedRootRefresh({ + gatewayUpdateImpl: async () => { + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: current working directory is gone"); + }); + restoreCwd = () => cwdSpy.mockRestore(); + return { + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }; + }, + }); + try { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + }, + async () => { + await updateCommand({}); + }, + ); + } finally { + restoreCwd?.(); + } + return { originalCwd }; + }, + customSetup: true, + expectedOptions: (_root: string, context?: { originalCwd: string }) => + expect.objectContaining({ + cwd: expect.any(String), + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve(context?.originalCwd ?? process.cwd(), "./state"), + }), + timeoutMs: 60_000, + }), + assertExtra: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + }, + }, + ])("$name", async (testCase) => { + const setup = testCase.customSetup ? undefined : setupUpdatedRootRefresh(); + const context = await testCase.invoke(); + const root = setup?.root ?? runCommandWithTimeout.mock.calls[0]?.[1]?.cwd; + const entryPath = setup?.entryPath ?? path.join(String(root), "dist", "entry.js"); expect(runCommandWithTimeout).toHaveBeenCalledWith( [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ - cwd: root, - env: expect.objectContaining({ - OPENCLAW_STATE_DIR: path.resolve("./state"), - OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"), - }), - timeoutMs: 60_000, - }), + testCase.expectedOptions(String(root), context), ); - expect(runDaemonInstall).not.toHaveBeenCalled(); - }); - - it("updateCommand reuses the captured invocation cwd when process.cwd later fails", async () => { - const root = createCaseDir("openclaw-updated-root"); - const entryPath = path.join(root, "dist", "entry.js"); - pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); - - const originalCwd = process.cwd(); - let restoreCwd: (() => void) | undefined; - vi.mocked(runGatewayUpdate).mockImplementation(async () => { - const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { - throw new Error("ENOENT: current working directory is gone"); - }); - restoreCwd = () => cwdSpy.mockRestore(); - return { - status: "ok", - mode: "npm", - root, - steps: [], - durationMs: 100, - }; - }); - serviceLoaded.mockResolvedValue(true); - - try { - await withEnvAsync( - { - OPENCLAW_STATE_DIR: "./state", - }, - async () => { - await updateCommand({}); - }, - ); - } finally { - restoreCwd?.(); - } - - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ - cwd: root, - env: expect.objectContaining({ - OPENCLAW_STATE_DIR: path.resolve(originalCwd, "./state"), - }), - timeoutMs: 60_000, - }), - ); - expect(runDaemonInstall).not.toHaveBeenCalled(); + testCase.assertExtra(); }); it("updateCommand falls back to restart when service env refresh cannot complete", async () => { From f9408e57d29fc82e2881fb6b357efa3102ba8f0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:30:52 +0000 Subject: [PATCH 068/393] test: merge slack action mapping cases --- src/channels/plugins/actions/actions.test.ts | 51 ++++++++------------ 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index dc79ba3247e..872e8770121 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1150,8 +1150,8 @@ describe("signalMessageActions", () => { }); describe("slack actions adapter", () => { - it("forwards simple slack action params", async () => { - for (const testCase of [ + it("forwards slack action params", async () => { + const cases = [ { action: "read" as const, params: { @@ -1174,15 +1174,6 @@ describe("slack actions adapter", () => { limit: 2, }, }, - ] as const) { - handleSlackAction.mockClear(); - await runSlackAction(testCase.action, testCase.params); - expectFirstSlackAction(testCase.expected); - } - }); - - it("forwards blocks for send/edit actions", async () => { - const cases = [ { action: "send" as const, params: { @@ -1243,12 +1234,31 @@ describe("slack actions adapter", () => { blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], }, }, + { + action: "send" as const, + params: { + to: "channel:C1", + message: "", + media: "https://example.com/image.png", + }, + expected: { + action: "sendMessage", + to: "channel:C1", + content: "", + mediaUrl: "https://example.com/image.png", + }, + absentKeys: ["blocks"], + }, ] as const; for (const testCase of cases) { handleSlackAction.mockClear(); await runSlackAction(testCase.action, testCase.params); expectFirstSlackAction(testCase.expected); + const [params] = handleSlackAction.mock.calls[0] ?? []; + for (const key of testCase.absentKeys ?? []) { + expect(params).not.toHaveProperty(key); + } } }); @@ -1290,25 +1300,6 @@ describe("slack actions adapter", () => { } }); - it("does not attach empty blocks to plain media sends", async () => { - handleSlackAction.mockClear(); - - await runSlackAction("send", { - to: "channel:C1", - message: "", - media: "https://example.com/image.png", - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "sendMessage", - to: "channel:C1", - content: "", - mediaUrl: "https://example.com/image.png", - }); - expect(params).not.toHaveProperty("blocks"); - }); - it("rejects edit when both message and blocks are missing", async () => { const { cfg, actions } = slackHarness(); From 5a5a66d63d345aefd13c3a39f6aa9330aea02869 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:32:04 +0000 Subject: [PATCH 069/393] test: merge command owner gating cases --- src/auto-reply/reply/commands.test.ts | 95 ++++++++++++++++----------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index bd59a708fa7..87abd8b1d52 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -729,32 +729,45 @@ describe("extractMessageText", () => { }); describe("handleCommands /config owner gating", () => { - it("blocks /config show from authorized non-owner senders", async () => { + it("enforces /config show owner gating", async () => { const cfg = { commands: { config: true, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/config show", cfg); - params.command.senderIsOwner = false; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - }); + const cases = [ + { + name: "blocks authorized non-owner senders", + text: "/config show", + senderIsOwner: false, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }, + }, + { + name: "keeps /config show working for owners", + text: "/config show messages.ackReaction", + senderIsOwner: true, + beforeRun: () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + }, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config messages.ackReaction"); + }, + }, + ] as const; - it("keeps /config show working for owners", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - const params = buildParams("/config show messages.ackReaction", cfg); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackReaction"); + for (const testCase of cases) { + testCase.beforeRun?.(); + const params = buildParams(testCase.text, cfg); + params.command.senderIsOwner = testCase.senderIsOwner; + const result = await handleCommands(params); + testCase.assert(result); + } }); }); @@ -932,28 +945,34 @@ describe("handleCommands /config configWrites gating", () => { }); describe("handleCommands /debug owner gating", () => { - it("blocks /debug show from authorized non-owner senders", async () => { + it("enforces /debug show owner gating", async () => { const cfg = { commands: { debug: true, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/debug show", cfg); - params.command.senderIsOwner = false; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - }); + const cases = [ + { + senderIsOwner: false, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }, + }, + { + senderIsOwner: true, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Debug overrides"); + }, + }, + ] as const; - it("keeps /debug show working for owners", async () => { - const cfg = { - commands: { debug: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/debug show", cfg); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Debug overrides"); + for (const testCase of cases) { + const params = buildParams("/debug show", cfg); + params.command.senderIsOwner = testCase.senderIsOwner; + const result = await handleCommands(params); + testCase.assert(result); + } }); }); From 3be44b104449ab59b5ba3260d50539d1868c4eef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:32:59 +0000 Subject: [PATCH 070/393] test: merge update status output cases --- src/cli/update-cli.test.ts | 39 +++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 6c262ed04c6..bfbea81336e 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -393,20 +393,33 @@ describe("update-cli", () => { expect(logs.join("\n")).toContain("No changes were applied."); }); - it("updateStatusCommand prints table output", async () => { - await updateStatusCommand({ json: false }); + it("updateStatusCommand renders table and json output", async () => { + const cases = [ + { + name: "table output", + options: { json: false }, + assert: () => { + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); + expect(logs.join("\n")).toContain("OpenClaw update status"); + }, + }, + { + name: "json output", + options: { json: true }, + assert: () => { + const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0]; + expect(typeof last).toBe("string"); + const parsed = JSON.parse(String(last)); + expect(parsed.channel.value).toBe("stable"); + }, + }, + ] as const; - const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); - expect(logs.join("\n")).toContain("OpenClaw update status"); - }); - - it("updateStatusCommand emits JSON", async () => { - await updateStatusCommand({ json: true }); - - const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0]; - expect(typeof last).toBe("string"); - const parsed = JSON.parse(String(last)); - expect(parsed.channel.value).toBe("stable"); + for (const testCase of cases) { + vi.mocked(defaultRuntime.log).mockClear(); + await updateStatusCommand(testCase.options); + testCase.assert(); + } }); it.each([ From 580e00d91bc3cfc8712f093b3c7d3eac1c05548f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:34:59 +0000 Subject: [PATCH 071/393] test: merge command gateway config permission cases --- src/auto-reply/reply/commands.test.ts | 215 ++++++++++++++------------ 1 file changed, 113 insertions(+), 102 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 87abd8b1d52..2e261a5093b 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -832,115 +832,126 @@ describe("handleCommands /config configWrites gating", () => { expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); }); - it("blocks /config set from gateway clients without operator.admin", async () => { - const cfg = { + it("enforces gateway client permissions for /config commands", async () => { + const baseCfg = { commands: { config: true, text: true }, } as OpenClawConfig; - const params = buildParams('/config set messages.ackReaction=":)"', cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("requires operator.admin"); - }); - - it("keeps /config show available to gateway operator.write clients", async () => { - const cfg = { - commands: { config: true, text: true }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - const params = buildParams("/config show messages.ackReaction", cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = false; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackReaction"); - }); - - it("keeps /config set working for gateway operator.admin clients", async () => { - await withTempConfigPath({ messages: { ackReaction: ":)" } }, async (configPath) => { - const cfg = { - commands: { config: true, text: true }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams('/config set messages.ackReaction=":D"', cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write", "operator.admin"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config updated"); - const written = await readJsonFile(configPath); - expect(written.messages?.ackReaction).toBe(":D"); - }); - }); - - it("keeps /config set working for gateway operator.admin on protected account paths", async () => { - const initialConfig = { - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, + const cases = [ + { + name: "blocks /config set from gateway clients without operator.admin", + run: async () => { + const params = buildParams('/config set messages.ackReaction=":)"', baseCfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("requires operator.admin"); }, }, - }; - await withTempConfigPath(initialConfig, async (configPath) => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: structuredClone(initialConfig), - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams( - "/config set channels.telegram.accounts.work.enabled=false", - { - commands: { config: true, text: true }, - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, + { + name: "keeps /config show available to gateway operator.write clients", + run: async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + const params = buildParams("/config show messages.ackReaction", baseCfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = false; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config messages.ackReaction"); + }, + }, + { + name: "keeps /config set working for gateway operator.admin clients", + run: async () => { + await withTempConfigPath({ messages: { ackReaction: ":)" } }, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams('/config set messages.ackReaction=":D"', baseCfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.messages?.ackReaction).toBe(":D"); + }); + }, + }, + { + name: "keeps /config set working for gateway operator.admin on protected account paths", + run: async () => { + const initialConfig = { + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, }, }, - }, - } as OpenClawConfig, - { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write", "operator.admin"], + }; + await withTempConfigPath(initialConfig, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: structuredClone(initialConfig), + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams( + "/config set channels.telegram.accounts.work.enabled=false", + { + commands: { config: true, text: true }, + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, + }, + } as OpenClawConfig, + { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }, + ); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); + }); }, - ); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config updated"); - const written = await readJsonFile(configPath); - expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); - }); + }, + ] as const; + + for (const testCase of cases) { + await testCase.run(); + } }); }); From 48a9aa152c76a38ab396a96ceca767047a7a483a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:35:46 +0000 Subject: [PATCH 072/393] test: merge command approval scope cases --- src/auto-reply/reply/commands.test.ts | 62 ++++++++++++++------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 2e261a5093b..d4beca8794a 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -463,46 +463,50 @@ describe("/approve command", () => { } }); - it("rejects gateway clients without approvals scope", async () => { + it("enforces gateway approval scopes", async () => { const cfg = { commands: { text: true }, } as OpenClawConfig; - const params = buildParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.write"], - }); - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("requires operator.approvals"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("allows gateway clients with approvals or admin scopes", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const scopeCases = [["operator.approvals"], ["operator.admin"]]; - for (const scopes of scopeCases) { + const cases = [ + { + scopes: ["operator.write"], + expectedText: "requires operator.approvals", + expectedGatewayCalls: 0, + }, + { + scopes: ["operator.approvals"], + expectedText: "Exec approval allow-once submitted", + expectedGatewayCalls: 1, + }, + { + scopes: ["operator.admin"], + expectedText: "Exec approval allow-once submitted", + expectedGatewayCalls: 1, + }, + ] as const; + for (const testCase of cases) { + callGatewayMock.mockReset(); callGatewayMock.mockResolvedValue({ ok: true }); const params = buildParams("/approve abc allow-once", cfg, { Provider: "webchat", Surface: "webchat", - GatewayClientScopes: scopes, + GatewayClientScopes: testCase.scopes, }); const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), + expect(result.shouldContinue, String(testCase.scopes)).toBe(false); + expect(result.reply?.text, String(testCase.scopes)).toContain(testCase.expectedText); + expect(callGatewayMock, String(testCase.scopes)).toHaveBeenCalledTimes( + testCase.expectedGatewayCalls, ); + if (testCase.expectedGatewayCalls > 0) { + expect(callGatewayMock, String(testCase.scopes)).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + } } }); }); From 060654e94774f75f53462089ee18b69a2ce6f751 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:36:50 +0000 Subject: [PATCH 073/393] test: merge command hook cases --- src/auto-reply/reply/commands.test.ts | 94 ++++++++++++++------------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index d4beca8794a..1ea521f704a 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1473,52 +1473,56 @@ describe("handleCommands identity", () => { }); describe("handleCommands hooks", () => { - it("triggers hooks for /new with arguments", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/new take notes", cfg); - const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - - await handleCommands(params); - - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: "command", action: "new" })); - spy.mockRestore(); - }); - - it("triggers hooks for native /new routed to target sessions", async () => { - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/new", cfg, { - Provider: "telegram", - Surface: "telegram", - CommandSource: "native", - CommandTargetSessionKey: "agent:main:telegram:direct:123", - SessionKey: "telegram:slash:123", - SenderId: "123", - From: "telegram:123", - To: "slash:123", - CommandAuthorized: true, - }); - params.sessionKey = "agent:main:telegram:direct:123"; - const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - - await handleCommands(params); - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "command", - action: "new", - sessionKey: "agent:main:telegram:direct:123", - context: expect.objectContaining({ - workspaceDir: testWorkspaceDir, + it("triggers hooks for /new commands", async () => { + const cases = [ + { + name: "text command with arguments", + params: buildParams("/new take notes", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + expectedCall: expect.objectContaining({ type: "command", action: "new" }), + }, + { + name: "native command routed to target session", + params: (() => { + const params = buildParams( + "/new", + { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { + Provider: "telegram", + Surface: "telegram", + CommandSource: "native", + CommandTargetSessionKey: "agent:main:telegram:direct:123", + SessionKey: "telegram:slash:123", + SenderId: "123", + From: "telegram:123", + To: "slash:123", + CommandAuthorized: true, + }, + ); + params.sessionKey = "agent:main:telegram:direct:123"; + return params; + })(), + expectedCall: expect.objectContaining({ + type: "command", + action: "new", + sessionKey: "agent:main:telegram:direct:123", + context: expect.objectContaining({ + workspaceDir: testWorkspaceDir, + }), }), - }), - ); - spy.mockRestore(); + }, + ] as const; + for (const testCase of cases) { + const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); + await handleCommands(testCase.params); + expect(spy, testCase.name).toHaveBeenCalledWith(testCase.expectedCall); + spy.mockRestore(); + } }); }); From 7c24aab9545ed9185fa55d9634f67a5abf072843 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:37:39 +0000 Subject: [PATCH 074/393] test: merge command config write denial cases --- src/auto-reply/reply/commands.test.ts | 121 ++++++++++++++------------ 1 file changed, 66 insertions(+), 55 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 1ea521f704a..6feda5d2de6 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -776,64 +776,75 @@ describe("handleCommands /config owner gating", () => { }); describe("handleCommands /config configWrites gating", () => { - it("blocks /config set when channel config writes are disabled", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, - } as OpenClawConfig; - const params = buildParams('/config set messages.ackReaction=":)"', cfg); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config writes are disabled"); - }); - - it("blocks /config set when the target account disables writes", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - const cfg = { - commands: { config: true, text: true }, - channels: { - telegram: { - configWrites: true, - accounts: { - work: { configWrites: false, enabled: true }, - }, - }, - }, - } as OpenClawConfig; - const params = buildPolicyParams( - "/config set channels.telegram.accounts.work.enabled=false", - cfg, + it("blocks disallowed /config set writes", async () => { + const cases = [ { - AccountId: "default", - Provider: "telegram", - Surface: "telegram", + name: "channel config writes disabled", + params: (() => { + const params = buildParams('/config set messages.ackReaction=":)"', { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, + } as OpenClawConfig); + params.command.senderIsOwner = true; + return params; + })(), + expectedText: "Config writes are disabled", }, - ); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); - }); + { + name: "target account disables writes", + params: (() => { + const params = buildPolicyParams( + "/config set channels.telegram.accounts.work.enabled=false", + { + commands: { config: true, text: true }, + channels: { + telegram: { + configWrites: true, + accounts: { + work: { configWrites: false, enabled: true }, + }, + }, + }, + } as OpenClawConfig, + { + AccountId: "default", + Provider: "telegram", + Surface: "telegram", + }, + ); + params.command.senderIsOwner = true; + return params; + })(), + expectedText: "channels.telegram.accounts.work.configWrites=true", + }, + { + name: "ambiguous channel-root write", + params: (() => { + const params = buildPolicyParams( + '/config set channels.telegram={"enabled":false}', + { + commands: { config: true, text: true }, + channels: { telegram: { configWrites: true } }, + } as OpenClawConfig, + { + Provider: "telegram", + Surface: "telegram", + }, + ); + params.command.senderIsOwner = true; + return params; + })(), + expectedText: "cannot replace channels, channel roots, or accounts collections", + }, + ] as const; - it("blocks ambiguous channel-root /config writes from channel commands", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - const cfg = { - commands: { config: true, text: true }, - channels: { telegram: { configWrites: true } }, - } as OpenClawConfig; - const params = buildPolicyParams('/config set channels.telegram={"enabled":false}', cfg, { - Provider: "telegram", - Surface: "telegram", - }); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain( - "cannot replace channels, channel roots, or accounts collections", - ); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + for (const testCase of cases) { + const previousWriteCount = writeConfigFileMock.mock.calls.length; + const result = await handleCommands(testCase.params); + expect(result.shouldContinue, testCase.name).toBe(false); + expect(result.reply?.text, testCase.name).toContain(testCase.expectedText); + expect(writeConfigFileMock.mock.calls.length, testCase.name).toBe(previousWriteCount); + } }); it("enforces gateway client permissions for /config commands", async () => { From 59eaeaccfebccf54f5c3a6bcb0c2b3b3c80fc5fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:38:33 +0000 Subject: [PATCH 075/393] test: merge command allowlist add cases --- src/auto-reply/reply/commands.test.ts | 146 ++++++++++++++------------ 1 file changed, 80 insertions(+), 66 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 6feda5d2de6..6506a6f621b 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1083,78 +1083,92 @@ describe("handleCommands /allowlist", () => { expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); }); - it("adds entries to config and pairing store", async () => { - await withTempConfigPath( - { - channels: { telegram: { allowFrom: ["123"] } }, - }, - async (configPath) => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { telegram: { allowFrom: ["123"] } }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); - - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { allowFrom: ["123"] } }, - } as OpenClawConfig; - const params = buildPolicyParams("/allowlist add dm 789", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - const written = await readJsonFile(configPath); - expect(written.channels?.telegram?.allowFrom).toEqual(["123", "789"]); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - accountId: "default", - }); - expect(result.reply?.text).toContain("DM allowlist added"); - }, - ); - }); - - it("writes store entries to the selected account scope", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, - }, - }); + it("adds allowlist entries to config and pairing stores", async () => { validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ ok: true, config, })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); + const cases = [ + { + name: "default account", + run: async () => { + await withTempConfigPath( + { + channels: { telegram: { allowFrom: ["123"] } }, + }, + async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { allowFrom: ["123"] } }, + }, + }); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, - } as OpenClawConfig; - const params = buildPolicyParams("/allowlist add dm --account work 789", cfg, { - AccountId: "work", - }); - const result = await handleCommands(params); + const params = buildPolicyParams("/allowlist add dm 789", { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig); + const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - accountId: "work", - }); + expect(result.shouldContinue).toBe(false); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.allowFrom, "default account").toEqual([ + "123", + "789", + ]); + expect(addChannelAllowFromStoreEntryMock, "default account").toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(result.reply?.text, "default account").toContain("DM allowlist added"); + }, + ); + }, + }, + { + name: "selected account scope", + run: async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + }, + }); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const params = buildPolicyParams( + "/allowlist add dm --account work 789", + { + commands: { text: true, config: true }, + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + } as OpenClawConfig, + { + AccountId: "work", + }, + ); + const result = await handleCommands(params); + + expect(result.shouldContinue, "selected account scope").toBe(false); + expect(addChannelAllowFromStoreEntryMock, "selected account scope").toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "work", + }); + }, + }, + ] as const; + + for (const testCase of cases) { + await testCase.run(); + } }); it("blocks config-targeted /allowlist edits when the target account disables writes", async () => { From 37df574da0c02c68a75751ea1a1776ab33ff4c3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:39:45 +0000 Subject: [PATCH 076/393] test: merge update cli service refresh behavior --- src/cli/update-cli.test.ts | 113 +++++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 42 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index bfbea81336e..ed934ce9ea6 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -636,26 +636,80 @@ describe("update-cli", () => { expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); - it("updateCommand refreshes gateway service env when service is already installed", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; + it("updateCommand handles service env refresh and restart behavior", async () => { + const cases = [ + { + name: "refreshes service env when already installed", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + } satisfies UpdateRunResult); + vi.mocked(runDaemonInstall).mockResolvedValue(undefined); + serviceLoaded.mockResolvedValue(true); - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); - vi.mocked(runDaemonInstall).mockResolvedValue(undefined); - serviceLoaded.mockResolvedValue(true); + await updateCommand({}); + }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runRestartScript).toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + }, + }, + { + name: "falls back to daemon restart when service env refresh cannot complete", + run: async () => { + vi.mocked(runDaemonRestart).mockResolvedValue(true); + await runRestartFallbackScenario({ daemonInstall: "fail" }); + }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runDaemonRestart).toHaveBeenCalled(); + }, + }, + { + name: "keeps going when daemon install succeeds but restart fallback still handles relaunch", + run: async () => { + vi.mocked(runDaemonRestart).mockResolvedValue(true); + await runRestartFallbackScenario({ daemonInstall: "ok" }); + }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runDaemonRestart).toHaveBeenCalled(); + }, + }, + { + name: "skips service env refresh when --no-restart is set", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + serviceLoaded.mockResolvedValue(true); - await updateCommand({}); + await updateCommand({ restart: false }); + }, + assert: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + }, + }, + ] as const; - expect(runDaemonInstall).toHaveBeenCalledWith({ - force: true, - json: undefined, - }); - expect(runRestartScript).toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); + for (const testCase of cases) { + vi.clearAllMocks(); + await testCase.run(); + testCase.assert(); + } }); it.each([ @@ -756,31 +810,6 @@ describe("update-cli", () => { testCase.assertExtra(); }); - it("updateCommand falls back to restart when service env refresh cannot complete", async () => { - for (const daemonInstall of ["fail", "ok"] as const) { - vi.clearAllMocks(); - vi.mocked(runDaemonRestart).mockResolvedValue(true); - await runRestartFallbackScenario({ daemonInstall }); - - expect(runDaemonInstall).toHaveBeenCalledWith({ - force: true, - json: undefined, - }); - expect(runDaemonRestart).toHaveBeenCalled(); - } - }); - - it("updateCommand does not refresh service env when --no-restart is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - serviceLoaded.mockResolvedValue(true); - - await updateCommand({ restart: false }); - - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).not.toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - }); - it("updateCommand continues after doctor sub-step and clears update flag", async () => { const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); try { From 64c1fc098a8b95f03e3728bd02cbaac096648642 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:41:03 +0000 Subject: [PATCH 077/393] test: merge command owner show gating cases --- src/auto-reply/reply/commands.test.ts | 99 ++++++++++++++------------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 6506a6f621b..48fa3e061ab 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -732,44 +732,77 @@ describe("extractMessageText", () => { }); }); -describe("handleCommands /config owner gating", () => { - it("enforces /config show owner gating", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; +describe("handleCommands owner gating for privileged show commands", () => { + it("enforces owner gating for /config show and /debug show", async () => { const cases = [ { - name: "blocks authorized non-owner senders", - text: "/config show", - senderIsOwner: false, + name: "/config show blocks authorized non-owner senders", + build: () => { + const params = buildParams("/config show", { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = false; + return params; + }, assert: (result: Awaited>) => { expect(result.shouldContinue).toBe(false); expect(result.reply).toBeUndefined(); }, }, { - name: "keeps /config show working for owners", - text: "/config show messages.ackReaction", - senderIsOwner: true, - beforeRun: () => { + name: "/config show stays available for owners", + build: () => { readConfigFileSnapshotMock.mockResolvedValueOnce({ valid: true, parsed: { messages: { ackReaction: ":)" } }, }); + const params = buildParams("/config show messages.ackReaction", { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = true; + return params; }, assert: (result: Awaited>) => { expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Config messages.ackReaction"); }, }, + { + name: "/debug show blocks authorized non-owner senders", + build: () => { + const params = buildParams("/debug show", { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = false; + return params; + }, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }, + }, + { + name: "/debug show stays available for owners", + build: () => { + const params = buildParams("/debug show", { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = true; + return params; + }, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Debug overrides"); + }, + }, ] as const; for (const testCase of cases) { - testCase.beforeRun?.(); - const params = buildParams(testCase.text, cfg); - params.command.senderIsOwner = testCase.senderIsOwner; - const result = await handleCommands(params); + const result = await handleCommands(testCase.build()); testCase.assert(result); } }); @@ -970,38 +1003,6 @@ describe("handleCommands /config configWrites gating", () => { }); }); -describe("handleCommands /debug owner gating", () => { - it("enforces /debug show owner gating", async () => { - const cfg = { - commands: { debug: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const cases = [ - { - senderIsOwner: false, - assert: (result: Awaited>) => { - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - }, - }, - { - senderIsOwner: true, - assert: (result: Awaited>) => { - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Debug overrides"); - }, - }, - ] as const; - - for (const testCase of cases) { - const params = buildParams("/debug show", cfg); - params.command.senderIsOwner = testCase.senderIsOwner; - const result = await handleCommands(params); - testCase.assert(result); - } - }); -}); - describe("handleCommands bash alias", () => { it("routes !poll and !stop through the /bash handler", async () => { const cfg = { From 253ec7452fe596d64e679ea116765dd73b7144c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:42:14 +0000 Subject: [PATCH 078/393] test: merge discord action listing cases --- src/channels/plugins/actions/actions.test.ts | 157 ++++++++++--------- 1 file changed, 86 insertions(+), 71 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 872e8770121..967b75a2f84 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -202,88 +202,103 @@ beforeEach(async () => { }); describe("discord message actions", () => { - it("lists channel and upload actions by default", async () => { - const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("emoji-upload"); - expect(actions).toContain("sticker-upload"); - expect(actions).toContain("channel-create"); - }); - - it("respects disabled channel actions", async () => { - const cfg = { - channels: { discord: { token: "d0", actions: { channels: false } } }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("channel-create"); - }); - - it("lists moderation when at least one account enables it", () => { + it("derives discord action listings from channel and moderation gates", () => { const cases = [ { - channels: { - discord: { - accounts: { - vime: { token: "d1", actions: { moderation: true } }, - }, - }, - }, + name: "defaults", + cfg: { channels: { discord: { token: "d0" } } } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: false, }, { - channels: { - discord: { - accounts: { - ops: { token: "d1", actions: { moderation: true } }, - chat: { token: "d2" }, + name: "disabled channel actions", + cfg: { + channels: { discord: { token: "d0", actions: { channels: false } } }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: false, + expectModeration: false, + }, + { + name: "single account enables moderation", + cfg: { + channels: { + discord: { + accounts: { + vime: { token: "d1", actions: { moderation: true } }, + }, }, }, - }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: true, + }, + { + name: "one of many accounts enables moderation", + cfg: { + channels: { + discord: { + accounts: { + ops: { token: "d1", actions: { moderation: true } }, + chat: { token: "d2" }, + }, + }, + }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: true, + }, + { + name: "all accounts omit moderation", + cfg: { + channels: { + discord: { + accounts: { + ops: { token: "d1" }, + chat: { token: "d2" }, + }, + }, + }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: false, + }, + { + name: "account moderation override inherits disabled top-level channels", + cfg: createDiscordModerationOverrideCfg(), + expectUploads: true, + expectChannelCreate: false, + expectModeration: true, + }, + { + name: "account override re-enables top-level disabled channels", + cfg: createDiscordModerationOverrideCfg({ channelsEnabled: true }), + expectUploads: true, + expectChannelCreate: true, + expectModeration: true, }, ] as const; - for (const channelConfig of cases) { - const cfg = channelConfig as unknown as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - expectModerationActions(actions); + for (const testCase of cases) { + const actions = discordMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + if (testCase.expectUploads) { + expect(actions, testCase.name).toContain("emoji-upload"); + expect(actions, testCase.name).toContain("sticker-upload"); + } + expectChannelCreateAction(actions, testCase.expectChannelCreate); + if (testCase.expectModeration) { + expectModerationActions(actions); + } else { + expect(actions, testCase.name).not.toContain("timeout"); + expect(actions, testCase.name).not.toContain("kick"); + expect(actions, testCase.name).not.toContain("ban"); + } } }); - - it("omits moderation when all accounts omit it", () => { - const cfg = { - channels: { - discord: { - accounts: { - ops: { token: "d1" }, - chat: { token: "d2" }, - }, - }, - }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - // moderation defaults to false, so without explicit true it stays hidden - expect(actions).not.toContain("timeout"); - expect(actions).not.toContain("kick"); - expect(actions).not.toContain("ban"); - }); - - it("inherits top-level channel gate when account overrides moderation only", () => { - const cfg = createDiscordModerationOverrideCfg(); - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("timeout"); - expectChannelCreateAction(actions, false); - }); - - it("allows account to explicitly re-enable top-level disabled channels", () => { - const cfg = createDiscordModerationOverrideCfg({ channelsEnabled: true }); - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("timeout"); - expectChannelCreateAction(actions, true); - }); }); describe("handleDiscordMessageAction", () => { From c4b866855a148b01251750f6367811cf2102ad7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:43:46 +0000 Subject: [PATCH 079/393] test: merge signal reaction mapping cases --- src/channels/plugins/actions/actions.test.ts | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 967b75a2f84..ca6110219df 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1108,6 +1108,18 @@ describe("signalMessageActions", () => { groupId: "group-id", targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", }, + toolContext: undefined, + }, + { + name: "falls back to toolContext.currentMessageId when messageId is omitted", + cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, + accountId: undefined, + params: { to: "+15559999999", emoji: "🔥" }, + expectedRecipient: "+15559999999", + expectedTimestamp: 1737630212345, + expectedEmoji: "🔥", + expectedOptions: {}, + toolContext: { currentMessageId: "1737630212345" }, }, ] as const; @@ -1116,6 +1128,7 @@ describe("signalMessageActions", () => { await runSignalAction("react", testCase.params, { cfg: testCase.cfg, accountId: testCase.accountId, + toolContext: testCase.toolContext, }); expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith( testCase.expectedRecipient, @@ -1129,22 +1142,6 @@ describe("signalMessageActions", () => { } }); - it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { - sendReactionSignal.mockClear(); - await runSignalAction( - "react", - { to: "+15559999999", emoji: "🔥" }, - { toolContext: { currentMessageId: "1737630212345" } }, - ); - expect(sendReactionSignal).toHaveBeenCalledTimes(1); - expect(sendReactionSignal).toHaveBeenCalledWith( - "+15559999999", - 1737630212345, - "🔥", - expect.objectContaining({}), - ); - }); - it("rejects invalid signal reaction inputs before dispatch", async () => { const cfg = { channels: { signal: { account: "+15550001111" } }, From 50c856978619b6cac4cc29e8603ad07f21f5a8d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:45:14 +0000 Subject: [PATCH 080/393] test: merge discord reaction id resolution cases --- src/channels/plugins/actions/actions.test.ts | 83 ++++++++++++-------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index ca6110219df..1b558336180 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -516,41 +516,58 @@ describe("handleDiscordMessageAction", () => { ); }); - it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { - await handleDiscordMessageAction({ - action: "react", - params: { - channelId: "123", - emoji: "ok", - }, - cfg: {} as OpenClawConfig, - toolContext: { currentMessageId: "9001" }, - }); - - const call = handleDiscordAction.mock.calls.at(-1); - expect(call?.[0]).toEqual( - expect.objectContaining({ - action: "react", - channelId: "123", - messageId: "9001", - emoji: "ok", - }), - ); - }); - - it("rejects reactions when neither messageId nor toolContext.currentMessageId is provided", async () => { - await expect( - handleDiscordMessageAction({ - action: "react", - params: { - channelId: "123", - emoji: "ok", + it("handles discord reaction messageId resolution", async () => { + const cases = [ + { + name: "falls back to toolContext.currentMessageId", + run: async () => { + await handleDiscordMessageAction({ + action: "react", + params: { + channelId: "123", + emoji: "ok", + }, + cfg: {} as OpenClawConfig, + toolContext: { currentMessageId: "9001" }, + }); }, - cfg: {} as OpenClawConfig, - }), - ).rejects.toThrow(/messageId required/i); + assert: () => { + const call = handleDiscordAction.mock.calls.at(-1); + expect(call?.[0]).toEqual( + expect.objectContaining({ + action: "react", + channelId: "123", + messageId: "9001", + emoji: "ok", + }), + ); + }, + }, + { + name: "rejects when no message id source is available", + run: async () => { + await expect( + handleDiscordMessageAction({ + action: "react", + params: { + channelId: "123", + emoji: "ok", + }, + cfg: {} as OpenClawConfig, + }), + ).rejects.toThrow(/messageId required/i); + }, + assert: () => { + expect(handleDiscordAction).not.toHaveBeenCalled(); + }, + }, + ] as const; - expect(handleDiscordAction).not.toHaveBeenCalled(); + for (const testCase of cases) { + handleDiscordAction.mockClear(); + await testCase.run(); + testCase.assert(); + } }); }); From 789730d1a3004d16a45d9a52c385d2818757a82a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:46:25 +0000 Subject: [PATCH 081/393] test: merge telegram reaction id cases --- src/channels/plugins/actions/actions.test.ts | 60 ++++++++------------ 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 1b558336180..57047a99976 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -986,16 +986,28 @@ describe("telegramMessageActions", () => { expectedChatId: "123", expectedMessageId: "9001", }, + { + name: "missing messageId soft-falls through to telegram-actions", + params: { + chatId: "123", + emoji: "ok", + }, + toolContext: undefined, + expectedChatId: "123", + expectedMessageId: undefined, + }, ] as const) { handleTelegramAction.mockClear(); - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: testCase.params, - cfg, - accountId: undefined, - toolContext: testCase.toolContext, - }); + await expect( + telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: testCase.params, + cfg, + accountId: undefined, + toolContext: testCase.toolContext, + }), + ).resolves.toBeDefined(); expect(handleTelegramAction, testCase.name).toHaveBeenCalledTimes(1); const call = handleTelegramAction.mock.calls[0]?.[0]; @@ -1005,35 +1017,13 @@ describe("telegramMessageActions", () => { const callPayload = call as Record; expect(callPayload.action, testCase.name).toBe("react"); expect(String(callPayload.chatId), testCase.name).toBe(testCase.expectedChatId); - expect(String(callPayload.messageId), testCase.name).toBe(testCase.expectedMessageId); + if (testCase.expectedMessageId === undefined) { + expect(callPayload.messageId, testCase.name).toBeUndefined(); + } else { + expect(String(callPayload.messageId), testCase.name).toBe(testCase.expectedMessageId); + } } }); - - it("forwards missing reaction messageId to telegram-actions for soft-fail handling", async () => { - const cfg = telegramCfg(); - - await expect( - telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: { - chatId: "123", - emoji: "ok", - }, - cfg, - accountId: undefined, - }), - ).resolves.toBeDefined(); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); - } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(callPayload.messageId).toBeUndefined(); - }); }); describe("signalMessageActions", () => { From 8cfcce0849c970ea755dde7dd42dfb3ed3e0b7cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:48:12 +0000 Subject: [PATCH 082/393] test: merge audit resolved inspection cases --- src/security/audit.test.ts | 432 +++++++++++++++++-------------------- 1 file changed, 202 insertions(+), 230 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 898cfb74535..935bccd3322 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2000,93 +2000,217 @@ description: test skill }); }); - it("keeps channel security findings when SecretRef credentials are configured but unavailable", async () => { - await withChannelSecurityStateDir(async () => { - const sourceConfig: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, + it("keeps source-configured channel security findings when resolved inspection is incomplete", async () => { + const cases = [ + { + name: "discord SecretRef configured but unavailable", + sourceConfig: { + channels: { + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, }, }, }, }, - }, - }; - const resolvedConfig: OpenClawConfig = { - channels: { - discord: { - enabled: true, - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, + } as OpenClawConfig, + resolvedConfig: { + channels: { + discord: { + enabled: true, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, }, }, }, }, - }, - }; - - const inspectableDiscordPlugin = stubChannelPlugin({ - id: "discord", - label: "Discord", - inspectAccount: (cfg) => { - const channel = cfg.channels?.discord ?? {}; - const token = channel.token; - return { - accountId: "default", - enabled: true, - configured: - Boolean(token) && - typeof token === "object" && - !Array.isArray(token) && - "source" in token, - token: "", - tokenSource: - Boolean(token) && - typeof token === "object" && - !Array.isArray(token) && - "source" in token - ? "config" - : "none", - tokenStatus: - Boolean(token) && - typeof token === "object" && - !Array.isArray(token) && - "source" in token - ? "configured_unavailable" - : "missing", - config: channel, - }; - }, - resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }), - isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), - }); - - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [inspectableDiscordPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.no_allowlists", - severity: "warn", + } as OpenClawConfig, + plugin: () => + stubChannelPlugin({ + id: "discord", + label: "Discord", + inspectAccount: (cfg) => { + const channel = cfg.channels?.discord ?? {}; + const token = channel.token; + return { + accountId: "default", + enabled: true, + configured: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token, + token: "", + tokenSource: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token + ? "config" + : "none", + tokenStatus: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token + ? "configured_unavailable" + : "missing", + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), }), - ]), - ); - }); + expectedCheckId: "channels.discord.commands.native.no_allowlists", + }, + { + name: "slack resolved inspection only exposes signingSecret status", + sourceConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + resolvedConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + plugin: (sourceConfig: OpenClawConfig) => + stubChannelPlugin({ + id: "slack", + label: "Slack", + inspectAccount: (cfg) => { + const channel = cfg.channels?.slack ?? {}; + if (cfg === sourceConfig) { + return { + accountId: "default", + enabled: false, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret + config: channel, + }; + } + return { + accountId: "default", + enabled: true, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "available", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "available", // pragma: allowlist secret + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }), + expectedCheckId: "channels.slack.commands.slash.no_allowlists", + }, + { + name: "slack source config still wins when resolved inspection is unconfigured", + sourceConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + resolvedConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + plugin: (sourceConfig: OpenClawConfig) => + stubChannelPlugin({ + id: "slack", + label: "Slack", + inspectAccount: (cfg) => { + const channel = cfg.channels?.slack ?? {}; + if (cfg === sourceConfig) { + return { + accountId: "default", + enabled: true, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret + config: channel, + }; + } + return { + accountId: "default", + enabled: true, + configured: false, + mode: "http", + botTokenSource: "config", + botTokenStatus: "available", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "missing", // pragma: allowlist secret + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }), + expectedCheckId: "channels.slack.commands.slash.no_allowlists", + }, + ] as const; + + for (const testCase of cases) { + await withChannelSecurityStateDir(async () => { + const res = await runSecurityAudit({ + config: testCase.resolvedConfig, + sourceConfig: testCase.sourceConfig, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [testCase.plugin(testCase.sourceConfig)], + }); + + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: testCase.expectedCheckId, + severity: "warn", + }), + ]), + ); + }); + } }); it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { @@ -2123,158 +2247,6 @@ description: test skill expect(finding?.detail).toContain("missing SecretRef"); }); - it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => { - await withChannelSecurityStateDir(async () => { - const sourceConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - const resolvedConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - - const inspectableSlackPlugin = stubChannelPlugin({ - id: "slack", - label: "Slack", - inspectAccount: (cfg) => { - const channel = cfg.channels?.slack ?? {}; - if (cfg === sourceConfig) { - return { - accountId: "default", - enabled: false, - configured: true, - mode: "http", - botTokenSource: "config", - botTokenStatus: "configured_unavailable", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "configured_unavailable", // pragma: allowlist secret - config: channel, - }; - } - return { - accountId: "default", - enabled: true, - configured: true, - mode: "http", - botTokenSource: "config", - botTokenStatus: "available", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "available", // pragma: allowlist secret - config: channel, - }; - }, - resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), - isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), - }); - - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [inspectableSlackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.no_allowlists", - severity: "warn", - }), - ]), - ); - }); - }); - - it("keeps source-configured Slack HTTP findings when resolved inspection is unconfigured", async () => { - await withChannelSecurityStateDir(async () => { - const sourceConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - const resolvedConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - - const inspectableSlackPlugin = stubChannelPlugin({ - id: "slack", - label: "Slack", - inspectAccount: (cfg) => { - const channel = cfg.channels?.slack ?? {}; - if (cfg === sourceConfig) { - return { - accountId: "default", - enabled: true, - configured: true, - mode: "http", - botTokenSource: "config", - botTokenStatus: "configured_unavailable", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "configured_unavailable", // pragma: allowlist secret - config: channel, - }; - } - return { - accountId: "default", - enabled: true, - configured: false, - mode: "http", - botTokenSource: "config", - botTokenStatus: "available", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "missing", // pragma: allowlist secret - config: channel, - }; - }, - resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), - isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), - }); - - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [inspectableSlackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.no_allowlists", - severity: "warn", - }), - ]), - ); - }); - }); - it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => { await withChannelSecurityStateDir(async () => { const cfg: OpenClawConfig = { From 610d836151b855804a907f877028c11d55291e7d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:49:07 +0000 Subject: [PATCH 083/393] test: merge audit gateway auth guardrail cases --- src/security/audit.test.ts | 77 ++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 935bccd3322..ade9a3cdd73 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -348,7 +348,7 @@ description: test skill expect(summary?.detail).toContain("trust model: personal assistant"); }); - it("evaluates non-loopback gateway auth presence", async () => { + it("evaluates gateway auth presence and rate-limit guardrails", async () => { const cases = [ { name: "flags non-loopback bind without auth as critical", @@ -432,6 +432,41 @@ description: test skill expectNoFinding(res, "gateway.bind_no_auth"); }, }, + { + name: "warns when auth has no rate limit", + run: async () => + audit( + { + gateway: { + bind: "lan", + auth: { token: "secret" }, + }, + }, + { env: {} }, + ), + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn")).toBe(true); + }, + }, + { + name: "does not warn when auth rate limit is configured", + run: async () => + audit( + { + gateway: { + bind: "lan", + auth: { + token: "secret", + rateLimit: { maxAttempts: 10, windowMs: 60_000, lockoutMs: 300_000 }, + }, + }, + }, + { env: {} }, + ), + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "gateway.auth_no_rate_limit"); + }, + }, ] as const; await Promise.all( @@ -442,46 +477,6 @@ description: test skill ); }); - it("evaluates gateway auth rate-limit warning based on configuration", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectWarn: boolean; - }> = [ - { - name: "no rate limit", - cfg: { - gateway: { - bind: "lan", - auth: { token: "secret" }, - }, - }, - expectWarn: true, - }, - { - name: "rate limit configured", - cfg: { - gateway: { - bind: "lan", - auth: { - token: "secret", - rateLimit: { maxAttempts: 10, windowMs: 60_000, lockoutMs: 300_000 }, - }, - }, - }, - expectWarn: false, - }, - ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg, { env: {} }); - expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn"), testCase.name).toBe( - testCase.expectWarn, - ); - }), - ); - }); - it("scores dangerous gateway.tools.allow over HTTP by exposure", async () => { const cases: Array<{ name: string; From c1733d700df037d7105f80c1741dc618e87968c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:50:09 +0000 Subject: [PATCH 084/393] test: merge audit sandbox docker danger cases --- src/security/audit.test.ts | 107 ++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index ade9a3cdd73..92320027434 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1150,66 +1150,65 @@ description: test skill ); }); - it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "all", - docker: { - binds: ["/etc/passwd:/mnt/passwd:ro", "/run:/run"], - network: "host", - seccompProfile: "unconfined", - apparmorProfile: "unconfined", + it("flags dangerous sandbox docker config", async () => { + const cases = [ + { + name: "dangerous binds, host network, seccomp, and apparmor", + cfg: { + agents: { + defaults: { + sandbox: { + mode: "all", + docker: { + binds: ["/etc/passwd:/mnt/passwd:ro", "/run:/run"], + network: "host", + seccompProfile: "unconfined", + apparmorProfile: "unconfined", + }, + }, }, }, - }, + } as OpenClawConfig, + expectedFindings: [ + { checkId: "sandbox.dangerous_bind_mount", severity: "critical" }, + { checkId: "sandbox.dangerous_network_mode", severity: "critical" }, + { checkId: "sandbox.dangerous_seccomp_profile", severity: "critical" }, + { checkId: "sandbox.dangerous_apparmor_profile", severity: "critical" }, + ], }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "sandbox.dangerous_bind_mount", severity: "critical" }), - expect.objectContaining({ - checkId: "sandbox.dangerous_network_mode", - severity: "critical", - }), - expect.objectContaining({ - checkId: "sandbox.dangerous_seccomp_profile", - severity: "critical", - }), - expect.objectContaining({ - checkId: "sandbox.dangerous_apparmor_profile", - severity: "critical", - }), - ]), - ); - }); - - it("flags container namespace join network mode in sandbox config", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "all", - docker: { - network: "container:peer", + { + name: "container namespace join network mode", + cfg: { + agents: { + defaults: { + sandbox: { + mode: "all", + docker: { + network: "container:peer", + }, + }, }, }, - }, + } as OpenClawConfig, + expectedFindings: [ + { + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + title: "Dangerous network mode in sandbox config", + }, + ], }, - }; - const res = await audit(cfg); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "sandbox.dangerous_network_mode", - severity: "critical", - title: "Dangerous network mode in sandbox config", - }), - ]), + ] as const; + + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining( + testCase.expectedFindings.map((finding) => expect.objectContaining(finding)), + ), + ); + }), ); }); From 23a3211c2924480f3ec9ab870d1092214c97fe99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:51:18 +0000 Subject: [PATCH 085/393] test: merge audit discord allowlist cases --- src/security/audit.test.ts | 122 +++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 66 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 92320027434..9bece264378 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1957,41 +1957,68 @@ description: test skill ); }); - it("flags Discord native commands without a guild user allowlist", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, + it("evaluates Discord native command allowlist findings", async () => { + const cases = [ + { + name: "flags missing guild user allowlists", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, }, }, }, }, - }, - }; + } as OpenClawConfig, + expectFinding: true, + }, + { + name: "does not flag when dm.allowFrom includes a Discord snowflake id", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + dm: { allowFrom: ["387380367612706819"] }, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + expectFinding: false, + }, + ] as const; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], + for (const testCase of cases) { + await withChannelSecurityStateDir(async () => { + const res = await runSecurityAudit({ + config: testCase.cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [discordPlugin], + }); + + expect( + res.findings.some( + (finding) => finding.checkId === "channels.discord.commands.native.no_allowlists", + ), + testCase.name, + ).toBe(testCase.expectFinding); }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.no_allowlists", - severity: "warn", - }), - ]), - ); - }); + } }); it("keeps source-configured channel security findings when resolved inspection is incomplete", async () => { @@ -2241,43 +2268,6 @@ description: test skill expect(finding?.detail).toContain("missing SecretRef"); }); - it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - dm: { allowFrom: ["387380367612706819"] }, - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, - }, - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.no_allowlists", - }), - ]), - ); - }); - }); - it.each([ { name: "warns when Discord allowlists contain name-based entries", From 9b7aafa141e3c96f4c8d87da255513d31d33f02d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:52:21 +0000 Subject: [PATCH 086/393] test: merge audit sandbox docker config cases --- src/security/audit.test.ts | 45 ++++++++++++++------------------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 9bece264378..dfd60ebda69 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1104,12 +1104,8 @@ description: test skill ); }); - it("checks sandbox docker mode-off findings with/without agent override", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedPresent: boolean; - }> = [ + it("evaluates sandbox docker config findings", async () => { + const cases = [ { name: "mode off with docker config only", cfg: { @@ -1121,8 +1117,8 @@ description: test skill }, }, }, - }, - expectedPresent: true, + } as OpenClawConfig, + expectedFindings: [{ checkId: "sandbox.docker_config_mode_off" }], }, { name: "agent enables sandbox mode", @@ -1136,22 +1132,10 @@ description: test skill }, list: [{ id: "ops", sandbox: { mode: "all" } }], }, - }, - expectedPresent: false, + } as OpenClawConfig, + expectedFindings: [], + expectedAbsent: ["sandbox.docker_config_mode_off"], }, - ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect(hasFinding(res, "sandbox.docker_config_mode_off"), testCase.name).toBe( - testCase.expectedPresent, - ); - }), - ); - }); - - it("flags dangerous sandbox docker config", async () => { - const cases = [ { name: "dangerous binds, host network, seccomp, and apparmor", cfg: { @@ -1203,11 +1187,16 @@ description: test skill await Promise.all( cases.map(async (testCase) => { const res = await audit(testCase.cfg); - expect(res.findings, testCase.name).toEqual( - expect.arrayContaining( - testCase.expectedFindings.map((finding) => expect.objectContaining(finding)), - ), - ); + if (testCase.expectedFindings.length > 0) { + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining( + testCase.expectedFindings.map((finding) => expect.objectContaining(finding)), + ), + ); + } + for (const checkId of testCase.expectedAbsent ?? []) { + expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); + } }), ); }); From 9e087f66bebef2850fb7891389f5578c3f0cd5b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:54:06 +0000 Subject: [PATCH 087/393] test: merge audit browser sandbox cases --- src/security/audit.test.ts | 264 +++++++++++++++++++------------------ 1 file changed, 136 insertions(+), 128 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index dfd60ebda69..d4bf15f1264 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -843,39 +843,51 @@ description: test skill ); }); - it("evaluates sandbox browser container findings", async () => { + it("evaluates sandbox browser findings", async () => { const cases = [ { name: "warns when sandbox browser containers have missing or stale hash labels", - fixtureLabel: "browser-hash-labels", - execDockerRawFn: (async (args: string[]) => { - if (args[0] === "ps") { - return { - stdout: Buffer.from("openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") { - return { - stdout: Buffer.from("abc123\tepoch-v0\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") { - return { - stdout: Buffer.from("\t\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.from("not found"), - code: 1, - }; - }) as NonNullable, + run: async () => { + const { stateDir, configPath } = + await createFilesystemAuditFixture("browser-hash-labels"); + return runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn: (async (args: string[]) => { + if (args[0] === "ps") { + return { + stdout: Buffer.from( + "openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n", + ), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") { + return { + stdout: Buffer.from("abc123\tepoch-v0\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") { + return { + stdout: Buffer.from("\t\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable, + }); + }, assert: (res: SecurityAuditReport) => { expect(hasFinding(res, "sandbox.browser_container.hash_label_missing", "warn")).toBe( true, @@ -889,10 +901,21 @@ description: test skill }, { name: "skips sandbox browser hash label checks when docker inspect is unavailable", - fixtureLabel: "browser-hash-labels-skip", - execDockerRawFn: (async () => { - throw new Error("spawn docker ENOENT"); - }) as NonNullable, + run: async () => { + const { stateDir, configPath } = await createFilesystemAuditFixture( + "browser-hash-labels-skip", + ); + return runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn: (async () => { + throw new Error("spawn docker ENOENT"); + }) as NonNullable, + }); + }, assert: (res: SecurityAuditReport) => { expect(hasFinding(res, "sandbox.browser_container.hash_label_missing")).toBe(false); expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale")).toBe(false); @@ -900,54 +923,95 @@ description: test skill }, { name: "flags sandbox browser containers with non-loopback published ports", - fixtureLabel: "browser-non-loopback-publish", - execDockerRawFn: (async (args: string[]) => { - if (args[0] === "ps") { - return { - stdout: Buffer.from("openclaw-sbx-browser-exposed\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-exposed") { - return { - stdout: Buffer.from("hash123\t2026-02-21-novnc-auth-default\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "port" && args.at(-1) === "openclaw-sbx-browser-exposed") { - return { - stdout: Buffer.from("6080/tcp -> 0.0.0.0:49101\n9222/tcp -> 127.0.0.1:49100\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.from("not found"), - code: 1, - }; - }) as NonNullable, + run: async () => { + const { stateDir, configPath } = await createFilesystemAuditFixture( + "browser-non-loopback-publish", + ); + return runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn: (async (args: string[]) => { + if (args[0] === "ps") { + return { + stdout: Buffer.from("openclaw-sbx-browser-exposed\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-exposed") { + return { + stdout: Buffer.from("hash123\t2026-02-21-novnc-auth-default\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "port" && args.at(-1) === "openclaw-sbx-browser-exposed") { + return { + stdout: Buffer.from("6080/tcp -> 0.0.0.0:49101\n9222/tcp -> 127.0.0.1:49100\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable, + }); + }, assert: (res: SecurityAuditReport) => { expect( hasFinding(res, "sandbox.browser_container.non_loopback_publish", "critical"), ).toBe(true); }, }, + { + name: "warns when bridge network omits cdpSourceRange", + run: async () => + audit({ + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { enabled: true, network: "bridge" }, + }, + }, + }, + }), + assert: (res: SecurityAuditReport) => { + const finding = res.findings.find( + (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain("agents.defaults.sandbox.browser"); + }, + }, + { + name: "does not warn for dedicated default browser network", + run: async () => + audit({ + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { enabled: true }, + }, + }, + }, + }), + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "sandbox.browser_cdp_bridge_unrestricted")).toBe(false); + }, + }, ] as const; await Promise.all( cases.map(async (testCase) => { - const { stateDir, configPath } = await createFilesystemAuditFixture(testCase.fixtureLabel); - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn: testCase.execDockerRawFn, - }); + const res = await testCase.run(); testCase.assert(res); }), ); @@ -1201,62 +1265,6 @@ description: test skill ); }); - it("checks sandbox browser bridge-network restrictions", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedPresent: boolean; - expectedSeverity?: "warn"; - detailIncludes?: string; - }> = [ - { - name: "bridge without cdpSourceRange", - cfg: { - agents: { - defaults: { - sandbox: { - mode: "all", - browser: { enabled: true, network: "bridge" }, - }, - }, - }, - }, - expectedPresent: true, - expectedSeverity: "warn", - detailIncludes: "agents.defaults.sandbox.browser", - }, - { - name: "dedicated default network", - cfg: { - agents: { - defaults: { - sandbox: { - mode: "all", - browser: { enabled: true }, - }, - }, - }, - }, - expectedPresent: false, - }, - ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - const finding = res.findings.find( - (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", - ); - expect(Boolean(finding), testCase.name).toBe(testCase.expectedPresent); - if (testCase.expectedPresent) { - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); - if (testCase.detailIncludes) { - expect(finding?.detail, testCase.name).toContain(testCase.detailIncludes); - } - } - }), - ); - }); - it("evaluates ineffective gateway.nodes.denyCommands entries", async () => { const cases = [ { From 78666551762e17d23f9ca843e8c33f45ac9ac5c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:55:12 +0000 Subject: [PATCH 088/393] test: merge audit allowCommands cases --- src/security/audit.test.ts | 49 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index d4bf15f1264..2118b3727dc 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1320,12 +1320,8 @@ description: test skill ); }); - it("scores dangerous gateway.nodes.allowCommands by exposure", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - }> = [ + it("evaluates dangerous gateway.nodes.allowCommands findings", async () => { + const cases = [ { name: "loopback gateway", cfg: { @@ -1333,8 +1329,8 @@ description: test skill bind: "loopback", nodes: { allowCommands: ["camera.snap", "screen.record"] }, }, - }, - expectedSeverity: "warn", + } as OpenClawConfig, + expectedSeverity: "warn" as const, }, { name: "lan-exposed gateway", @@ -1343,14 +1339,31 @@ description: test skill bind: "lan", nodes: { allowCommands: ["camera.snap", "screen.record"] }, }, - }, - expectedSeverity: "critical", + } as OpenClawConfig, + expectedSeverity: "critical" as const, }, - ]; + { + name: "denied again suppresses dangerous allowCommands finding", + cfg: { + gateway: { + nodes: { + allowCommands: ["camera.snap", "screen.record"], + denyCommands: ["camera.snap", "screen.record"], + }, + }, + } as OpenClawConfig, + expectedAbsent: true, + }, + ] as const; await Promise.all( cases.map(async (testCase) => { const res = await audit(testCase.cfg); + if (testCase.expectedAbsent) { + expectNoFinding(res, "gateway.nodes.allow_commands_dangerous"); + return; + } + const finding = res.findings.find( (f) => f.checkId === "gateway.nodes.allow_commands_dangerous", ); @@ -1361,20 +1374,6 @@ description: test skill ); }); - it("does not flag dangerous allowCommands entries when denied again", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - allowCommands: ["camera.snap", "screen.record"], - denyCommands: ["camera.snap", "screen.record"], - }, - }, - }; - - const res = await audit(cfg); - expectNoFinding(res, "gateway.nodes.allow_commands_dangerous"); - }); - it("flags agent profile overrides when global tools.profile is minimal", async () => { const cfg: OpenClawConfig = { tools: { From ef53926542d125b3eea9f1f2dd74b40cfbe5df01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:56:19 +0000 Subject: [PATCH 089/393] test: merge audit install metadata cases --- src/security/audit.test.ts | 239 +++++++++++++++++++------------------ 1 file changed, 126 insertions(+), 113 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 2118b3727dc..f7d3cb24f60 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3000,124 +3000,137 @@ description: test skill ); }); - it.each([ - { - name: "warns on unpinned npm install specs and missing integrity metadata", - cfg: { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call", - }, - }, - }, - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks", + it("evaluates install metadata findings", async () => { + const cases = [ + { + name: "warns on unpinned npm install specs and missing integrity metadata", + run: async () => + runInstallMetadataAudit( + { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks", + }, + }, + }, + }, + } satisfies OpenClawConfig, + sharedInstallMetadataStateDir, + ), + expectedPresent: [ + "plugins.installs_unpinned_npm_specs", + "plugins.installs_missing_integrity", + "hooks.installs_unpinned_npm_specs", + "hooks.installs_missing_integrity", + ], + }, + { + name: "does not warn on pinned npm install specs with integrity metadata", + run: async () => + runInstallMetadataAudit( + { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.2.3", + integrity: "sha512-plugin", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + }, + }, + }, + }, + } satisfies OpenClawConfig, + sharedInstallMetadataStateDir, + ), + expectedAbsent: [ + "plugins.installs_unpinned_npm_specs", + "plugins.installs_missing_integrity", + "hooks.installs_unpinned_npm_specs", + "hooks.installs_missing_integrity", + ], + }, + { + name: "warns when install records drift from installed package versions", + run: async () => { + const tmp = await makeTmpDir("install-version-drift"); + const stateDir = path.join(tmp, "state"); + const pluginDir = path.join(stateDir, "extensions", "voice-call"); + const hookDir = path.join(stateDir, "hooks", "test-hooks"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(hookDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }), + "utf-8", + ); + await fs.writeFile( + path.join(hookDir, "package.json"), + JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }), + "utf-8", + ); + + return runInstallMetadataAudit( + { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.2.3", + integrity: "sha512-plugin", + resolvedVersion: "1.2.3", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + resolvedVersion: "1.2.3", + }, + }, + }, }, }, - }, - }, - } satisfies OpenClawConfig, - expectedPresent: [ - "plugins.installs_unpinned_npm_specs", - "plugins.installs_missing_integrity", - "hooks.installs_unpinned_npm_specs", - "hooks.installs_missing_integrity", - ], - }, - { - name: "does not warn on pinned npm install specs with integrity metadata", - cfg: { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call@1.2.3", - integrity: "sha512-plugin", - }, - }, - }, - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", - }, - }, - }, - }, - } satisfies OpenClawConfig, - expectedAbsent: [ - "plugins.installs_unpinned_npm_specs", - "plugins.installs_missing_integrity", - "hooks.installs_unpinned_npm_specs", - "hooks.installs_missing_integrity", - ], - }, - ])("$name", async (testCase) => { - const res = await runInstallMetadataAudit(testCase.cfg, sharedInstallMetadataStateDir); - for (const checkId of testCase.expectedPresent ?? []) { - expect(hasFinding(res, checkId, "warn"), checkId).toBe(true); - } - for (const checkId of testCase.expectedAbsent ?? []) { - expect(hasFinding(res, checkId), checkId).toBe(false); - } - }); - - it("warns when install records drift from installed package versions", async () => { - const tmp = await makeTmpDir("install-version-drift"); - const stateDir = path.join(tmp, "state"); - const pluginDir = path.join(stateDir, "extensions", "voice-call"); - const hookDir = path.join(stateDir, "hooks", "test-hooks"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.mkdir(hookDir, { recursive: true }); - await fs.writeFile( - path.join(pluginDir, "package.json"), - JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }), - "utf-8", - ); - await fs.writeFile( - path.join(hookDir, "package.json"), - JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }), - "utf-8", - ); - - const cfg: OpenClawConfig = { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call@1.2.3", - integrity: "sha512-plugin", - resolvedVersion: "1.2.3", - }, + stateDir, + ); }, + expectedPresent: ["plugins.installs_version_drift", "hooks.installs_version_drift"], }, - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", - resolvedVersion: "1.2.3", - }, - }, - }, - }, - }; + ] as const; - const res = await runInstallMetadataAudit(cfg, stateDir); - - expect(hasFinding(res, "plugins.installs_version_drift", "warn")).toBe(true); - expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true); + for (const testCase of cases) { + const res = await testCase.run(); + for (const checkId of testCase.expectedPresent ?? []) { + expect(hasFinding(res, checkId, "warn"), `${testCase.name}:${checkId}`).toBe(true); + } + for (const checkId of testCase.expectedAbsent ?? []) { + expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); + } + } }); it("evaluates extension tool reachability findings", async () => { From 58c26ad706116cf18bb20609a521e88080fc5415 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:57:59 +0000 Subject: [PATCH 090/393] test: merge audit code safety cases --- src/security/audit.test.ts | 157 +++++++++++++++++++++---------------- 1 file changed, 89 insertions(+), 68 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index f7d3cb24f60..9746ef0792a 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3240,65 +3240,103 @@ description: test skill ); }); - it("does not scan plugin code safety findings when deep audit is disabled", async () => { - const cfg: OpenClawConfig = {}; - const nonDeepRes = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - deep: false, - stateDir: sharedCodeSafetyStateDir, - execDockerRawFn: execDockerRawUnavailable, - }); - expect(nonDeepRes.findings.some((f) => f.checkId === "plugins.code_safety")).toBe(false); - - // Deep-mode positive coverage lives in the detailed plugin+skills code-safety test below. - }); - - it("reports detailed code-safety issues for both plugins and skills", async () => { - const cfg: OpenClawConfig = { - agents: { defaults: { workspace: sharedCodeSafetyWorkspaceDir } }, - }; - const [pluginFindings, skillFindings] = await Promise.all([ - collectPluginsCodeSafetyFindings({ stateDir: sharedCodeSafetyStateDir }), - collectInstalledSkillsCodeSafetyFindings({ cfg, stateDir: sharedCodeSafetyStateDir }), - ]); - - const pluginFinding = pluginFindings.find( - (finding) => finding.checkId === "plugins.code_safety" && finding.severity === "critical", - ); - expect(pluginFinding).toBeDefined(); - expect(pluginFinding?.detail).toContain("dangerous-exec"); - expect(pluginFinding?.detail).toMatch(/\.hidden[\\/]+index\.js:\d+/); - - const skillFinding = skillFindings.find( - (finding) => finding.checkId === "skills.code_safety" && finding.severity === "critical", - ); - expect(skillFinding).toBeDefined(); - expect(skillFinding?.detail).toContain("dangerous-exec"); - expect(skillFinding?.detail).toMatch(/runner\.js:\d+/); - }); - - it("evaluates plugin code-safety scanner failure modes", async () => { + it("evaluates code-safety findings", async () => { const cases = [ + { + name: "does not scan plugin code safety findings when deep audit is disabled", + run: async () => + runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + deep: false, + stateDir: sharedCodeSafetyStateDir, + execDockerRawFn: execDockerRawUnavailable, + }), + assert: (result: SecurityAuditReport) => { + expect(result.findings.some((f) => f.checkId === "plugins.code_safety")).toBe(false); + }, + }, + { + name: "reports detailed code-safety issues for both plugins and skills", + run: async () => { + const cfg: OpenClawConfig = { + agents: { defaults: { workspace: sharedCodeSafetyWorkspaceDir } }, + }; + const [pluginFindings, skillFindings] = await Promise.all([ + collectPluginsCodeSafetyFindings({ stateDir: sharedCodeSafetyStateDir }), + collectInstalledSkillsCodeSafetyFindings({ cfg, stateDir: sharedCodeSafetyStateDir }), + ]); + return { pluginFindings, skillFindings }; + }, + assert: ( + result: Awaited> extends never + ? never + : { + pluginFindings: Awaited>; + skillFindings: Awaited>; + }, + ) => { + const pluginFinding = result.pluginFindings.find( + (finding) => + finding.checkId === "plugins.code_safety" && finding.severity === "critical", + ); + expect(pluginFinding).toBeDefined(); + expect(pluginFinding?.detail).toContain("dangerous-exec"); + expect(pluginFinding?.detail).toMatch(/\.hidden[\\/]+index\.js:\d+/); + + const skillFinding = result.skillFindings.find( + (finding) => + finding.checkId === "skills.code_safety" && finding.severity === "critical", + ); + expect(skillFinding).toBeDefined(); + expect(skillFinding?.detail).toContain("dangerous-exec"); + expect(skillFinding?.detail).toMatch(/runner\.js:\d+/); + }, + }, { name: "flags plugin extension entry path traversal in deep audit", - label: "audit-scanner-escape", - pluginName: "escape-plugin", - extensions: ["../outside.js"], + run: async () => { + const tmpDir = await makeTmpDir("audit-scanner-escape"); + const pluginDir = path.join(tmpDir, "extensions", "escape-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "escape-plugin", + openclaw: { extensions: ["../outside.js"] }, + }), + ); + await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); + return collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); + }, assert: (findings: Awaited>) => { expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); }, }, { name: "reports scan_failed when plugin code scanner throws during deep audit", - label: "audit-scanner-throws", - pluginName: "scanfail-plugin", - extensions: ["index.js"], - beforeRun: () => - vi + run: async () => { + const scanSpy = vi .spyOn(skillScanner, "scanDirectoryWithSummary") - .mockRejectedValueOnce(new Error("boom")), + .mockRejectedValueOnce(new Error("boom")); + try { + const tmpDir = await makeTmpDir("audit-scanner-throws"); + const pluginDir = path.join(tmpDir, "extensions", "scanfail-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "scanfail-plugin", + openclaw: { extensions: ["index.js"] }, + }), + ); + await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); + return await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); + } finally { + scanSpy.mockRestore(); + } + }, assert: (findings: Awaited>) => { expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true); }, @@ -3306,25 +3344,8 @@ description: test skill ] as const; for (const testCase of cases) { - const scanSpy = testCase.beforeRun?.(); - try { - const tmpDir = await makeTmpDir(testCase.label); - const pluginDir = path.join(tmpDir, "extensions", testCase.pluginName); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: testCase.pluginName, - openclaw: { extensions: testCase.extensions }, - }), - ); - await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); - - const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); - testCase.assert(findings); - } finally { - scanSpy?.mockRestore(); - } + const result = await testCase.run(); + testCase.assert(result as never); } }); From 141d73ddf4523ef224e39658f9f10a977657a40a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:59:12 +0000 Subject: [PATCH 091/393] test: merge audit dangerous flag cases --- src/security/audit.test.ts | 122 ++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 9746ef0792a..5b11b6398a8 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1497,71 +1497,71 @@ description: test skill } }); - it.each([ - { - name: "warns when control UI allows insecure auth", - cfg: { - gateway: { - controlUi: { allowInsecureAuth: true }, - }, - } satisfies OpenClawConfig, - expectedFinding: { - checkId: "gateway.control_ui.insecure_auth", - severity: "warn", - }, - expectedDangerousFlag: "gateway.controlUi.allowInsecureAuth=true", - }, - { - name: "warns when control UI device auth is disabled", - cfg: { - gateway: { - controlUi: { dangerouslyDisableDeviceAuth: true }, - }, - } satisfies OpenClawConfig, - expectedFinding: { - checkId: "gateway.control_ui.device_auth_disabled", - severity: "critical", - }, - expectedDangerousFlag: "gateway.controlUi.dangerouslyDisableDeviceAuth=true", - }, - ])("$name", async (testCase) => { - const res = await audit(testCase.cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining(testCase.expectedFinding), - expect.objectContaining({ - checkId: "config.insecure_or_dangerous_flags", - severity: "warn", - detail: expect.stringContaining(testCase.expectedDangerousFlag), - }), - ]), - ); - }); - - it("warns when insecure/dangerous debug flags are enabled", async () => { - const cfg: OpenClawConfig = { - hooks: { - gmail: { allowUnsafeExternalContent: true }, - mappings: [{ allowUnsafeExternalContent: true }], - }, - tools: { - exec: { - applyPatch: { - workspaceOnly: false, + it("warns on insecure or dangerous flags", async () => { + const cases = [ + { + name: "control UI allows insecure auth", + cfg: { + gateway: { + controlUi: { allowInsecureAuth: true }, }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.insecure_auth", + severity: "warn", }, + expectedDangerousDetails: ["gateway.controlUi.allowInsecureAuth=true"], }, - }; + { + name: "control UI device auth is disabled", + cfg: { + gateway: { + controlUi: { dangerouslyDisableDeviceAuth: true }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.device_auth_disabled", + severity: "critical", + }, + expectedDangerousDetails: ["gateway.controlUi.dangerouslyDisableDeviceAuth=true"], + }, + { + name: "generic insecure debug flags", + cfg: { + hooks: { + gmail: { allowUnsafeExternalContent: true }, + mappings: [{ allowUnsafeExternalContent: true }], + }, + tools: { + exec: { + applyPatch: { + workspaceOnly: false, + }, + }, + }, + } satisfies OpenClawConfig, + expectedDangerousDetails: [ + "hooks.gmail.allowUnsafeExternalContent=true", + "hooks.mappings[0].allowUnsafeExternalContent=true", + "tools.exec.applyPatch.workspaceOnly=false", + ], + }, + ] as const; - const res = await audit(cfg); - const finding = res.findings.find((f) => f.checkId === "config.insecure_or_dangerous_flags"); - - expect(finding).toBeTruthy(); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("hooks.gmail.allowUnsafeExternalContent=true"); - expect(finding?.detail).toContain("hooks.mappings[0].allowUnsafeExternalContent=true"); - expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false"); + for (const testCase of cases) { + const res = await audit(testCase.cfg); + if (testCase.expectedFinding) { + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + } + const finding = res.findings.find((f) => f.checkId === "config.insecure_or_dangerous_flags"); + expect(finding, testCase.name).toBeTruthy(); + expect(finding?.severity, testCase.name).toBe("warn"); + for (const detail of testCase.expectedDangerousDetails) { + expect(finding?.detail, `${testCase.name}:${detail}`).toContain(detail); + } + } }); it.each([ From 63997aec23878cd55a368eb57fa2b815736d64b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:00:19 +0000 Subject: [PATCH 092/393] test: merge audit trust exposure cases --- src/security/audit.test.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 5b11b6398a8..3ab907ab9a2 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3349,7 +3349,7 @@ description: test skill } }); - it("evaluates open-group exposure findings", async () => { + it("evaluates trust-model exposure findings", async () => { const cases = [ { name: "flags open groupPolicy when tools.elevated is enabled", @@ -3426,18 +3426,6 @@ description: test skill ).toBe(false); }, }, - ] as const; - - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - testCase.assert(res); - }), - ); - }); - - it("evaluates multi-user trust-model heuristics", async () => { - const cases = [ { name: "warns when config heuristics suggest a likely multi-user setup", cfg: { From 6646ca61cc454e4275e73645dba5a0efb7d85b6a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:01:19 +0000 Subject: [PATCH 093/393] test: merge audit channel command hygiene cases --- src/security/audit.test.ts | 41 +++++++++++++------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 3ab907ab9a2..1e1db1b6d8f 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2625,19 +2625,9 @@ description: test skill severity: "critical", }, }, - ])("$name", async (testCase) => { - await withChannelSecurityStateDir(async () => { - const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); - - expect(res.findings).toEqual( - expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), - ); - }); - }); - - it("warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + { + name: "warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", + cfg: { channels: { telegram: { enabled: true, @@ -2647,22 +2637,19 @@ description: test skill groups: { "-100123": {} }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [telegramPlugin], - }); + } satisfies OpenClawConfig, + plugins: [telegramPlugin], + expectedFinding: { + checkId: "channels.telegram.allowFrom.invalid_entries", + severity: "warn", + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.telegram.allowFrom.invalid_entries", - severity: "warn", - }), - ]), + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), ); }); }); From 4a95e6529f82a2c56f5b2cf2d8be3e5019740824 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:02:34 +0000 Subject: [PATCH 094/393] test: merge slack validation cases --- src/channels/plugins/actions/actions.test.ts | 48 ++++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 57047a99976..36c73ad7a2d 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1281,10 +1281,11 @@ describe("slack actions adapter", () => { } }); - it("rejects invalid send block combinations before dispatch", async () => { + it("rejects invalid Slack payloads before dispatch", async () => { const cases = [ { name: "invalid JSON", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1294,6 +1295,7 @@ describe("slack actions adapter", () => { }, { name: "empty blocks", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1303,6 +1305,7 @@ describe("slack actions adapter", () => { }, { name: "blocks with media", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1311,29 +1314,34 @@ describe("slack actions adapter", () => { }, error: /does not support blocks with media/i, }, - ] as const; - - for (const testCase of cases) { - handleSlackAction.mockClear(); - await expectSlackSendRejected(testCase.params, testCase.error); - } - }); - - it("rejects edit when both message and blocks are missing", async () => { - const { cfg, actions } = slackHarness(); - - await expect( - actions.handleAction?.({ - channel: "slack", - action: "edit", - cfg, + { + name: "edit missing message and blocks", + action: "edit" as const, params: { channelId: "C1", messageId: "171234.567", message: "", }, - }), - ).rejects.toThrow(/edit requires message or blocks/i); - expect(handleSlackAction).not.toHaveBeenCalled(); + error: /edit requires message or blocks/i, + }, + ] as const; + + for (const testCase of cases) { + handleSlackAction.mockClear(); + if (testCase.action === "send") { + await expectSlackSendRejected(testCase.params, testCase.error); + } else { + const { cfg, actions } = slackHarness(); + await expect( + actions.handleAction?.({ + channel: "slack", + action: "edit", + cfg, + params: testCase.params, + }), + ).rejects.toThrow(testCase.error); + } + expect(handleSlackAction, testCase.name).not.toHaveBeenCalled(); + } }); }); From 9e29511316845c1c9d71acc9a9b2cde4dc41430d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:03:59 +0000 Subject: [PATCH 095/393] test: merge update cli dry run cases --- src/cli/update-cli.test.ts | 63 ++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index ed934ce9ea6..e65f7ef83be 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -376,21 +376,48 @@ describe("update-cli", () => { setStdoutTty(false); }); - it("updateCommand --dry-run previews without mutating", async () => { - vi.mocked(defaultRuntime.log).mockClear(); - serviceLoaded.mockResolvedValue(true); + it("updateCommand dry-run previews without mutating and bypasses downgrade confirmation", async () => { + const cases = [ + { + name: "preview mode", + run: async () => { + vi.mocked(defaultRuntime.log).mockClear(); + serviceLoaded.mockResolvedValue(true); + await updateCommand({ dryRun: true, channel: "beta" }); + }, + assert: () => { + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); - await updateCommand({ dryRun: true, channel: "beta" }); + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.join("\n")).toContain("Update dry-run"); + expect(logs.join("\n")).toContain("No changes were applied."); + }, + }, + { + name: "downgrade bypass", + run: async () => { + await setupNonInteractiveDowngrade(); + vi.mocked(defaultRuntime.exit).mockClear(); + await updateCommand({ dryRun: true }); + }, + assert: () => { + expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe( + false, + ); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + }, + }, + ] as const; - expect(writeConfigFile).not.toHaveBeenCalled(); - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).not.toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - - const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logs.join("\n")).toContain("Update dry-run"); - expect(logs.join("\n")).toContain("No changes were applied."); + for (const testCase of cases) { + vi.clearAllMocks(); + await testCase.run(); + testCase.assert(); + } }); it("updateStatusCommand renders table and json output", async () => { @@ -921,16 +948,6 @@ describe("update-cli", () => { ).toBe(shouldRunPackageUpdate); }); - it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => { - await setupNonInteractiveDowngrade(); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateCommand({ dryRun: true }); - - expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false); - expect(runGatewayUpdate).not.toHaveBeenCalled(); - }); - it("updateWizardCommand requires a TTY", async () => { setTty(false); vi.mocked(defaultRuntime.error).mockClear(); From c6726354138bacebd9ccb9963cb15291eae69c49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:05:08 +0000 Subject: [PATCH 096/393] test: merge update cli outcome cases --- src/cli/update-cli.test.ts | 77 ++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index e65f7ef83be..1b17a9e1cce 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -628,39 +628,52 @@ describe("update-cli", () => { expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1"); }); - it("updateCommand outputs JSON when --json is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - vi.mocked(defaultRuntime.log).mockClear(); + it("updateCommand reports success and failure outcomes", async () => { + const cases = [ + { + name: "outputs JSON when --json is set", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + vi.mocked(defaultRuntime.log).mockClear(); + await updateCommand({ json: true }); + }, + assert: () => { + const logCalls = vi.mocked(defaultRuntime.log).mock.calls; + const jsonOutput = logCalls.find((call) => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + }, + }, + { + name: "exits with error on failure", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "error", + mode: "git", + reason: "rebase-failed", + steps: [], + durationMs: 100, + } satisfies UpdateRunResult); + vi.mocked(defaultRuntime.exit).mockClear(); + await updateCommand({}); + }, + assert: () => { + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }, + }, + ] as const; - await updateCommand({ json: true }); - - const logCalls = vi.mocked(defaultRuntime.log).mock.calls; - const jsonOutput = logCalls.find((call) => { - try { - JSON.parse(call[0] as string); - return true; - } catch { - return false; - } - }); - expect(jsonOutput).toBeDefined(); - }); - - it("updateCommand exits with error on failure", async () => { - const mockResult: UpdateRunResult = { - status: "error", - mode: "git", - reason: "rebase-failed", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateCommand({}); - - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + for (const testCase of cases) { + vi.clearAllMocks(); + await testCase.run(); + testCase.assert(); + } }); it("updateCommand handles service env refresh and restart behavior", async () => { From 31d739fda2d79093bb26f10991668fd0e5380dd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:06:26 +0000 Subject: [PATCH 097/393] test: merge update cli validation cases --- src/cli/update-cli.test.ts | 74 +++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 1b17a9e1cce..e29a5c1ea61 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -890,31 +890,46 @@ describe("update-cli", () => { expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false); }); - it.each([ - { - name: "update command", - run: async () => await updateCommand({ timeout: "invalid" }), - requireTty: false, - }, - { - name: "update status command", - run: async () => await updateStatusCommand({ timeout: "invalid" }), - requireTty: false, - }, - { - name: "update wizard command", - run: async () => await updateWizardCommand({ timeout: "invalid" }), - requireTty: true, - }, - ])("validates timeout option for $name", async ({ run, requireTty }) => { - setTty(requireTty); - vi.mocked(defaultRuntime.error).mockClear(); - vi.mocked(defaultRuntime.exit).mockClear(); + it("validates update command invocation errors", async () => { + const cases = [ + { + name: "update command invalid timeout", + run: async () => await updateCommand({ timeout: "invalid" }), + requireTty: false, + expectedError: "timeout", + }, + { + name: "update status command invalid timeout", + run: async () => await updateStatusCommand({ timeout: "invalid" }), + requireTty: false, + expectedError: "timeout", + }, + { + name: "update wizard invalid timeout", + run: async () => await updateWizardCommand({ timeout: "invalid" }), + requireTty: true, + expectedError: "timeout", + }, + { + name: "update wizard requires a TTY", + run: async () => await updateWizardCommand({}), + requireTty: false, + expectedError: "Update wizard requires a TTY", + }, + ] as const; - await run(); + for (const testCase of cases) { + setTty(testCase.requireTty); + vi.mocked(defaultRuntime.error).mockClear(); + vi.mocked(defaultRuntime.exit).mockClear(); - expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout")); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + await testCase.run(); + + expect(defaultRuntime.error, testCase.name).toHaveBeenCalledWith( + expect.stringContaining(testCase.expectedError), + ); + expect(defaultRuntime.exit, testCase.name).toHaveBeenCalledWith(1); + } }); it("persists update channel when --channel is set", async () => { @@ -961,19 +976,6 @@ describe("update-cli", () => { ).toBe(shouldRunPackageUpdate); }); - it("updateWizardCommand requires a TTY", async () => { - setTty(false); - vi.mocked(defaultRuntime.error).mockClear(); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateWizardCommand({}); - - expect(defaultRuntime.error).toHaveBeenCalledWith( - expect.stringContaining("Update wizard requires a TTY"), - ); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }); - it("updateWizardCommand offers dev checkout and forwards selections", async () => { const tempDir = createCaseDir("openclaw-update-wizard"); await withEnvAsync({ OPENCLAW_GIT_DIR: tempDir }, async () => { From e3d021163cdc7f0fc8009907b59abe053df7a41c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:07:57 +0000 Subject: [PATCH 098/393] test: merge action media root cases --- src/channels/plugins/actions/actions.test.ts | 101 +++++++++++-------- 1 file changed, 60 insertions(+), 41 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 36c73ad7a2d..4afd1ba968c 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -498,24 +498,6 @@ describe("handleDiscordMessageAction", () => { expect(call?.[1]).toEqual(expect.any(Object)); }); - it("forwards trusted mediaLocalRoots for send actions", async () => { - await handleDiscordMessageAction({ - action: "send", - params: { to: "channel:123", message: "hi", media: "/tmp/file.png" }, - cfg: {} as OpenClawConfig, - mediaLocalRoots: ["/tmp/agent-root"], - }); - - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - mediaUrl: "/tmp/file.png", - }), - expect.any(Object), - expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), - ); - }); - it("handles discord reaction messageId resolution", async () => { const cases = [ { @@ -886,29 +868,6 @@ describe("telegramMessageActions", () => { } }); - it("forwards trusted mediaLocalRoots for send", async () => { - const cfg = telegramCfg(); - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "send", - params: { - to: "123", - media: "/tmp/voice.ogg", - }, - cfg, - mediaLocalRoots: ["/tmp/agent-root"], - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - mediaUrl: "/tmp/voice.ogg", - }), - cfg, - expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), - ); - }); - it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { const cfg = telegramCfg(); const handleAction = telegramMessageActions.handleAction; @@ -1026,6 +985,66 @@ describe("telegramMessageActions", () => { }); }); +it("forwards trusted mediaLocalRoots for send actions", async () => { + const cases = [ + { + name: "discord", + run: async () => { + await handleDiscordMessageAction({ + action: "send", + params: { to: "channel:123", message: "hi", media: "/tmp/file.png" }, + cfg: {} as OpenClawConfig, + mediaLocalRoots: ["/tmp/agent-root"], + }); + }, + assert: () => { + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + mediaUrl: "/tmp/file.png", + }), + expect.any(Object), + expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), + ); + }, + clear: () => handleDiscordAction.mockClear(), + }, + { + name: "telegram", + run: async () => { + const cfg = telegramCfg(); + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "send", + params: { + to: "123", + media: "/tmp/voice.ogg", + }, + cfg, + mediaLocalRoots: ["/tmp/agent-root"], + }); + }, + assert: () => { + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + mediaUrl: "/tmp/voice.ogg", + }), + expect.any(Object), + expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), + ); + }, + clear: () => handleTelegramAction.mockClear(), + }, + ] as const; + + for (const testCase of cases) { + testCase.clear(); + await testCase.run(); + testCase.assert(); + } +}); + describe("signalMessageActions", () => { it("lists actions based on account presence and reaction gates", () => { const cases = [ From 58313fcd0546087234427383368379dd24ed5ea9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:09:08 +0000 Subject: [PATCH 099/393] test: merge update cli restart behavior cases --- src/cli/update-cli.test.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index e29a5c1ea61..763d701fdd1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -743,6 +743,21 @@ describe("update-cli", () => { expect(runDaemonRestart).not.toHaveBeenCalled(); }, }, + { + name: "skips success message when restart does not run", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + vi.mocked(runDaemonRestart).mockResolvedValue(false); + vi.mocked(defaultRuntime.log).mockClear(); + await updateCommand({ restart: true }); + }, + assert: () => { + const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe( + false, + ); + }, + }, ] as const; for (const testCase of cases) { @@ -879,17 +894,6 @@ describe("update-cli", () => { } }); - it("updateCommand skips success message when restart does not run", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - vi.mocked(runDaemonRestart).mockResolvedValue(false); - vi.mocked(defaultRuntime.log).mockClear(); - - await updateCommand({ restart: true }); - - const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false); - }); - it("validates update command invocation errors", async () => { const cases = [ { From 647fb9cc3e8134b03a9abc1067d4d31254d9b24c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:10:40 +0000 Subject: [PATCH 100/393] test: merge update cli channel cases --- src/cli/update-cli.test.ts | 71 +++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 763d701fdd1..329434933d1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -481,28 +481,47 @@ describe("update-cli", () => { expectedChannel: "beta" as const, expectedTag: undefined as string | undefined, }, - ])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => { - await prepare(); - if (mode) { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode })); - } - - await updateCommand(options); - - if (expectedChannel !== undefined) { - const call = expectUpdateCallChannel(expectedChannel); - if (expectedTag !== undefined) { - expect(call?.tag).toBe(expectedTag); + { + name: "uses explicit beta channel and persists it", + mode: "git" as const, + options: { channel: "beta" }, + prepare: async () => {}, + expectedChannel: undefined as string | undefined, + expectedTag: undefined as string | undefined, + expectedPersistedChannel: "beta" as const, + }, + ])( + "$name", + async ({ mode, options, prepare, expectedChannel, expectedTag, expectedPersistedChannel }) => { + await prepare(); + if (mode) { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode })); } - return; - } - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], - expect.any(Object), - ); - }); + await updateCommand(options); + + if (expectedChannel !== undefined) { + const call = expectUpdateCallChannel(expectedChannel); + if (expectedTag !== undefined) { + expect(call?.tag).toBe(expectedTag); + } + if (expectedPersistedChannel !== undefined) { + expect(writeConfigFile).toHaveBeenCalled(); + const writeCall = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as { + update?: { channel?: string }; + }; + expect(writeCall?.update?.channel).toBe(expectedPersistedChannel); + } + return; + } + + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + }, + ); it("falls back to latest when beta tag is older than release", async () => { const tempDir = createCaseDir("openclaw-update"); @@ -936,18 +955,6 @@ describe("update-cli", () => { } }); - it("persists update channel when --channel is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - - await updateCommand({ channel: "beta" }); - - expect(writeConfigFile).toHaveBeenCalled(); - const call = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as { - update?: { channel?: string }; - }; - expect(call?.update?.channel).toBe("beta"); - }); - it.each([ { name: "requires confirmation without --yes", From 9f8cf7f71a6fa77bf1e7e7e5d49be8f65b828c83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 16:21:51 +0000 Subject: [PATCH 101/393] test: stabilize full gate --- docs/.generated/config-baseline.json | 3796 +++++++++++++++++ docs/.generated/config-baseline.jsonl | 294 +- extensions/discord/src/channel.setup.ts | 7 +- extensions/discord/src/channel.ts | 3 + extensions/discord/src/setup-core.ts | 2 +- extensions/discord/src/setup-surface.ts | 2 +- extensions/discord/src/shared.ts | 10 +- extensions/imessage/src/channel.setup.ts | 7 +- extensions/imessage/src/channel.ts | 3 + extensions/imessage/src/setup-core.ts | 2 +- extensions/imessage/src/setup-surface.ts | 7 +- extensions/imessage/src/shared.ts | 15 +- extensions/signal/src/channel.setup.ts | 7 +- extensions/signal/src/channel.ts | 3 + extensions/signal/src/setup-core.ts | 3 +- extensions/signal/src/setup-surface.ts | 8 +- extensions/signal/src/shared.ts | 9 +- extensions/slack/src/channel.setup.ts | 7 +- extensions/slack/src/channel.ts | 3 + extensions/slack/src/setup-core.ts | 2 +- extensions/slack/src/setup-surface.ts | 2 +- extensions/slack/src/shared.ts | 12 +- extensions/telegram/src/channel.setup.ts | 7 +- extensions/telegram/src/channel.ts | 3 + extensions/telegram/src/setup-core.ts | 3 +- extensions/telegram/src/shared.ts | 11 +- extensions/whatsapp/src/channel.setup.ts | 15 +- extensions/whatsapp/src/channel.ts | 11 + extensions/whatsapp/src/setup-surface.ts | 3 +- extensions/whatsapp/src/shared.ts | 27 +- src/acp/translator.session-rate-limit.test.ts | 1 + src/auto-reply/reply/commands.test.ts | 2 +- src/channels/plugins/actions/actions.test.ts | 5 +- src/cli/update-cli.test.ts | 35 +- src/memory/embeddings.test.ts | 31 +- src/plugin-sdk/core.ts | 12 + src/plugin-sdk/discord.ts | 1 + src/plugin-sdk/imessage.ts | 2 + src/plugin-sdk/signal.ts | 4 + src/plugin-sdk/slack.ts | 1 + src/plugin-sdk/telegram.ts | 2 + src/plugin-sdk/whatsapp.ts | 2 + src/plugins/loader.test.ts | 19 +- src/security/audit.test.ts | 43 +- 44 files changed, 4316 insertions(+), 128 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 1efe91f11a7..dabe2cf9837 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -45294,6 +45294,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.*.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.*.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.*.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.*.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.acpx", "kind": "plugin", @@ -45560,6 +45612,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.acpx.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.acpx.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.acpx.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.acpx.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.amazon-bedrock", "kind": "plugin", @@ -45629,6 +45733,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.amazon-bedrock.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.amazon-bedrock.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.amazon-bedrock.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.amazon-bedrock.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.anthropic", "kind": "plugin", @@ -45698,6 +45854,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.anthropic.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.anthropic.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.bluebubbles", "kind": "plugin", @@ -45767,6 +45975,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.bluebubbles.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.bluebubbles.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.bluebubbles.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.bluebubbles.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.brave", "kind": "plugin", @@ -45836,6 +46096,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.brave.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.brave.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.byteplus", "kind": "plugin", @@ -45905,6 +46217,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.byteplus.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.cloudflare-ai-gateway", "kind": "plugin", @@ -45974,6 +46338,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.copilot-proxy", "kind": "plugin", @@ -46043,6 +46459,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.copilot-proxy.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.copilot-proxy.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.copilot-proxy.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.copilot-proxy.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.device-pair", "kind": "plugin", @@ -46126,6 +46594,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.device-pair.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.device-pair.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.device-pair.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.device-pair.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.diagnostics-otel", "kind": "plugin", @@ -46195,6 +46715,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.diagnostics-otel.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.diagnostics-otel.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.diagnostics-otel.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.diagnostics-otel.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.diffs", "kind": "plugin", @@ -46601,6 +47173,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.diffs.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.diffs.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.diffs.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.diffs.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.discord", "kind": "plugin", @@ -46670,6 +47294,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.discord.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.discord.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.discord.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.discord.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.elevenlabs", "kind": "plugin", @@ -46739,6 +47415,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.elevenlabs.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.elevenlabs.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.elevenlabs.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.elevenlabs.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.feishu", "kind": "plugin", @@ -46808,6 +47536,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.feishu.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.feishu.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.feishu.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.feishu.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.firecrawl", "kind": "plugin", @@ -46877,6 +47657,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.firecrawl.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.firecrawl.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.firecrawl.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.firecrawl.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.github-copilot", "kind": "plugin", @@ -46946,6 +47778,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.github-copilot.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.github-copilot.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.github-copilot.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.github-copilot.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.google", "kind": "plugin", @@ -47015,6 +47899,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.google.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.google.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.google.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.google.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.googlechat", "kind": "plugin", @@ -47084,6 +48020,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.googlechat.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.googlechat.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.googlechat.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.googlechat.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.huggingface", "kind": "plugin", @@ -47153,6 +48141,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.huggingface.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.huggingface.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.imessage", "kind": "plugin", @@ -47222,6 +48262,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.imessage.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.imessage.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.imessage.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.imessage.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.irc", "kind": "plugin", @@ -47291,6 +48383,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.irc.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.irc.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.irc.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.irc.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.kilocode", "kind": "plugin", @@ -47360,6 +48504,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.kilocode.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.kilocode.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.kimi", "kind": "plugin", @@ -47429,6 +48625,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.kimi.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.kimi.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.line", "kind": "plugin", @@ -47498,6 +48746,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.line.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.line.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.line.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.line.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.llm-task", "kind": "plugin", @@ -47637,6 +48937,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.llm-task.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.llm-task.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.llm-task.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.llm-task.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.lobster", "kind": "plugin", @@ -47706,6 +49058,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.lobster.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.lobster.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.lobster.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.lobster.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.matrix", "kind": "plugin", @@ -47775,6 +49179,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.matrix.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.matrix.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.matrix.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.matrix.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.mattermost", "kind": "plugin", @@ -47844,6 +49300,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.mattermost.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.mattermost.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.mattermost.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.mattermost.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.memory-core", "kind": "plugin", @@ -47913,6 +49421,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.memory-core.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-core.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-core.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.memory-core.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.memory-lancedb", "kind": "plugin", @@ -48111,6 +49671,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.memory-lancedb.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-lancedb.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-lancedb.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.memory-lancedb.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.microsoft", "kind": "plugin", @@ -48180,6 +49792,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.microsoft.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.microsoft.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.microsoft.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.microsoft.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.minimax", "kind": "plugin", @@ -48249,6 +49913,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.minimax.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.minimax.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.minimax.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.minimax.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.mistral", "kind": "plugin", @@ -48318,6 +50034,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.mistral.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.mistral.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.modelstudio", "kind": "plugin", @@ -48387,6 +50155,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.modelstudio.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.moonshot", "kind": "plugin", @@ -48456,6 +50276,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.moonshot.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.msteams", "kind": "plugin", @@ -48525,6 +50397,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.msteams.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.msteams.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.msteams.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.msteams.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.nextcloud-talk", "kind": "plugin", @@ -48594,6 +50518,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nextcloud-talk.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.nextcloud-talk.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.nextcloud-talk.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.nextcloud-talk.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.nostr", "kind": "plugin", @@ -48663,6 +50639,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nostr.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.nostr.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.nostr.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.nostr.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.nvidia", "kind": "plugin", @@ -48732,6 +50760,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nvidia.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.nvidia.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.ollama", "kind": "plugin", @@ -48801,6 +50881,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.ollama.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.ollama.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.ollama.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.ollama.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.open-prose", "kind": "plugin", @@ -48870,6 +51002,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.open-prose.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.open-prose.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.open-prose.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.open-prose.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.openai", "kind": "plugin", @@ -48939,6 +51123,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openai.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openai.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.opencode", "kind": "plugin", @@ -49022,6 +51258,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.opencode-go.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.opencode-go.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.opencode.config", "kind": "plugin", @@ -49077,6 +51365,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.opencode.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.openrouter", "kind": "plugin", @@ -49146,6 +51486,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openrouter.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.openshell", "kind": "plugin", @@ -49382,6 +51774,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openshell.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.perplexity", "kind": "plugin", @@ -49451,6 +51895,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.perplexity.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.phone-control", "kind": "plugin", @@ -49520,6 +52016,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.phone-control.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.phone-control.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.phone-control.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.phone-control.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.qianfan", "kind": "plugin", @@ -49589,6 +52137,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.qianfan.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qianfan.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.qwen-portal-auth", "kind": "plugin", @@ -49658,6 +52258,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.qwen-portal-auth.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.qwen-portal-auth.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.qwen-portal-auth.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qwen-portal-auth.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.sglang", "kind": "plugin", @@ -49727,6 +52379,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.sglang.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.sglang.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.sglang.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.sglang.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.signal", "kind": "plugin", @@ -49796,6 +52500,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.signal.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.signal.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.signal.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.signal.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.slack", "kind": "plugin", @@ -49865,6 +52621,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.slack.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.slack.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.slack.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.slack.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.synology-chat", "kind": "plugin", @@ -49934,6 +52742,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.synology-chat.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.synology-chat.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.synology-chat.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.synology-chat.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.synthetic", "kind": "plugin", @@ -50003,6 +52863,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.synthetic.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.synthetic.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.talk-voice", "kind": "plugin", @@ -50072,6 +52984,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.talk-voice.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.talk-voice.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.talk-voice.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.talk-voice.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.telegram", "kind": "plugin", @@ -50141,6 +53105,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.telegram.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.telegram.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.telegram.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.telegram.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.thread-ownership", "kind": "plugin", @@ -50248,6 +53264,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.thread-ownership.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.thread-ownership.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.thread-ownership.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.thread-ownership.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.tlon", "kind": "plugin", @@ -50317,6 +53385,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.tlon.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.tlon.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.tlon.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.tlon.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.together", "kind": "plugin", @@ -50386,6 +53506,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.together.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.together.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.together.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.together.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.twitch", "kind": "plugin", @@ -50455,6 +53627,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.twitch.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.twitch.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.twitch.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.twitch.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.venice", "kind": "plugin", @@ -50524,6 +53748,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.venice.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.venice.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.vercel-ai-gateway", "kind": "plugin", @@ -50593,6 +53869,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.vllm", "kind": "plugin", @@ -50662,6 +53990,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.vllm.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.vllm.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.vllm.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.vllm.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.voice-call", "kind": "plugin", @@ -52087,6 +55467,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.voice-call.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.voice-call.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.voice-call.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.voice-call.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.volcengine", "kind": "plugin", @@ -52156,6 +55588,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.volcengine.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.volcengine.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.whatsapp", "kind": "plugin", @@ -52225,6 +55709,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.whatsapp.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.whatsapp.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.whatsapp.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.whatsapp.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.xai", "kind": "plugin", @@ -52294,6 +55830,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.xai.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.xai.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.xiaomi", "kind": "plugin", @@ -52363,6 +55951,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.xiaomi.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.zai", "kind": "plugin", @@ -52432,6 +56072,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.zai.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.zai.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.zalo", "kind": "plugin", @@ -52501,6 +56193,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.zalo.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalo.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalo.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.zalo.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.zalouser", "kind": "plugin", @@ -52570,6 +56314,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.zalouser.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalouser.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalouser.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.zalouser.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.installs", "kind": "core", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index caf0e22623c..7e76ecdcd3a 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5165} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -4020,6 +4020,10 @@ {"recordType":"path","path":"plugins.entries.*.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Enabled","help":"Per-plugin enablement override for a specific entry, applied on top of global plugin policy (restart required). Use this to stage plugin rollout gradually across environments.","hasChildren":false} {"recordType":"path","path":"plugins.entries.*.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.*.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.*.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.*.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.*.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.*.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.acpx","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACPX Runtime","help":"ACP runtime backend powered by acpx with configurable command path and version policy. (plugin: acpx)","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACPX Runtime Config","help":"Plugin-defined config payload for acpx.","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.config.command","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"acpx Command","help":"Optional path/command override for acpx (for example /home/user/repos/acpx/dist/cli.js). Leave unset to use plugin-local bundled acpx.","hasChildren":false} @@ -4040,52 +4044,92 @@ {"recordType":"path","path":"plugins.entries.acpx.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable ACPX Runtime","hasChildren":false} {"recordType":"path","path":"plugins.entries.acpx.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.acpx.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.acpx.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.acpx.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.acpx.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.amazon-bedrock","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/amazon-bedrock-provider","help":"OpenClaw Amazon Bedrock provider plugin (plugin: amazon-bedrock)","hasChildren":true} {"recordType":"path","path":"plugins.entries.amazon-bedrock.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/amazon-bedrock-provider Config","help":"Plugin-defined config payload for amazon-bedrock.","hasChildren":false} {"recordType":"path","path":"plugins.entries.amazon-bedrock.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/amazon-bedrock-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.amazon-bedrock.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.amazon-bedrock.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider","help":"OpenClaw Anthropic provider plugin (plugin: anthropic)","hasChildren":true} {"recordType":"path","path":"plugins.entries.anthropic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider Config","help":"Plugin-defined config payload for anthropic.","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/anthropic-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.anthropic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles","help":"OpenClaw BlueBubbles channel plugin (plugin: bluebubbles)","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles Config","help":"Plugin-defined config payload for bluebubbles.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/bluebubbles","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.brave","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin","help":"OpenClaw Brave plugin (plugin: brave)","hasChildren":true} {"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":false} {"recordType":"path","path":"plugins.entries.brave.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/brave-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.brave.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.brave.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider","help":"OpenClaw BytePlus provider plugin (plugin: byteplus)","hasChildren":true} {"recordType":"path","path":"plugins.entries.byteplus.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider Config","help":"Plugin-defined config payload for byteplus.","hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/byteplus-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.byteplus.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy","help":"OpenClaw Copilot Proxy provider plugin (plugin: copilot-proxy)","hasChildren":true} {"recordType":"path","path":"plugins.entries.copilot-proxy.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy Config","help":"Plugin-defined config payload for copilot-proxy.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/copilot-proxy","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.copilot-proxy.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.device-pair","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing","help":"Generate setup codes and approve device pairing requests. (plugin: device-pair)","hasChildren":true} {"recordType":"path","path":"plugins.entries.device-pair.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing Config","help":"Plugin-defined config payload for device-pair.","hasChildren":true} {"recordType":"path","path":"plugins.entries.device-pair.config.publicUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway URL","help":"Public WebSocket URL used for /pair setup codes (ws/wss or http/https).","hasChildren":false} {"recordType":"path","path":"plugins.entries.device-pair.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Device Pairing","hasChildren":false} {"recordType":"path","path":"plugins.entries.device-pair.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.device-pair.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.device-pair.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.device-pair.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.device-pair.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.device-pair.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.diagnostics-otel","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"@openclaw/diagnostics-otel","help":"OpenClaw diagnostics OpenTelemetry exporter (plugin: diagnostics-otel)","hasChildren":true} {"recordType":"path","path":"plugins.entries.diagnostics-otel.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"@openclaw/diagnostics-otel Config","help":"Plugin-defined config payload for diagnostics-otel.","hasChildren":false} {"recordType":"path","path":"plugins.entries.diagnostics-otel.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Enable @openclaw/diagnostics-otel","hasChildren":false} {"recordType":"path","path":"plugins.entries.diagnostics-otel.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.diagnostics-otel.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.diffs","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Diffs","help":"Read-only diff viewer and file renderer for agents. (plugin: diffs)","hasChildren":true} {"recordType":"path","path":"plugins.entries.diffs.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Diffs Config","help":"Plugin-defined config payload for diffs.","hasChildren":true} {"recordType":"path","path":"plugins.entries.diffs.config.defaults","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -4113,71 +4157,127 @@ {"recordType":"path","path":"plugins.entries.diffs.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Diffs","hasChildren":false} {"recordType":"path","path":"plugins.entries.diffs.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.diffs.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.diffs.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diffs.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diffs.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.diffs.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.discord","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/discord","help":"OpenClaw Discord channel plugin (plugin: discord)","hasChildren":true} {"recordType":"path","path":"plugins.entries.discord.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/discord Config","help":"Plugin-defined config payload for discord.","hasChildren":false} {"recordType":"path","path":"plugins.entries.discord.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/discord","hasChildren":false} {"recordType":"path","path":"plugins.entries.discord.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.discord.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.discord.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.discord.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.discord.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.discord.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.elevenlabs","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/elevenlabs-speech","help":"OpenClaw ElevenLabs speech plugin (plugin: elevenlabs)","hasChildren":true} {"recordType":"path","path":"plugins.entries.elevenlabs.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/elevenlabs-speech Config","help":"Plugin-defined config payload for elevenlabs.","hasChildren":false} {"recordType":"path","path":"plugins.entries.elevenlabs.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/elevenlabs-speech","hasChildren":false} {"recordType":"path","path":"plugins.entries.elevenlabs.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.elevenlabs.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu","help":"OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng) (plugin: feishu)","hasChildren":true} {"recordType":"path","path":"plugins.entries.feishu.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu Config","help":"Plugin-defined config payload for feishu.","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/feishu","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.feishu.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.feishu.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.feishu.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.firecrawl","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin","help":"OpenClaw Firecrawl plugin (plugin: firecrawl)","hasChildren":true} {"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":false} {"recordType":"path","path":"plugins.entries.firecrawl.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/firecrawl-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.firecrawl.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.firecrawl.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.github-copilot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider","help":"OpenClaw GitHub Copilot provider plugin (plugin: github-copilot)","hasChildren":true} {"recordType":"path","path":"plugins.entries.github-copilot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider Config","help":"Plugin-defined config payload for github-copilot.","hasChildren":false} {"recordType":"path","path":"plugins.entries.github-copilot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/github-copilot-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.github-copilot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.github-copilot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.google","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin","help":"OpenClaw Google plugin (plugin: google)","hasChildren":true} {"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":false} {"recordType":"path","path":"plugins.entries.google.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.google.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.google.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat","help":"OpenClaw Google Chat channel plugin (plugin: googlechat)","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat Config","help":"Plugin-defined config payload for googlechat.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/googlechat","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.googlechat.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.huggingface","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider","help":"OpenClaw Hugging Face provider plugin (plugin: huggingface)","hasChildren":true} {"recordType":"path","path":"plugins.entries.huggingface.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider Config","help":"Plugin-defined config payload for huggingface.","hasChildren":false} {"recordType":"path","path":"plugins.entries.huggingface.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/huggingface-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.huggingface.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.huggingface.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage","help":"OpenClaw iMessage channel plugin (plugin: imessage)","hasChildren":true} {"recordType":"path","path":"plugins.entries.imessage.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage Config","help":"Plugin-defined config payload for imessage.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/imessage","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.imessage.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.imessage.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.imessage.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.imessage.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.imessage.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/irc","help":"OpenClaw IRC channel plugin (plugin: irc)","hasChildren":true} {"recordType":"path","path":"plugins.entries.irc.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/irc Config","help":"Plugin-defined config payload for irc.","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/irc","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.irc.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.irc.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.irc.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.irc.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.irc.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kilocode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider","help":"OpenClaw Kilo Gateway provider plugin (plugin: kilocode)","hasChildren":true} {"recordType":"path","path":"plugins.entries.kilocode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider Config","help":"Plugin-defined config payload for kilocode.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kilocode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kilocode-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.kilocode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.kilocode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kimi","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-provider","help":"OpenClaw Kimi provider plugin (plugin: kimi)","hasChildren":true} {"recordType":"path","path":"plugins.entries.kimi.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-provider Config","help":"Plugin-defined config payload for kimi.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kimi.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kimi-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.kimi.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.kimi.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line","help":"OpenClaw LINE channel plugin (plugin: line)","hasChildren":true} {"recordType":"path","path":"plugins.entries.line.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line Config","help":"Plugin-defined config payload for line.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/line","hasChildren":false} {"recordType":"path","path":"plugins.entries.line.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.line.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.line.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.line.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.line.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.line.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.llm-task","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"LLM Task","help":"Generic JSON-only LLM tool for structured tasks callable from workflows. (plugin: llm-task)","hasChildren":true} {"recordType":"path","path":"plugins.entries.llm-task.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"LLM Task Config","help":"Plugin-defined config payload for llm-task.","hasChildren":true} {"recordType":"path","path":"plugins.entries.llm-task.config.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -4190,26 +4290,46 @@ {"recordType":"path","path":"plugins.entries.llm-task.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable LLM Task","hasChildren":false} {"recordType":"path","path":"plugins.entries.llm-task.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.llm-task.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.llm-task.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.llm-task.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.llm-task.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.llm-task.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.lobster","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Lobster","help":"Typed workflow tool with resumable approvals. (plugin: lobster)","hasChildren":true} {"recordType":"path","path":"plugins.entries.lobster.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Lobster Config","help":"Plugin-defined config payload for lobster.","hasChildren":false} {"recordType":"path","path":"plugins.entries.lobster.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Lobster","hasChildren":false} {"recordType":"path","path":"plugins.entries.lobster.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.lobster.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.lobster.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.lobster.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.lobster.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.lobster.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.matrix","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/matrix","help":"OpenClaw Matrix channel plugin (plugin: matrix)","hasChildren":true} {"recordType":"path","path":"plugins.entries.matrix.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/matrix Config","help":"Plugin-defined config payload for matrix.","hasChildren":false} {"recordType":"path","path":"plugins.entries.matrix.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/matrix","hasChildren":false} {"recordType":"path","path":"plugins.entries.matrix.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.matrix.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.matrix.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.matrix.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.matrix.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.matrix.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mattermost","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mattermost","help":"OpenClaw Mattermost channel plugin (plugin: mattermost)","hasChildren":true} {"recordType":"path","path":"plugins.entries.mattermost.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mattermost Config","help":"Plugin-defined config payload for mattermost.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mattermost.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/mattermost","hasChildren":false} {"recordType":"path","path":"plugins.entries.mattermost.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.mattermost.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mattermost.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mattermost.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mattermost.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.mattermost.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-core","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/memory-core","help":"OpenClaw core memory search plugin (plugin: memory-core)","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-core.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/memory-core Config","help":"Plugin-defined config payload for memory-core.","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-core.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/memory-core","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-core.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-core.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-core.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-core.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-core.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-core.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-lancedb","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"@openclaw/memory-lancedb","help":"OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture (plugin: memory-lancedb)","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"@openclaw/memory-lancedb Config","help":"Plugin-defined config payload for memory-lancedb.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.config.autoCapture","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Auto-Capture","help":"Automatically capture important information from conversations","hasChildren":false} @@ -4224,81 +4344,145 @@ {"recordType":"path","path":"plugins.entries.memory-lancedb.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Enable @openclaw/memory-lancedb","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.microsoft","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/microsoft-speech","help":"OpenClaw Microsoft speech plugin (plugin: microsoft)","hasChildren":true} {"recordType":"path","path":"plugins.entries.microsoft.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/microsoft-speech Config","help":"Plugin-defined config payload for microsoft.","hasChildren":false} {"recordType":"path","path":"plugins.entries.microsoft.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/microsoft-speech","hasChildren":false} {"recordType":"path","path":"plugins.entries.microsoft.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.microsoft.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.microsoft.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.microsoft.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.microsoft.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.microsoft.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.minimax","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider","help":"OpenClaw MiniMax provider and OAuth plugin (plugin: minimax)","hasChildren":true} {"recordType":"path","path":"plugins.entries.minimax.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider Config","help":"Plugin-defined config payload for minimax.","hasChildren":false} {"recordType":"path","path":"plugins.entries.minimax.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Enable @openclaw/minimax-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.minimax.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.minimax.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mistral","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider","help":"OpenClaw Mistral provider plugin (plugin: mistral)","hasChildren":true} {"recordType":"path","path":"plugins.entries.mistral.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider Config","help":"Plugin-defined config payload for mistral.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mistral.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/mistral-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.mistral.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.mistral.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.modelstudio","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider","help":"OpenClaw Model Studio provider plugin (plugin: modelstudio)","hasChildren":true} {"recordType":"path","path":"plugins.entries.modelstudio.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider Config","help":"Plugin-defined config payload for modelstudio.","hasChildren":false} {"recordType":"path","path":"plugins.entries.modelstudio.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/modelstudio-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.modelstudio.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.modelstudio.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.moonshot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider","help":"OpenClaw Moonshot provider plugin (plugin: moonshot)","hasChildren":true} {"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":false} {"recordType":"path","path":"plugins.entries.moonshot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/moonshot-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.moonshot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.moonshot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams","help":"OpenClaw Microsoft Teams channel plugin (plugin: msteams)","hasChildren":true} {"recordType":"path","path":"plugins.entries.msteams.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams Config","help":"Plugin-defined config payload for msteams.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/msteams","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.msteams.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.msteams.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.msteams.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.msteams.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.msteams.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nextcloud-talk","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nextcloud-talk","help":"OpenClaw Nextcloud Talk channel plugin (plugin: nextcloud-talk)","hasChildren":true} {"recordType":"path","path":"plugins.entries.nextcloud-talk.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nextcloud-talk Config","help":"Plugin-defined config payload for nextcloud-talk.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nextcloud-talk.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nextcloud-talk","hasChildren":false} {"recordType":"path","path":"plugins.entries.nextcloud-talk.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nextcloud-talk.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nostr","help":"OpenClaw Nostr channel plugin for NIP-04 encrypted DMs (plugin: nostr)","hasChildren":true} {"recordType":"path","path":"plugins.entries.nostr.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nostr Config","help":"Plugin-defined config payload for nostr.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nostr","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nostr.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nostr.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nostr.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nostr.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.nostr.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nvidia","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider","help":"OpenClaw NVIDIA provider plugin (plugin: nvidia)","hasChildren":true} {"recordType":"path","path":"plugins.entries.nvidia.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider Config","help":"Plugin-defined config payload for nvidia.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nvidia.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nvidia-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.nvidia.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nvidia.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider","help":"OpenClaw Ollama provider plugin (plugin: ollama)","hasChildren":true} {"recordType":"path","path":"plugins.entries.ollama.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider Config","help":"Plugin-defined config payload for ollama.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/ollama-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.ollama.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.ollama.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.ollama.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.ollama.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.ollama.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenProse","help":"OpenProse VM skill pack with a /prose slash command. (plugin: open-prose)","hasChildren":true} {"recordType":"path","path":"plugins.entries.open-prose.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenProse Config","help":"Plugin-defined config payload for open-prose.","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenProse","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.open-prose.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.open-prose.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.open-prose.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.open-prose.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.open-prose.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider","help":"OpenClaw OpenAI provider plugins (plugin: openai)","hasChildren":true} {"recordType":"path","path":"plugins.entries.openai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider Config","help":"Plugin-defined config payload for openai.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openai-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.openai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider","help":"OpenClaw OpenCode Zen provider plugin (plugin: opencode)","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode-go","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider","help":"OpenClaw OpenCode Go provider plugin (plugin: opencode-go)","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode-go.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider Config","help":"Plugin-defined config payload for opencode-go.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode-go.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-go-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode-go.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode-go.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider Config","help":"Plugin-defined config payload for opencode.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openrouter","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider","help":"OpenClaw OpenRouter provider plugin (plugin: openrouter)","hasChildren":true} {"recordType":"path","path":"plugins.entries.openrouter.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider Config","help":"Plugin-defined config payload for openrouter.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openrouter.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openrouter-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.openrouter.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openrouter.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openshell","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox","help":"Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. (plugin: openshell)","hasChildren":true} {"recordType":"path","path":"plugins.entries.openshell.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox Config","help":"Plugin-defined config payload for openshell.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openshell.config.autoProviders","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Auto-create Providers","help":"When enabled, pass --auto-providers during sandbox create.","hasChildren":false} @@ -4316,61 +4500,109 @@ {"recordType":"path","path":"plugins.entries.openshell.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenShell Sandbox","hasChildren":false} {"recordType":"path","path":"plugins.entries.openshell.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openshell.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.perplexity","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin","help":"OpenClaw Perplexity plugin (plugin: perplexity)","hasChildren":true} {"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":false} {"recordType":"path","path":"plugins.entries.perplexity.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/perplexity-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.perplexity.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.perplexity.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control","help":"Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry. (plugin: phone-control)","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control Config","help":"Plugin-defined config payload for phone-control.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Phone Control","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.phone-control.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.phone-control.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.phone-control.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.phone-control.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qianfan","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider","help":"OpenClaw Qianfan provider plugin (plugin: qianfan)","hasChildren":true} {"recordType":"path","path":"plugins.entries.qianfan.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider Config","help":"Plugin-defined config payload for qianfan.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qianfan.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/qianfan-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.qianfan.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qianfan.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth","help":"Plugin entry for qwen-portal-auth.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth Config","help":"Plugin-defined config payload for qwen-portal-auth.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable qwen-portal-auth","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider","help":"OpenClaw SGLang provider plugin (plugin: sglang)","hasChildren":true} {"recordType":"path","path":"plugins.entries.sglang.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider Config","help":"Plugin-defined config payload for sglang.","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/sglang-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.sglang.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.sglang.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.sglang.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.sglang.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.sglang.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.signal","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/signal","help":"OpenClaw Signal channel plugin (plugin: signal)","hasChildren":true} {"recordType":"path","path":"plugins.entries.signal.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/signal Config","help":"Plugin-defined config payload for signal.","hasChildren":false} {"recordType":"path","path":"plugins.entries.signal.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/signal","hasChildren":false} {"recordType":"path","path":"plugins.entries.signal.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.signal.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.signal.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.signal.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.signal.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.signal.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.slack","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/slack","help":"OpenClaw Slack channel plugin (plugin: slack)","hasChildren":true} {"recordType":"path","path":"plugins.entries.slack.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/slack Config","help":"Plugin-defined config payload for slack.","hasChildren":false} {"recordType":"path","path":"plugins.entries.slack.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/slack","hasChildren":false} {"recordType":"path","path":"plugins.entries.slack.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.slack.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.slack.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.slack.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.slack.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.slack.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synology-chat","help":"Synology Chat channel plugin for OpenClaw (plugin: synology-chat)","hasChildren":true} {"recordType":"path","path":"plugins.entries.synology-chat.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synology-chat Config","help":"Plugin-defined config payload for synology-chat.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synology-chat","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.synology-chat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synthetic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider","help":"OpenClaw Synthetic provider plugin (plugin: synthetic)","hasChildren":true} {"recordType":"path","path":"plugins.entries.synthetic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider Config","help":"Plugin-defined config payload for synthetic.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synthetic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synthetic-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.synthetic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.synthetic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice","help":"Manage Talk voice selection (list/set). (plugin: talk-voice)","hasChildren":true} {"recordType":"path","path":"plugins.entries.talk-voice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice Config","help":"Plugin-defined config payload for talk-voice.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Talk Voice","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.talk-voice.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram","help":"OpenClaw Telegram channel plugin (plugin: telegram)","hasChildren":true} {"recordType":"path","path":"plugins.entries.telegram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram Config","help":"Plugin-defined config payload for telegram.","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/telegram","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.telegram.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.telegram.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.telegram.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.telegram.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.telegram.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.thread-ownership","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Thread Ownership","help":"Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API. (plugin: thread-ownership)","hasChildren":true} {"recordType":"path","path":"plugins.entries.thread-ownership.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Thread Ownership Config","help":"Plugin-defined config payload for thread-ownership.","hasChildren":true} {"recordType":"path","path":"plugins.entries.thread-ownership.config.abTestChannels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"A/B Test Channels","help":"Slack channel IDs where thread ownership is enforced","hasChildren":true} @@ -4379,36 +4611,64 @@ {"recordType":"path","path":"plugins.entries.thread-ownership.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Enable Thread Ownership","hasChildren":false} {"recordType":"path","path":"plugins.entries.thread-ownership.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.thread-ownership.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tlon","help":"OpenClaw Tlon/Urbit channel plugin (plugin: tlon)","hasChildren":true} {"recordType":"path","path":"plugins.entries.tlon.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tlon Config","help":"Plugin-defined config payload for tlon.","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tlon","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.tlon.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.tlon.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tlon.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tlon.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.tlon.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.together","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider","help":"OpenClaw Together provider plugin (plugin: together)","hasChildren":true} {"recordType":"path","path":"plugins.entries.together.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider Config","help":"Plugin-defined config payload for together.","hasChildren":false} {"recordType":"path","path":"plugins.entries.together.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/together-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.together.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.together.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch","help":"OpenClaw Twitch channel plugin (plugin: twitch)","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch Config","help":"Plugin-defined config payload for twitch.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/twitch","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.twitch.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.twitch.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.twitch.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.twitch.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.venice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider","help":"OpenClaw Venice provider plugin (plugin: venice)","hasChildren":true} {"recordType":"path","path":"plugins.entries.venice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider Config","help":"Plugin-defined config payload for venice.","hasChildren":false} {"recordType":"path","path":"plugins.entries.venice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/venice-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.venice.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.venice.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider","help":"OpenClaw Vercel AI Gateway provider plugin (plugin: vercel-ai-gateway)","hasChildren":true} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider Config","help":"Plugin-defined config payload for vercel-ai-gateway.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vercel-ai-gateway-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider","help":"OpenClaw vLLM provider plugin (plugin: vllm)","hasChildren":true} {"recordType":"path","path":"plugins.entries.vllm.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider Config","help":"Plugin-defined config payload for vllm.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vllm-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.vllm.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vllm.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vllm.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vllm.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.vllm.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/voice-call","help":"OpenClaw voice-call plugin (plugin: voice-call)","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/voice-call Config","help":"Plugin-defined config payload for voice-call.","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.config.allowFrom","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Inbound Allowlist","hasChildren":true} @@ -4530,41 +4790,73 @@ {"recordType":"path","path":"plugins.entries.voice-call.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/voice-call","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.voice-call.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.voice-call.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.voice-call.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.voice-call.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.volcengine","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider","help":"OpenClaw Volcengine provider plugin (plugin: volcengine)","hasChildren":true} {"recordType":"path","path":"plugins.entries.volcengine.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider Config","help":"Plugin-defined config payload for volcengine.","hasChildren":false} {"recordType":"path","path":"plugins.entries.volcengine.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/volcengine-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.volcengine.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.volcengine.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp","help":"OpenClaw WhatsApp channel plugin (plugin: whatsapp)","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp Config","help":"Plugin-defined config payload for whatsapp.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/whatsapp","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin","help":"OpenClaw xAI plugin (plugin: xai)","hasChildren":true} {"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xai-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.xai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.xai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xiaomi","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider","help":"OpenClaw Xiaomi provider plugin (plugin: xiaomi)","hasChildren":true} {"recordType":"path","path":"plugins.entries.xiaomi.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider Config","help":"Plugin-defined config payload for xiaomi.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xiaomi.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xiaomi-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.xiaomi.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.xiaomi.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider","help":"OpenClaw Z.AI provider plugin (plugin: zai)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider Config","help":"Plugin-defined config payload for zai.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zai-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.zai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.zai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo","help":"OpenClaw Zalo channel plugin (plugin: zalo)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalo.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo Config","help":"Plugin-defined config payload for zalo.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zalo","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalo.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalo.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalo.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalo.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalo.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalouser","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalouser","help":"OpenClaw Zalo Personal Account plugin via native zca-js integration (plugin: zalouser)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalouser.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalouser Config","help":"Plugin-defined config payload for zalouser.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalouser.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zalouser","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalouser.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalouser.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalouser.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalouser.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalouser.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalouser.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.installs","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Records","help":"CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).","hasChildren":true} {"recordType":"path","path":"plugins.installs.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"plugins.installs.*.installedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Time","help":"ISO timestamp of last install/update.","hasChildren":false} diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index c45ed85fb0b..efec8990442 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,10 +1,15 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/discord"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord"; import { type ResolvedDiscordAccount } from "./accounts.js"; import { discordSetupAdapter } from "./setup-core.js"; import { createDiscordPluginBase } from "./shared.js"; export const discordSetupPlugin: ChannelPlugin = { ...createDiscordPluginBase({ + configSchema: buildChannelConfigSchema(DiscordConfigSchema), setup: discordSetupAdapter, }), }; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index dff011825b0..8cae9c04323 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -13,8 +13,10 @@ import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, + buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, + DiscordConfigSchema, getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, @@ -275,6 +277,7 @@ function resolveDiscordOutboundSessionRoute(params: { export const discordPlugin: ChannelPlugin = { ...createDiscordPluginBase({ + configSchema: buildChannelConfigSchema(DiscordConfigSchema), setup: discordSetupAdapter, }), pairing: { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index f7722a35af5..46afa1fcbbd 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,8 +1,8 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; +import { formatDocsLink } from "openclaw/plugin-sdk/discord"; import { DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, - formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 801f7bf7838..7d0ded88dc0 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,5 +1,5 @@ +import { formatDocsLink } from "openclaw/plugin-sdk/discord"; import { - formatDocsLink, type OpenClawConfig, promptLegacyChannelAllowFrom, resolveSetupAccountId, diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 03174404bdb..2e611fb08a2 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -3,12 +3,7 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { - buildChannelConfigSchema, - DiscordConfigSchema, - getChatChannelMeta, - type ChannelPlugin, -} from "openclaw/plugin-sdk/discord"; +import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, @@ -45,6 +40,7 @@ export const discordConfigBase = createScopedChannelConfigBase, "configSchema">["configSchema"]; setup: NonNullable["setup"]>; }): Pick< ChannelPlugin, @@ -76,7 +72,7 @@ export function createDiscordPluginBase(params: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.discord"] }, - configSchema: buildChannelConfigSchema(DiscordConfigSchema), + configSchema: params.configSchema, config: { ...discordConfigBase, isConfigured: (account) => Boolean(account.token?.trim()), diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index 4f715cab88c..a6f2f90d9f0 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,10 +1,15 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; +import { + buildChannelConfigSchema, + IMessageConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/imessage"; import { type ResolvedIMessageAccount } from "./accounts.js"; import { imessageSetupAdapter } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; export const imessageSetupPlugin: ChannelPlugin = { ...createIMessagePluginBase({ + configSchema: buildChannelConfigSchema(IMessageConfigSchema), setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, }), diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 973456af7bb..bb36a33612c 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -6,9 +6,11 @@ import { import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { + buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, formatTrimmedAllowFromEntries, + IMessageConfigSchema, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveIMessageGroupRequireMention, @@ -102,6 +104,7 @@ function resolveIMessageOutboundSessionRoute(params: { export const imessagePlugin: ChannelPlugin = { ...createIMessagePluginBase({ + configSchema: buildChannelConfigSchema(IMessageConfigSchema), setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, }), diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 9da4e99b1ef..f6c71074ca9 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,6 +1,6 @@ +import { formatDocsLink } from "openclaw/plugin-sdk/imessage"; import { createPatchedAccountSetupAdapter, - formatDocsLink, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 54511d284c4..94358db1e11 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,8 +1,5 @@ -import { - detectBinary, - setSetupChannelEnabled, - type ChannelSetupWizard, -} from "openclaw/plugin-sdk/setup"; +import { detectBinary } from "openclaw/plugin-sdk/imessage"; +import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js"; import { createIMessageCliPathTextInput, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index 935546721da..e81390dcc8d 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -1,19 +1,19 @@ +import { + formatTrimmedAllowFromEntries, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - formatTrimmedAllowFromEntries, getChatChannelMeta, - IMessageConfigSchema, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, setAccountEnabledInConfigSection, type ChannelPlugin, -} from "openclaw/plugin-sdk/imessage"; +} from "openclaw/plugin-sdk/core"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, @@ -33,6 +33,7 @@ export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ })); export function createIMessagePluginBase(params: { + configSchema: Pick, "configSchema">["configSchema"]; setupWizard?: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -60,7 +61,7 @@ export function createIMessagePluginBase(params: { media: true, }, reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), + configSchema: params.configSchema, config: { listAccountIds: (cfg) => listIMessageAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 6fa8add4405..752fcfcc241 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,10 +1,15 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/signal"; +import { + buildChannelConfigSchema, + SignalConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/signal"; import { type ResolvedSignalAccount } from "./accounts.js"; import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { ...createSignalPluginBase({ + configSchema: buildChannelConfigSchema(SignalConfigSchema), setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 17b97c96f25..0a58c29bfe7 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -11,6 +11,7 @@ import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, @@ -19,6 +20,7 @@ import { normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, + SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, } from "openclaw/plugin-sdk/signal"; @@ -277,6 +279,7 @@ async function sendFormattedSignalMedia(ctx: { export const signalPlugin: ChannelPlugin = { ...createSignalPluginBase({ + configSchema: buildChannelConfigSchema(SignalConfigSchema), setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index e0c4d5ec0a3..5714ad1c68c 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,7 +1,5 @@ import { createPatchedAccountSetupAdapter, - formatCliCommand, - formatDocsLink, normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -16,6 +14,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/signal"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 01ded866785..705c4d2f839 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,9 +1,5 @@ -import { - detectBinary, - installSignalCli, - setSetupChannelEnabled, - type ChannelSetupWizard, -} from "openclaw/plugin-sdk/setup"; +import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/signal"; import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; import { createSignalCliPathTextInput, diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 3de5af7d57a..b5fe4bcd646 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -4,15 +4,13 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, getChatChannelMeta, - normalizeE164, setAccountEnabledInConfigSection, - SignalConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; +} from "openclaw/plugin-sdk/core"; +import { normalizeE164 } from "openclaw/plugin-sdk/setup"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -44,6 +42,7 @@ export const signalConfigAccessors = createScopedAccountConfigAccessors({ }); export function createSignalPluginBase(params: { + configSchema: Pick, "configSchema">["configSchema"]; setupWizard?: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -74,7 +73,7 @@ export function createSignalPluginBase(params: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), + configSchema: params.configSchema, config: { listAccountIds: (cfg) => listSignalAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 854e1782315..519f6eabe7b 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,4 +1,8 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/slack"; +import { + buildChannelConfigSchema, + SlackConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; import { slackSetupAdapter } from "./setup-core.js"; import { slackSetupWizard } from "./setup-surface.js"; @@ -6,6 +10,7 @@ import { createSlackPluginBase } from "./shared.js"; export const slackSetupPlugin: ChannelPlugin = { ...createSlackPluginBase({ + configSchema: buildChannelConfigSchema(SlackConfigSchema), setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5e25f0187b1..8a82a3577b8 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -11,6 +11,7 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { + buildChannelConfigSchema, buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, listSlackDirectoryGroupsFromConfig, @@ -22,6 +23,7 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, + SlackConfigSchema, createSlackActions, type ChannelPlugin, type OpenClawConfig, @@ -307,6 +309,7 @@ async function resolveSlackAllowlistNames(params: { export const slackPlugin: ChannelPlugin = { ...createSlackPluginBase({ + configSchema: buildChannelConfigSchema(SlackConfigSchema), setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 2a3aad980fa..fc856ad0dd2 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -2,7 +2,6 @@ import { createAllowlistSetupWizardProxy, DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, - formatDocsLink, hasConfiguredSecretInput, type OpenClawConfig, noteChannelLookupFailure, @@ -19,6 +18,7 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/slack"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index d103a329c50..112142df4d6 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,5 +1,4 @@ import { - formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, @@ -12,6 +11,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/slack"; import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 58dfae35c90..ff8be31895e 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -3,18 +3,13 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core"; import { formatDocsLink, hasConfiguredSecretInput, patchChannelConfigForAccount, -} from "openclaw/plugin-sdk/setup"; -import { - buildChannelConfigSchema, - getChatChannelMeta, - SlackConfigSchema, - type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/slack"; +} from "openclaw/plugin-sdk/setup"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, @@ -161,6 +156,7 @@ export const slackConfigBase = createScopedChannelConfigBase({ }); export function createSlackPluginBase(params: { + configSchema: Pick, "configSchema">["configSchema"]; setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -205,7 +201,7 @@ export function createSlackPluginBase(params: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), + configSchema: params.configSchema, config: { ...slackConfigBase, isConfigured: (account) => isSlackPluginAccountConfigured(account), diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 4879ef96c09..bdee67aa41d 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,4 +1,8 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; +import { + buildChannelConfigSchema, + TelegramConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/telegram"; import { type ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; @@ -7,6 +11,7 @@ import { createTelegramPluginBase } from "./shared.js"; export const telegramSetupPlugin: ChannelPlugin = { ...createTelegramPluginBase({ + configSchema: buildChannelConfigSchema(TelegramConfigSchema), setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c05c926d52f..6dfe12870a2 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -12,6 +12,7 @@ import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra- import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram"; import { + buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, @@ -20,6 +21,7 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, + TelegramConfigSchema, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, type ChannelPlugin, @@ -296,6 +298,7 @@ function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { export const telegramPlugin: ChannelPlugin = { ...createTelegramPluginBase({ + configSchema: buildChannelConfigSchema(TelegramConfigSchema), setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 7e73898f8b1..d4a95f0d6fb 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,8 +1,6 @@ import { createEnvPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, patchChannelConfigForAccount, promptResolvedAllowFrom, splitSetupEntries, @@ -10,6 +8,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/telegram"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 644869dbc60..b70c8b7fa9d 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -1,16 +1,14 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { - createScopedAccountConfigAccessors, createScopedChannelConfigBase, + createScopedAccountConfigAccessors, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildChannelConfigSchema, getChatChannelMeta, normalizeAccountId, - TelegramConfigSchema, - type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/telegram"; + type ChannelPlugin, +} from "openclaw/plugin-sdk/core"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, @@ -73,6 +71,7 @@ export const telegramConfigBase = createScopedChannelConfigBase, "configSchema">["configSchema"]; setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -96,7 +95,7 @@ export function createTelegramPluginBase(params: { blockStreaming: true, }, reload: { configPrefixes: ["channels.telegram"] }, - configSchema: buildChannelConfigSchema(TelegramConfigSchema), + configSchema: params.configSchema, config: { ...telegramConfigBase, isConfigured: (account, cfg) => { diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index ebe4deb5789..3b4ecacce26 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,4 +1,11 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; +import { + buildChannelConfigSchema, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; import { whatsappSetupAdapter } from "./setup-core.js"; @@ -6,6 +13,12 @@ import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js" export const whatsappSetupPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, isConfigured: async (account) => await webAuthExists(account.authDir), diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e7f79ad5f2a..d69dd480a4a 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,5 +1,6 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { + buildChannelConfigSchema, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, @@ -7,9 +8,13 @@ import { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, readStringParam, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, resolveWhatsAppOutboundTarget, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, + WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp"; @@ -44,6 +49,12 @@ function parseWhatsAppExplicitTarget(raw: string) { export const whatsappPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, isConfigured: async (account) => diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 4a87ce4d0f8..9f2eb7dd311 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,8 +1,6 @@ import path from "node:path"; import { DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, normalizeAccountId, normalizeAllowFromEntries, normalizeE164, @@ -13,6 +11,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/whatsapp"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 6616ac6911f..3114db109d0 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -1,22 +1,19 @@ +import { + formatWhatsAppConfigAllowFromEntries, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - formatWhatsAppConfigAllowFromEntries, getChatChannelMeta, - normalizeE164, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, - WhatsAppConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; +} from "openclaw/plugin-sdk/core"; +import { normalizeE164 } from "openclaw/plugin-sdk/setup"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, @@ -79,6 +76,8 @@ export function createWhatsAppSetupWizardProxy( } export function createWhatsAppPluginBase(params: { + configSchema: Pick, "configSchema">["configSchema"]; + groups: Pick, "groups">["groups"]; setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; isConfigured: NonNullable["config"]>["isConfigured"]; @@ -114,7 +113,7 @@ export function createWhatsAppPluginBase(params: { }, reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + configSchema: params.configSchema, config: { listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), @@ -213,10 +212,6 @@ export function createWhatsAppPluginBase(params: { }, }, setup: params.setup, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, + groups: params.groups, }; } diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 55446550f9f..162afe6160c 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -308,6 +308,7 @@ describe("acp session UX bridge behavior", () => { "low", "medium", "high", + "xhigh", "adaptive", ]); expect(result.configOptions).toEqual( diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 48fa3e061ab..4e0a332910e 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -490,7 +490,7 @@ describe("/approve command", () => { const params = buildParams("/approve abc allow-once", cfg, { Provider: "webchat", Surface: "webchat", - GatewayClientScopes: testCase.scopes, + GatewayClientScopes: [...testCase.scopes], }); const result = await handleCommands(params); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 4afd1ba968c..1692e0f0754 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1154,7 +1154,7 @@ describe("signalMessageActions", () => { await runSignalAction("react", testCase.params, { cfg: testCase.cfg, accountId: testCase.accountId, - toolContext: testCase.toolContext, + toolContext: "toolContext" in testCase ? testCase.toolContext : undefined, }); expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith( testCase.expectedRecipient, @@ -1294,7 +1294,8 @@ describe("slack actions adapter", () => { await runSlackAction(testCase.action, testCase.params); expectFirstSlackAction(testCase.expected); const [params] = handleSlackAction.mock.calls[0] ?? []; - for (const key of testCase.absentKeys ?? []) { + const absentKeys = "absentKeys" in testCase ? testCase.absentKeys : undefined; + for (const key of absentKeys ?? []) { expect(params).not.toHaveProperty(key); } } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 329434933d1..abab0eb5cf4 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -482,7 +482,7 @@ describe("update-cli", () => { expectedTag: undefined as string | undefined, }, { - name: "uses explicit beta channel and persists it", + name: "switches git installs to package mode for explicit beta and persists it", mode: "git" as const, options: { channel: "beta" }, prepare: async () => {}, @@ -505,21 +505,21 @@ describe("update-cli", () => { if (expectedTag !== undefined) { expect(call?.tag).toBe(expectedTag); } - if (expectedPersistedChannel !== undefined) { - expect(writeConfigFile).toHaveBeenCalled(); - const writeCall = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as { - update?: { channel?: string }; - }; - expect(writeCall?.update?.channel).toBe(expectedPersistedChannel); - } - return; + } else { + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); } - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], - expect.any(Object), - ); + if (expectedPersistedChannel !== undefined) { + expect(writeConfigFile).toHaveBeenCalled(); + const writeCall = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as { + update?: { channel?: string }; + }; + expect(writeCall?.update?.channel).toBe(expectedPersistedChannel); + } }, ); @@ -873,8 +873,11 @@ describe("update-cli", () => { }, ])("$name", async (testCase) => { const setup = testCase.customSetup ? undefined : setupUpdatedRootRefresh(); - const context = await testCase.invoke(); - const root = setup?.root ?? runCommandWithTimeout.mock.calls[0]?.[1]?.cwd; + const context = (await testCase.invoke()) as { originalCwd: string } | undefined; + const runCommandWithTimeoutMock = vi.mocked(runCommandWithTimeout) as unknown as { + mock: { calls: Array<[unknown, { cwd?: string }?]> }; + }; + const root = setup?.root ?? runCommandWithTimeoutMock.mock.calls[0]?.[1]?.cwd; const entryPath = setup?.entryPath ?? path.join(String(root), "dist", "entry.js"); expect(runCommandWithTimeout).toHaveBeenCalledWith( diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index 911ca01f884..8cf984522e2 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -33,6 +33,7 @@ function readFirstFetchRequest(fetchMock: { mock: { calls: unknown[][] } }) { type EmbeddingsModule = typeof import("./embeddings.js"); type AuthModule = typeof import("../agents/model-auth.js"); +type ResolvedProviderAuth = Awaited>; let authModule: AuthModule; let createEmbeddingProvider: EmbeddingsModule["createEmbeddingProvider"]; @@ -336,12 +337,18 @@ describe("embedding provider auto selection", () => { }); it("selects the first available remote provider in auto mode", async () => { - for (const testCase of [ + const cases: Array<{ + name: string; + expectedProvider: "openai" | "gemini" | "mistral"; + fetchMockFactory: typeof createFetchMock | typeof createGeminiFetchMock; + resolveApiKey: (provider: string) => ResolvedProviderAuth; + expectedUrl: string; + }> = [ { name: "openai first", expectedProvider: "openai" as const, fetchMockFactory: createFetchMock, - resolveApiKey(provider: string) { + resolveApiKey(provider: string): ResolvedProviderAuth { if (provider === "openai") { return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" }; } @@ -353,12 +360,16 @@ describe("embedding provider auto selection", () => { name: "gemini fallback", expectedProvider: "gemini" as const, fetchMockFactory: createGeminiFetchMock, - resolveApiKey(provider: string) { + resolveApiKey(provider: string): ResolvedProviderAuth { if (provider === "openai") { throw new Error('No API key found for provider "openai".'); } if (provider === "google") { - return { apiKey: "gemini-key", source: "env: GEMINI_API_KEY", mode: "api-key" }; + return { + apiKey: "gemini-key", + source: "env: GEMINI_API_KEY", + mode: "api-key" as const, + }; } throw new Error(`Unexpected provider ${provider}`); }, @@ -368,15 +379,21 @@ describe("embedding provider auto selection", () => { name: "mistral after earlier misses", expectedProvider: "mistral" as const, fetchMockFactory: createFetchMock, - resolveApiKey(provider: string) { + resolveApiKey(provider: string): ResolvedProviderAuth { if (provider === "mistral") { - return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; + return { + apiKey: "mistral-key", + source: "env: MISTRAL_API_KEY", + mode: "api-key" as const, + }; } throw new Error(`No API key found for provider "${provider}".`); }, expectedUrl: "https://api.mistral.ai/v1/embeddings", }, - ]) { + ]; + + for (const testCase of cases) { vi.resetAllMocks(); vi.unstubAllGlobals(); const fetchMock = testCase.fetchMockFactory(); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index b683ecbb945..56f0bdafa26 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -59,6 +59,18 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { getChatChannelMeta } from "../channels/registry.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { DEFAULT_SECRET_FILE_MAX_BYTES, diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 273df91e908..a249fde385d 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -32,6 +32,7 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatDocsLink } from "../terminal/links.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 5481c117be6..f3dfba82120 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -18,6 +18,8 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { formatDocsLink } from "../terminal/links.js"; export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index da3d839e356..bac479002b4 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -21,11 +21,15 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatCliCommand } from "../cli/command-format.js"; +export { formatDocsLink } from "../terminal/links.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, } from "../channels/plugins/normalize/signal.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { installSignalCli } from "../plugins/signal-cli-install.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 0e8ce16aef2..113e705ede9 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -21,6 +21,7 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatDocsLink } from "../terminal/links.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index db53fa92a35..672bde385c5 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -24,6 +24,8 @@ export type { StickerMetadata } from "../../extensions/telegram/src/bot/types.js export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; +export { formatCliCommand } from "../cli/command-format.js"; +export { formatDocsLink } from "../terminal/links.js"; export { PAIRING_APPROVED_MESSAGE, diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 3727cc802ec..ed66e212021 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -28,6 +28,8 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatCliCommand } from "../cli/command-format.js"; +export { formatDocsLink } from "../terminal/links.js"; export { formatWhatsAppConfigAllowFromEntries, resolveWhatsAppConfigAllowFrom, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 9e3e98cb821..691cd7e7607 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -44,6 +44,7 @@ const { } = await importFreshPluginTestModules(); type TempPlugin = { dir: string; file: string; id: string }; +type PluginLoadConfig = NonNullable[0]>["config"]; function chmodSafeDir(dir: string) { if (process.platform === "win32") { @@ -862,7 +863,7 @@ describe("loadOpenClawPlugins", () => { telegram: { enabled: true }, }, }, - } satisfies Parameters[0]["config"], + } satisfies PluginLoadConfig, assert: (registry: ReturnType) => { expectTelegramLoaded(registry); }, @@ -878,7 +879,7 @@ describe("loadOpenClawPlugins", () => { plugins: { enabled: true, }, - } satisfies Parameters[0]["config"], + } satisfies PluginLoadConfig, assert: (registry: ReturnType) => { expectTelegramLoaded(registry); }, @@ -896,7 +897,7 @@ describe("loadOpenClawPlugins", () => { telegram: { enabled: false }, }, }, - } satisfies Parameters[0]["config"], + } satisfies PluginLoadConfig, assert: (registry: ReturnType) => { const telegram = registry.plugins.find((entry) => entry.id === "telegram"); expect(telegram?.status).toBe("disabled"); @@ -1873,7 +1874,9 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip const registry = loadRegistryFromAllowedPlugins([first, second]); expect(scenario.selectCount(registry), scenario.label).toBe(1); - scenario.assertPrimaryOwner?.(registry); + if ("assertPrimaryOwner" in scenario) { + scenario.assertPrimaryOwner?.(registry); + } expect( registry.diagnostics.some( (diag) => @@ -2692,7 +2695,7 @@ module.exports = { const overridden = entries.find((entry) => entry.status === "disabled"); expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin); expect(overridden?.origin, scenario.label).toBe(scenario.expectedDisabledOrigin); - if (scenario.expectedDisabledError) { + if ("expectedDisabledError" in scenario) { expect(overridden?.error, scenario.label).toContain(scenario.expectedDisabledError); } } @@ -2998,8 +3001,10 @@ module.exports = { ] as const; for (const scenario of scenarios) { - const { registry, warnings, pluginId, expectWarning, expectedSource } = - scenario.loadRegistry(); + const loadedScenario = scenario.loadRegistry(); + const { registry, warnings, pluginId, expectWarning } = loadedScenario; + const expectedSource = + "expectedSource" in loadedScenario ? loadedScenario.expectedSource : undefined; const plugin = registry.plugins.find((entry) => entry.id === pluginId); expect(plugin?.status, scenario.label).toBe("loaded"); if (expectedSource) { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 1e1db1b6d8f..6a8e72f6f2e 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -469,12 +469,10 @@ description: test skill }, ] as const; - await Promise.all( - cases.map(async (testCase) => { - const res = await testCase.run(); - testCase.assert(res); - }), - ); + for (const testCase of cases) { + const res = await testCase.run(); + testCase.assert(res); + } }); it("scores dangerous gateway.tools.allow over HTTP by exposure", async () => { @@ -1072,11 +1070,12 @@ description: test skill }, assert: ( res: SecurityAuditReport, - fixture: { stateDir: string; workspaceDir: string; outsideSkillPath: string }, + fixture: { stateDir: string; workspaceDir: string; outsideSkillPath?: string }, ) => { const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape"); expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain(fixture.outsideSkillPath); + expect(fixture.outsideSkillPath).toBeTruthy(); + expect(finding?.detail).toContain(fixture.outsideSkillPath ?? ""); }, }, { @@ -1258,7 +1257,8 @@ description: test skill ), ); } - for (const checkId of testCase.expectedAbsent ?? []) { + const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; + for (const checkId of expectedAbsent) { expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); } }), @@ -1313,7 +1313,8 @@ description: test skill for (const text of testCase.detailIncludes) { expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); } - for (const text of testCase.detailExcludes ?? []) { + const detailExcludes = "detailExcludes" in testCase ? testCase.detailExcludes : []; + for (const text of detailExcludes) { expect(finding?.detail, `${testCase.name}:${text}`).not.toContain(text); } }), @@ -1359,15 +1360,20 @@ description: test skill await Promise.all( cases.map(async (testCase) => { const res = await audit(testCase.cfg); - if (testCase.expectedAbsent) { + if ("expectedAbsent" in testCase && testCase.expectedAbsent) { expectNoFinding(res, "gateway.nodes.allow_commands_dangerous"); return; } + const expectedSeverity = + "expectedSeverity" in testCase ? testCase.expectedSeverity : undefined; + if (!expectedSeverity) { + return; + } const finding = res.findings.find( (f) => f.checkId === "gateway.nodes.allow_commands_dangerous", ); - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + expect(finding?.severity, testCase.name).toBe(expectedSeverity); expect(finding?.detail, testCase.name).toContain("camera.snap"); expect(finding?.detail, testCase.name).toContain("screen.record"); }), @@ -1550,7 +1556,7 @@ description: test skill for (const testCase of cases) { const res = await audit(testCase.cfg); - if (testCase.expectedFinding) { + if ("expectedFinding" in testCase) { expect(res.findings, testCase.name).toEqual( expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), ); @@ -2824,9 +2830,10 @@ description: test skill await Promise.all( cases.map(async (testCase) => { - const res = await audit(testCase.cfg, testCase.env ? { env: testCase.env } : undefined); + const env = "env" in testCase ? testCase.env : undefined; + const res = await audit(testCase.cfg, env ? { env } : undefined); expectFinding(res, testCase.expectedFinding, testCase.expectedSeverity); - if (testCase.expectedExtraFinding) { + if ("expectedExtraFinding" in testCase) { expectFinding( res, testCase.expectedExtraFinding.checkId, @@ -3111,10 +3118,12 @@ description: test skill for (const testCase of cases) { const res = await testCase.run(); - for (const checkId of testCase.expectedPresent ?? []) { + const expectedPresent = "expectedPresent" in testCase ? testCase.expectedPresent : []; + for (const checkId of expectedPresent) { expect(hasFinding(res, checkId, "warn"), `${testCase.name}:${checkId}`).toBe(true); } - for (const checkId of testCase.expectedAbsent ?? []) { + const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; + for (const checkId of expectedAbsent) { expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); } } From 0aff1c76309924525ee616cace04a604d789d5fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:23:15 -0700 Subject: [PATCH 102/393] feat(agents): infer image generation defaults --- .../openclaw-tools.image-generation.test.ts | 45 +++++++- src/agents/tools/image-generate-tool.test.ts | 103 +++++++++++++----- src/agents/tools/image-generate-tool.ts | 102 +++++++++++++---- src/agents/tools/image-tool.helpers.ts | 14 +-- src/agents/tools/image-tool.ts | 103 +++++------------- src/agents/tools/media-tool-shared.ts | 18 ++- src/agents/tools/model-config.helpers.ts | 64 ++++++++++- 7 files changed, 308 insertions(+), 141 deletions(-) diff --git a/src/agents/openclaw-tools.image-generation.test.ts b/src/agents/openclaw-tools.image-generation.test.ts index dd237115ab7..9ad49f66371 100644 --- a/src/agents/openclaw-tools.image-generation.test.ts +++ b/src/agents/openclaw-tools.image-generation.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import * as imageGenerationRuntime from "../image-generation/runtime.js"; import { createOpenClawTools } from "./openclaw-tools.js"; vi.mock("../plugins/tools.js", () => ({ @@ -10,7 +11,33 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function stubImageGenerationProviders() { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "openai", + defaultModel: "gpt-image-1", + models: ["gpt-image-1"], + supportedSizes: ["1024x1024"], + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); +} + describe("openclaw tools image generation registration", () => { + beforeEach(() => { + vi.stubEnv("OPENAI_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEYS", ""); + vi.stubEnv("GEMINI_API_KEY", ""); + vi.stubEnv("GEMINI_API_KEYS", ""); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + it("registers image_generate when image-generation config is present", () => { const tools = createOpenClawTools({ config: asConfig({ @@ -28,7 +55,21 @@ describe("openclaw tools image generation registration", () => { expect(tools.map((tool) => tool.name)).toContain("image_generate"); }); - it("omits image_generate when image-generation config is absent", () => { + it("registers image_generate when a compatible provider has env-backed auth", () => { + stubImageGenerationProviders(); + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + + const tools = createOpenClawTools({ + config: asConfig({}), + agentDir: "/tmp/openclaw-agent-main", + }); + + expect(tools.map((tool) => tool.name)).toContain("image_generate"); + }); + + it("omits image_generate when config is absent and no compatible provider auth exists", () => { + stubImageGenerationProviders(); + const tools = createOpenClawTools({ config: asConfig({}), agentDir: "/tmp/openclaw-agent-main", diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 97f324921e3..86f5aaf07d9 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -1,19 +1,89 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as imageGenerationRuntime from "../../image-generation/runtime.js"; import * as imageOps from "../../media/image-ops.js"; import * as mediaStore from "../../media/store.js"; import * as webMedia from "../../plugin-sdk/web-media.js"; -import { createImageGenerateTool } from "./image-generate-tool.js"; +import { + createImageGenerateTool, + resolveImageGenerationModelConfigForTool, +} from "./image-generate-tool.js"; + +function stubImageGenerationProviders() { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "google", + defaultModel: "gemini-3.1-flash-image-preview", + models: ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"], + supportedResolutions: ["1K", "2K", "4K"], + supportsImageEditing: true, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + { + id: "openai", + defaultModel: "gpt-image-1", + models: ["gpt-image-1"], + supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], + supportsImageEditing: false, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); +} describe("createImageGenerateTool", () => { - afterEach(() => { - vi.restoreAllMocks(); + beforeEach(() => { + vi.stubEnv("OPENAI_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEYS", ""); + vi.stubEnv("GEMINI_API_KEY", ""); + vi.stubEnv("GEMINI_API_KEYS", ""); }); - it("returns null when image-generation model is not configured", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("returns null when no image-generation model can be inferred", () => { + stubImageGenerationProviders(); expect(createImageGenerateTool({ config: {} })).toBeNull(); }); + it("infers an OpenAI image-generation model from env-backed auth", () => { + stubImageGenerationProviders(); + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + + expect(resolveImageGenerationModelConfigForTool({ cfg: {} })).toEqual({ + primary: "openai/gpt-image-1", + }); + expect(createImageGenerateTool({ config: {} })).not.toBeNull(); + }); + + it("prefers the primary model provider when multiple image providers have auth", () => { + stubImageGenerationProviders(); + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + vi.stubEnv("GEMINI_API_KEY", "gemini-test"); + + expect( + resolveImageGenerationModelConfigForTool({ + cfg: { + agents: { + defaults: { + model: { + primary: "google/gemini-3.1-pro-preview", + }, + }, + }, + }, + }), + ).toEqual({ + primary: "google/gemini-3.1-flash-image-preview", + fallbacks: ["openai/gpt-image-1"], + }); + }); + it("generates images and returns MEDIA paths", async () => { const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({ provider: "openai", @@ -215,28 +285,7 @@ describe("createImageGenerateTool", () => { }); it("lists registered provider and model options", async () => { - vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ - { - id: "google", - defaultModel: "gemini-3.1-flash-image-preview", - models: ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, - generateImage: vi.fn(async () => { - throw new Error("not used"); - }), - }, - { - id: "openai", - defaultModel: "gpt-image-1", - models: ["gpt-image-1"], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], - supportsImageEditing: false, - generateImage: vi.fn(async () => { - throw new Error("not used"); - }), - }, - ]); + stubImageGenerationProviders(); const tool = createImageGenerateTool({ config: { diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 810bfe3ba6f..057b9013100 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -15,7 +15,17 @@ import { loadWebMedia } from "../../plugin-sdk/web-media.js"; import { resolveUserPath } from "../../utils.js"; import { ToolInputError, readNumberParam, readStringParam } from "./common.js"; import { decodeDataUrl } from "./image-tool.helpers.js"; -import { resolveMediaToolLocalRoots } from "./media-tool-shared.js"; +import { + applyImageGenerationModelConfigDefaults, + resolveMediaToolLocalRoots, +} from "./media-tool-shared.js"; +import { + buildToolModelConfigFromCandidates, + coerceToolModelConfig, + hasToolModelConfig, + resolveDefaultModelRef, + type ToolModelConfig, +} from "./model-config.helpers.js"; import { createSandboxBridgeReadFile, resolveSandboxedBridgeMediaPath, @@ -71,15 +81,51 @@ const ImageGenerateToolSchema = Type.Object({ ), }); -function hasConfiguredImageGenerationModel(cfg: OpenClawConfig): boolean { - const configured = cfg.agents?.defaults?.imageGenerationModel; - if (typeof configured === "string") { - return configured.trim().length > 0; +function resolveImageGenerationModelCandidates( + cfg: OpenClawConfig | undefined, +): Array { + const providerDefaults = new Map(); + for (const provider of listRuntimeImageGenerationProviders({ config: cfg })) { + const providerId = provider.id.trim(); + const modelId = provider.defaultModel?.trim(); + if (!providerId || !modelId || providerDefaults.has(providerId)) { + continue; + } + providerDefaults.set(providerId, `${providerId}/${modelId}`); } - if (configured?.primary?.trim()) { - return true; + + const orderedProviders = [ + resolveDefaultModelRef(cfg).provider, + "openai", + "google", + ...providerDefaults.keys(), + ]; + const orderedRefs: string[] = []; + const seen = new Set(); + for (const providerId of orderedProviders) { + const ref = providerDefaults.get(providerId); + if (!ref || seen.has(ref)) { + continue; + } + seen.add(ref); + orderedRefs.push(ref); } - return (configured?.fallbacks ?? []).some((entry) => entry.trim().length > 0); + return orderedRefs; +} + +export function resolveImageGenerationModelConfigForTool(params: { + cfg?: OpenClawConfig; + agentDir?: string; +}): ToolModelConfig | null { + const explicit = coerceToolModelConfig(params.cfg?.agents?.defaults?.imageGenerationModel); + if (hasToolModelConfig(explicit)) { + return explicit; + } + return buildToolModelConfigFromCandidates({ + explicit, + agentDir: params.agentDir, + candidates: resolveImageGenerationModelCandidates(params.cfg), + }); } function resolveAction(args: Record): "generate" | "list" { @@ -274,9 +320,15 @@ export function createImageGenerateTool(options?: { fsPolicy?: ToolFsPolicy; }): AnyAgentTool | null { const cfg = options?.config ?? loadConfig(); - if (!hasConfiguredImageGenerationModel(cfg)) { + const imageGenerationModelConfig = resolveImageGenerationModelConfigForTool({ + cfg, + agentDir: options?.agentDir, + }); + if (!imageGenerationModelConfig) { return null; } + const effectiveCfg = + applyImageGenerationModelConfigDefaults(cfg, imageGenerationModelConfig) ?? cfg; const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, { workspaceOnly: options?.fsPolicy?.workspaceOnly === true, }); @@ -293,25 +345,27 @@ export function createImageGenerateTool(options?: { label: "Image Generation", name: "image_generate", description: - 'Generate new images or edit reference images with the configured image-generation model. Use action="list" to inspect available providers/models. Generated images are delivered automatically from the tool result as MEDIA paths.', + 'Generate new images or edit reference images with the configured or inferred image-generation model. Use action="list" to inspect available providers/models. Generated images are delivered automatically from the tool result as MEDIA paths.', parameters: ImageGenerateToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const action = resolveAction(params); if (action === "list") { - const providers = listRuntimeImageGenerationProviders({ config: cfg }).map((provider) => ({ - id: provider.id, - ...(provider.label ? { label: provider.label } : {}), - ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}), - models: provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []), - ...(provider.supportedSizes ? { supportedSizes: [...provider.supportedSizes] } : {}), - ...(provider.supportedResolutions - ? { supportedResolutions: [...provider.supportedResolutions] } - : {}), - ...(typeof provider.supportsImageEditing === "boolean" - ? { supportsImageEditing: provider.supportsImageEditing } - : {}), - })); + const providers = listRuntimeImageGenerationProviders({ config: effectiveCfg }).map( + (provider) => ({ + id: provider.id, + ...(provider.label ? { label: provider.label } : {}), + ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}), + models: provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []), + ...(provider.supportedSizes ? { supportedSizes: [...provider.supportedSizes] } : {}), + ...(provider.supportedResolutions + ? { supportedResolutions: [...provider.supportedResolutions] } + : {}), + ...(typeof provider.supportsImageEditing === "boolean" + ? { supportsImageEditing: provider.supportsImageEditing } + : {}), + }), + ); const lines = providers.flatMap((provider) => { const caps: string[] = []; if (provider.supportsImageEditing) { @@ -360,7 +414,7 @@ export function createImageGenerateTool(options?: { : undefined); const result = await generateImage({ - cfg, + cfg: effectiveCfg, prompt, agentDir: options?.agentDir, modelOverride: model, diff --git a/src/agents/tools/image-tool.helpers.ts b/src/agents/tools/image-tool.helpers.ts index a1581cb2b94..f0e088b4092 100644 --- a/src/agents/tools/image-tool.helpers.ts +++ b/src/agents/tools/image-tool.helpers.ts @@ -1,12 +1,9 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; -import { - resolveAgentModelFallbackValues, - resolveAgentModelPrimaryValue, -} from "../../config/model-input.js"; import { extractAssistantText } from "../pi-embedded-utils.js"; +import { coerceToolModelConfig, type ToolModelConfig } from "./model-config.helpers.js"; -export type ImageModelConfig = { primary?: string; fallbacks?: string[] }; +export type ImageModelConfig = ToolModelConfig; export function decodeDataUrl(dataUrl: string): { buffer: Buffer; @@ -55,12 +52,7 @@ export function coerceImageAssistantText(params: { } export function coerceImageModelConfig(cfg?: OpenClawConfig): ImageModelConfig { - const primary = resolveAgentModelPrimaryValue(cfg?.agents?.defaults?.imageModel); - const fallbacks = resolveAgentModelFallbackValues(cfg?.agents?.defaults?.imageModel); - return { - ...(primary?.trim() ? { primary: primary.trim() } : {}), - ...(fallbacks.length > 0 ? { fallbacks } : {}), - }; + return coerceToolModelConfig(cfg?.agents?.defaults?.imageModel); } export function resolveProviderVisionModelFromConfig(params: { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 8dd471b8a7d..39f755fdffd 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -18,7 +18,11 @@ import { resolveMediaToolLocalRoots, resolvePromptAndModelOverride, } from "./media-tool-shared.js"; -import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js"; +import { + buildToolModelConfigFromCandidates, + hasToolModelConfig, + resolveDefaultModelRef, +} from "./model-config.helpers.js"; import { createSandboxBridgeReadFile, resolveSandboxedBridgeMediaPath, @@ -68,89 +72,40 @@ export function resolveImageModelConfigForTool(params: { // because images are auto-injected into prompts (see attempt.ts detectAndLoadPromptImages). // The tool description is adjusted via modelHasVision to discourage redundant usage. const explicit = coerceImageModelConfig(params.cfg); - if (explicit.primary?.trim() || (explicit.fallbacks?.length ?? 0) > 0) { + if (hasToolModelConfig(explicit)) { return explicit; } const primary = resolveDefaultModelRef(params.cfg); - const openaiOk = hasAuthForProvider({ - provider: "openai", - agentDir: params.agentDir, - }); - const anthropicOk = hasAuthForProvider({ - provider: "anthropic", - agentDir: params.agentDir, - }); - - const fallbacks: string[] = []; - const addFallback = (modelRef: string | null) => { - const ref = (modelRef ?? "").trim(); - if (!ref) { - return; - } - if (fallbacks.includes(ref)) { - return; - } - fallbacks.push(ref); - }; const providerVisionFromConfig = resolveProviderVisionModelFromConfig({ cfg: params.cfg, provider: primary.provider, }); - const providerOk = hasAuthForProvider({ - provider: primary.provider, + const primaryCandidates = (() => { + if (isMinimaxVlmProvider(primary.provider)) { + return [`${primary.provider}/MiniMax-VL-01`]; + } + if (providerVisionFromConfig) { + return [providerVisionFromConfig]; + } + if (primary.provider === "zai") { + return ["zai/glm-4.6v"]; + } + if (primary.provider === "openai") { + return ["openai/gpt-5-mini"]; + } + if (primary.provider === "anthropic") { + return [ANTHROPIC_IMAGE_PRIMARY]; + } + return []; + })(); + + return buildToolModelConfigFromCandidates({ + explicit, agentDir: params.agentDir, + candidates: [...primaryCandidates, "openai/gpt-5-mini", ANTHROPIC_IMAGE_FALLBACK], }); - - let preferred: string | null = null; - - // MiniMax users: always try the canonical vision model first when auth exists. - if (isMinimaxVlmProvider(primary.provider) && providerOk) { - preferred = `${primary.provider}/MiniMax-VL-01`; - } else if (providerOk && providerVisionFromConfig) { - preferred = providerVisionFromConfig; - } else if (primary.provider === "zai" && providerOk) { - preferred = "zai/glm-4.6v"; - } else if (primary.provider === "openai" && openaiOk) { - preferred = "openai/gpt-5-mini"; - } else if (primary.provider === "anthropic" && anthropicOk) { - preferred = ANTHROPIC_IMAGE_PRIMARY; - } - - if (preferred?.trim()) { - if (openaiOk) { - addFallback("openai/gpt-5-mini"); - } - if (anthropicOk) { - addFallback(ANTHROPIC_IMAGE_FALLBACK); - } - // Don't duplicate primary in fallbacks. - const pruned = fallbacks.filter((ref) => ref !== preferred); - return { - primary: preferred, - ...(pruned.length > 0 ? { fallbacks: pruned } : {}), - }; - } - - // Cross-provider fallback when we can't pair with the primary provider. - if (openaiOk) { - if (anthropicOk) { - addFallback(ANTHROPIC_IMAGE_FALLBACK); - } - return { - primary: "openai/gpt-5-mini", - ...(fallbacks.length ? { fallbacks } : {}), - }; - } - if (anthropicOk) { - return { - primary: ANTHROPIC_IMAGE_PRIMARY, - fallbacks: [ANTHROPIC_IMAGE_FALLBACK], - }; - } - - return null; } function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undefined { @@ -279,7 +234,7 @@ export function createImageTool(options?: { const agentDir = options?.agentDir?.trim(); if (!agentDir) { const explicit = coerceImageModelConfig(options?.config); - if (explicit.primary?.trim() || (explicit.fallbacks?.length ?? 0) > 0) { + if (hasToolModelConfig(explicit)) { throw new Error("createImageTool requires agentDir when enabled"); } return null; diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 56f4a92ca97..9326935b72f 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -2,6 +2,7 @@ import { type Api, type Model } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; import { getDefaultLocalRoots } from "../../plugin-sdk/web-media.js"; import type { ImageModelConfig } from "./image-tool.helpers.js"; +import type { ToolModelConfig } from "./model-config.helpers.js"; import { getApiKeyForModel, normalizeWorkspaceDir, requireApiKey } from "./tool-runtime.helpers.js"; type TextToolAttempt = { @@ -20,6 +21,21 @@ type TextToolResult = { export function applyImageModelConfigDefaults( cfg: OpenClawConfig | undefined, imageModelConfig: ImageModelConfig, +): OpenClawConfig | undefined { + return applyAgentDefaultModelConfig(cfg, "imageModel", imageModelConfig); +} + +export function applyImageGenerationModelConfigDefaults( + cfg: OpenClawConfig | undefined, + imageGenerationModelConfig: ToolModelConfig, +): OpenClawConfig | undefined { + return applyAgentDefaultModelConfig(cfg, "imageGenerationModel", imageGenerationModelConfig); +} + +function applyAgentDefaultModelConfig( + cfg: OpenClawConfig | undefined, + key: "imageModel" | "imageGenerationModel", + modelConfig: ToolModelConfig, ): OpenClawConfig | undefined { if (!cfg) { return undefined; @@ -30,7 +46,7 @@ export function applyImageModelConfigDefaults( ...cfg.agents, defaults: { ...cfg.agents?.defaults, - imageModel: imageModelConfig, + [key]: modelConfig, }, }, }; diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts index 6f002238d88..3d6700c90f7 100644 --- a/src/agents/tools/model-config.helpers.ts +++ b/src/agents/tools/model-config.helpers.ts @@ -1,9 +1,22 @@ import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../../config/model-input.js"; +import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveEnvApiKey } from "../model-auth.js"; import { resolveConfiguredModelRef } from "../model-selection.js"; +export type ToolModelConfig = { primary?: string; fallbacks?: string[] }; + +export function hasToolModelConfig(model: ToolModelConfig | undefined): boolean { + return Boolean( + model?.primary?.trim() || (model?.fallbacks ?? []).some((entry) => entry.trim().length > 0), + ); +} + export function resolveDefaultModelRef(cfg?: OpenClawConfig): { provider: string; model: string } { if (cfg) { const resolved = resolveConfiguredModelRef({ @@ -16,12 +29,59 @@ export function resolveDefaultModelRef(cfg?: OpenClawConfig): { provider: string return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL }; } -export function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean { +export function hasAuthForProvider(params: { provider: string; agentDir?: string }): boolean { if (resolveEnvApiKey(params.provider)?.apiKey) { return true; } - const store = ensureAuthProfileStore(params.agentDir, { + const agentDir = params.agentDir?.trim(); + if (!agentDir) { + return false; + } + const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, }); return listProfilesForProvider(store, params.provider).length > 0; } + +export function coerceToolModelConfig(model?: AgentModelConfig): ToolModelConfig { + const primary = resolveAgentModelPrimaryValue(model); + const fallbacks = resolveAgentModelFallbackValues(model); + return { + ...(primary?.trim() ? { primary: primary.trim() } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), + }; +} + +export function buildToolModelConfigFromCandidates(params: { + explicit: ToolModelConfig; + agentDir?: string; + candidates: Array; +}): ToolModelConfig | null { + if (hasToolModelConfig(params.explicit)) { + return params.explicit; + } + + const deduped: string[] = []; + for (const candidate of params.candidates) { + const trimmed = candidate?.trim(); + if (!trimmed || !trimmed.includes("/")) { + continue; + } + const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); + if (!provider || !hasAuthForProvider({ provider, agentDir: params.agentDir })) { + continue; + } + if (!deduped.includes(trimmed)) { + deduped.push(trimmed); + } + } + + if (deduped.length === 0) { + return null; + } + + return { + primary: deduped[0], + ...(deduped.length > 1 ? { fallbacks: deduped.slice(1) } : {}), + }; +} From c94beb03b2389b81dac0d08f367940ca9af70aef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:23:21 -0700 Subject: [PATCH 103/393] docs(image-generation): document implicit tool enablement --- docs/concepts/models.md | 2 +- docs/gateway/configuration-reference.md | 1 + docs/tools/index.md | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 88cf928568e..6ed1d1de3ab 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -26,7 +26,7 @@ Related: - `agents.defaults.models` is the allowlist/catalog of models OpenClaw can use (plus aliases). - `agents.defaults.imageModel` is used **only when** the primary model can’t accept images. -- `agents.defaults.imageGenerationModel` is used by the shared image-generation capability. +- `agents.defaults.imageGenerationModel` is used by the shared image-generation capability. If omitted, `image_generate` can still infer a provider default from compatible auth-backed image-generation plugins. - Per-agent defaults can override `agents.defaults.model` via `agents.list[].model` plus bindings (see [/concepts/multi-agent](/concepts/multi-agent)). ## Quick model policy diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index dccd87da423..6cf6272483e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -905,6 +905,7 @@ Time format in system prompt. Default: `auto` (OS preference). - Also used as fallback routing when the selected/default model cannot accept image input. - `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared image-generation capability and any future tool/plugin surface that generates images. + - If omitted, `image_generate` can still infer a best-effort provider default from compatible auth-backed image-generation providers. - `pdfModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the `pdf` tool for model routing. - If omitted, the PDF tool falls back to `imageModel`, then to best-effort provider defaults. diff --git a/docs/tools/index.md b/docs/tools/index.md index 1dfe2b87703..f5eb956f13e 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -402,7 +402,7 @@ Notes: ### `image_generate` -Generate one or more images with the configured image-generation model. +Generate one or more images with the configured or inferred image-generation model. Core parameters: @@ -416,7 +416,8 @@ Core parameters: Notes: -- Only available when `agents.defaults.imageGenerationModel` is configured. +- Available when `agents.defaults.imageGenerationModel` is configured, or when OpenClaw can infer a compatible image-generation default from your enabled providers plus available auth. +- Explicit `agents.defaults.imageGenerationModel` still wins over any inferred default. - Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support. - Returns local `MEDIA:` lines so channels can deliver the generated files directly. - Uses the image-generation model directly (independent of the main chat model). From 39a8dab0da606e10fc98a0a83423692f637f5194 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:24:14 -0700 Subject: [PATCH 104/393] refactor: dedupe plugin lazy runtime helpers --- docs/tools/plugin.md | 4 +- extensions/bluebubbles/src/actions.ts | 8 ++-- extensions/bluebubbles/src/channel.ts | 8 ++-- .../src/monitor/provider.test-support.ts | 1 - extensions/feishu/src/channel.ts | 8 ++-- extensions/googlechat/src/channel.ts | 8 ++-- extensions/imessage/src/channel.ts | 8 +--- extensions/matrix/src/channel.ts | 8 ++-- extensions/msteams/src/channel.ts | 8 ++-- ...t-native-commands.skills-allowlist.test.ts | 8 ++-- .../telegram/src/bot-native-commands.test.ts | 2 +- extensions/tlon/src/channel.ts | 8 +--- extensions/zalo/src/actions.ts | 8 ++-- extensions/zalo/src/channel.ts | 8 +--- src/plugin-sdk/compat.ts | 16 ++++++++ src/plugin-sdk/lazy-runtime.ts | 2 + src/plugin-sdk/subpaths.test.ts | 7 ++++ src/shared/lazy-runtime.ts | 15 ++++++++ .../discord-provider.test-support.ts | 37 ++++++++++--------- .../extensions/telegram-plugin-command.ts | 0 20 files changed, 94 insertions(+), 78 deletions(-) delete mode 100644 extensions/discord/src/monitor/provider.test-support.ts rename extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts => test/helpers/extensions/telegram-plugin-command.ts (100%) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a5df54761cc..6d6a61b5e2c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -949,12 +949,14 @@ authoring plugins: - Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`, `openclaw/plugin-sdk/channel-config-schema`, `openclaw/plugin-sdk/channel-policy`, + `openclaw/plugin-sdk/lazy-runtime`, `openclaw/plugin-sdk/reply-history`, `openclaw/plugin-sdk/routing`, `openclaw/plugin-sdk/runtime-store`, and `openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers. - `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older - external plugins. Bundled plugins should not use it. + external plugins. Bundled plugins should not use it, and non-test imports emit + a one-time deprecation warning outside test environments. - `openclaw/plugin-sdk/telegram` for Telegram channel plugin types and shared channel-facing helpers. Built-in Telegram implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/discord` for Discord channel plugin types and shared channel-facing helpers. Built-in Discord implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/slack` for Slack channel plugin types and shared channel-facing helpers. Built-in Slack implementation internals stay private to the bundled extension. diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index bd797f5ee53..c9d96cb29ee 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -11,18 +11,16 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, } from "openclaw/plugin-sdk/bluebubbles"; -import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; -type BlueBubblesActionsRuntime = typeof import("./actions.runtime.js").blueBubblesActionsRuntime; - -const loadBlueBubblesActionsRuntime = createLazyRuntimeSurface( +const loadBlueBubblesActionsRuntime = createLazyRuntimeNamedExport( () => import("./actions.runtime.js"), - ({ blueBubblesActionsRuntime }) => blueBubblesActionsRuntime, + "blueBubblesActionsRuntime", ); const providerId = "bluebubbles"; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 5343fb501a2..9d9e49e74ab 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -18,7 +18,7 @@ import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, type ResolvedBlueBubblesAccount, @@ -38,11 +38,9 @@ import { parseBlueBubblesTarget, } from "./targets.js"; -type BlueBubblesChannelRuntime = typeof import("./channel.runtime.js").blueBubblesChannelRuntime; - -const loadBlueBubblesChannelRuntime = createLazyRuntimeSurface( +const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ blueBubblesChannelRuntime }) => blueBubblesChannelRuntime, + "blueBubblesChannelRuntime", ); const meta = { diff --git a/extensions/discord/src/monitor/provider.test-support.ts b/extensions/discord/src/monitor/provider.test-support.ts deleted file mode 100644 index 360210e3604..00000000000 --- a/extensions/discord/src/monitor/provider.test-support.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../test/helpers/extensions/discord-provider.test-support.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index ace8592497d..52d2e04aa1a 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -12,7 +12,7 @@ import { PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/feishu"; import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu"; -import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveFeishuAccount, resolveFeishuCredentials, @@ -42,11 +42,9 @@ const meta: ChannelMeta = { order: 70, }; -type FeishuChannelRuntime = typeof import("./channel.runtime.js").feishuChannelRuntime; - -const loadFeishuChannelRuntime = createLazyRuntimeSurface( +const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ feishuChannelRuntime }) => feishuChannelRuntime, + "feishuChannelRuntime", ); function setFeishuNamedAccountEnabled( diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index ffccbb9bbac..95aeccfbac2 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -27,7 +27,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/googlechat"; import { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat"; -import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listGoogleChatAccountIds, @@ -48,11 +48,9 @@ import { const meta = getChatChannelMeta("googlechat"); -type GoogleChatChannelRuntime = typeof import("./channel.runtime.js").googleChatChannelRuntime; - -const loadGoogleChatChannelRuntime = createLazyRuntimeSurface( +const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ googleChatChannelRuntime }) => googleChatChannelRuntime, + "googleChatChannelRuntime", ); const formatAllowFromEntry = (entry: string) => diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index bb36a33612c..fe20327e463 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -17,6 +17,7 @@ import { resolveIMessageGroupToolPolicy, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; @@ -25,12 +26,7 @@ import { imessageSetupAdapter } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; -let imessageChannelRuntimePromise: Promise | null = null; - -async function loadIMessageChannelRuntime() { - imessageChannelRuntimePromise ??= import("./channel.runtime.js"); - return imessageChannelRuntimePromise; -} +const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); function buildIMessageBaseSessionKey(params: { cfg: Parameters[0]["cfg"]; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 777017dcbf8..a7cc18208c3 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -7,7 +7,7 @@ import { buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildChannelConfigSchema, buildProbeChannelStatusSummary, @@ -39,11 +39,9 @@ import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); -type MatrixChannelRuntime = typeof import("./channel.runtime.js").matrixChannelRuntime; - -const loadMatrixChannelRuntime = createLazyRuntimeSurface( +const loadMatrixChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ matrixChannelRuntime }) => matrixChannelRuntime, + "matrixChannelRuntime", ); const meta = { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 7400f61e819..00430996001 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,6 +1,6 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, @@ -57,11 +57,9 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "Files.Read.All": "files (OneDrive)", }; -type MSTeamsChannelRuntime = typeof import("./channel.runtime.js").msTeamsChannelRuntime; - -const loadMSTeamsChannelRuntime = createLazyRuntimeSurface( +const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ msTeamsChannelRuntime }) => msTeamsChannelRuntime, + "msTeamsChannelRuntime", ); export const msteamsPlugin: ChannelPlugin = { diff --git a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index 29540bb9011..5a2b2552739 100644 --- a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -4,16 +4,16 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { writeSkill } from "../../../src/agents/skills.e2e-test-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + pluginCommandMocks, + resetPluginCommandMocks, +} from "../../../test/helpers/extensions/telegram-plugin-command.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; import { createNativeCommandTestParams, resetNativeCommandMenuMocks, waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; -import { - pluginCommandMocks, - resetPluginCommandMocks, -} from "./bot-native-commands.plugin-command-test-support.js"; const tempDirs: string[] = []; diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index a3f5ab3a9ce..e20806b11e4 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -8,7 +8,7 @@ import type { RuntimeEnv } from "../../../src/runtime.js"; import { pluginCommandMocks, resetPluginCommandMocks, -} from "./bot-native-commands.plugin-command-test-support.js"; +} from "../../../test/helpers/extensions/telegram-plugin-command.js"; const skillCommandMocks = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 5f754201ac1..daea0d8a52e 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,3 +1,4 @@ +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { @@ -17,12 +18,7 @@ import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const TLON_CHANNEL_ID = "tlon" as const; -let tlonChannelRuntimePromise: Promise | null = null; - -async function loadTlonChannelRuntime() { - tlonChannelRuntimePromise ??= import("./channel.runtime.js"); - return tlonChannelRuntimePromise; -} +const loadTlonChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); const tlonSetupWizardProxy = createTlonSetupWizardBase({ resolveConfigured: async ({ cfg }) => diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 67b6f42b8a7..201838f0b04 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -1,4 +1,4 @@ -import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, @@ -7,11 +7,9 @@ import type { import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; import { listEnabledZaloAccounts } from "./accounts.js"; -type ZaloActionsRuntime = typeof import("./actions.runtime.js").zaloActionsRuntime; - -const loadZaloActionsRuntime = createLazyRuntimeSurface( +const loadZaloActionsRuntime = createLazyRuntimeNamedExport( () => import("./actions.runtime.js"), - ({ zaloActionsRuntime }) => zaloActionsRuntime, + "zaloActionsRuntime", ); const providerId = "zalo"; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 16e4560cd14..80b03ea00c5 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -5,6 +5,7 @@ import { buildOpenGroupPolicyWarning, collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelAccountSnapshot, ChannelPlugin, @@ -56,12 +57,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { return trimmed.replace(/^(zalo|zl):/i, ""); } -let zaloChannelRuntimePromise: Promise | null = null; - -async function loadZaloChannelRuntime() { - zaloChannelRuntimePromise ??= import("./channel.runtime.js"); - return zaloChannelRuntimePromise; -} +const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); export const zaloPlugin: ChannelPlugin = { id: "zalo", diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 9f723eff1fa..ad8d9ff5293 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -1,6 +1,22 @@ // Legacy compat surface for external plugins that still depend on older // broad plugin-sdk imports. Keep this file intentionally small. +const shouldWarnCompatImport = + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test" && + process.env.OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING !== "1"; + +if (shouldWarnCompatImport) { + process.emitWarning( + "openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports.", + { + code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED", + detail: + "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating.", + }, + ); +} + export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; diff --git a/src/plugin-sdk/lazy-runtime.ts b/src/plugin-sdk/lazy-runtime.ts index ef8d7039373..e1f829204a2 100644 --- a/src/plugin-sdk/lazy-runtime.ts +++ b/src/plugin-sdk/lazy-runtime.ts @@ -1,5 +1,7 @@ export { + createLazyRuntimeModule, createLazyRuntimeMethod, createLazyRuntimeMethodBinder, + createLazyRuntimeNamedExport, createLazyRuntimeSurface, } from "../shared/lazy-runtime.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index dc10258e324..4e73ce9c26e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -7,6 +7,7 @@ import type { } from "openclaw/plugin-sdk/core"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; +import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; @@ -99,6 +100,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function"); }); + it("exports shared lazy runtime helpers from the dedicated subpath", () => { + expect(typeof lazyRuntimeSdk.createLazyRuntimeSurface).toBe("function"); + expect(typeof lazyRuntimeSdk.createLazyRuntimeModule).toBe("function"); + expect(typeof lazyRuntimeSdk.createLazyRuntimeNamedExport).toBe("function"); + }); + it("exports narrow self-hosted provider setup helpers", () => { expect(typeof selfHostedProviderSetupSdk.buildVllmProvider).toBe("function"); expect(typeof selfHostedProviderSetupSdk.buildSglangProvider).toBe("function"); diff --git a/src/shared/lazy-runtime.ts b/src/shared/lazy-runtime.ts index cbbccfe7dec..23f6a6039de 100644 --- a/src/shared/lazy-runtime.ts +++ b/src/shared/lazy-runtime.ts @@ -9,6 +9,21 @@ export function createLazyRuntimeSurface( }; } +/** Cache the raw dynamically imported runtime module behind a stable loader. */ +export function createLazyRuntimeModule( + importer: () => Promise, +): () => Promise { + return createLazyRuntimeSurface(importer, (module) => module); +} + +/** Cache a single named runtime export without repeating a custom selector closure per caller. */ +export function createLazyRuntimeNamedExport( + importer: () => Promise, + key: TKey, +): () => Promise { + return createLazyRuntimeSurface(importer, (module) => module[key]); +} + export function createLazyRuntimeMethod( load: () => Promise, select: (surface: TSurface) => (...args: TArgs) => TResult, diff --git a/test/helpers/extensions/discord-provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts index ca1d1fd0894..2c8ad988d04 100644 --- a/test/helpers/extensions/discord-provider.test-support.ts +++ b/test/helpers/extensions/discord-provider.test-support.ts @@ -255,6 +255,7 @@ export const baseConfig = (): OpenClawConfig => }) as OpenClawConfig; vi.mock("@buape/carbon", () => { + class Command {} class ReadyListener {} class RateLimitError extends Error { status = 429; @@ -292,7 +293,7 @@ vi.mock("@buape/carbon", () => { return clientGetPluginMock(name); } } - return { Client, RateLimitError, ReadyListener }; + return { Client, Command, RateLimitError, ReadyListener }; }); vi.mock("@buape/carbon/gateway", () => ({ @@ -377,23 +378,23 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async () => { }; }); -vi.mock("../discord/src/accounts.js", () => ({ +vi.mock("../../../extensions/discord/src/accounts.js", () => ({ resolveDiscordAccount: resolveDiscordAccountMock, })); -vi.mock("../discord/src/probe.js", () => ({ +vi.mock("../../../extensions/discord/src/probe.js", () => ({ fetchDiscordApplicationId: async () => "app-1", })); -vi.mock("../discord/src/token.js", () => ({ +vi.mock("../../../extensions/discord/src/token.js", () => ({ normalizeDiscordToken: (value?: string) => value, })); -vi.mock("../discord/src/voice/command.js", () => ({ +vi.mock("../../../extensions/discord/src/voice/command.js", () => ({ createDiscordVoiceCommand: () => ({ name: "voice-command" }), })); -vi.mock("../discord/src/monitor/agent-components.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/agent-components.js", () => ({ createAgentComponentButton: () => ({ id: "btn" }), createAgentSelectMenu: () => ({ id: "menu" }), createDiscordComponentButton: () => ({ id: "btn2" }), @@ -405,15 +406,15 @@ vi.mock("../discord/src/monitor/agent-components.js", () => ({ createDiscordComponentUserSelect: () => ({ id: "user" }), })); -vi.mock("../discord/src/monitor/auto-presence.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/auto-presence.js", () => ({ createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock, })); -vi.mock("../discord/src/monitor/commands.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/commands.js", () => ({ resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }), })); -vi.mock("../discord/src/monitor/exec-approvals.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/exec-approvals.js", () => ({ createExecApprovalButton: () => ({ id: "exec-approval" }), DiscordExecApprovalHandler: class DiscordExecApprovalHandler { async start() { @@ -425,11 +426,11 @@ vi.mock("../discord/src/monitor/exec-approvals.js", () => ({ }, })); -vi.mock("../discord/src/monitor/gateway-plugin.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({ createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }), })); -vi.mock("../discord/src/monitor/listeners.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/listeners.js", () => ({ DiscordMessageListener: class DiscordMessageListener {}, DiscordPresenceListener: class DiscordPresenceListener {}, DiscordReactionListener: class DiscordReactionListener {}, @@ -438,34 +439,34 @@ vi.mock("../discord/src/monitor/listeners.js", () => ({ registerDiscordListener: vi.fn(), })); -vi.mock("../discord/src/monitor/message-handler.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/message-handler.js", () => ({ createDiscordMessageHandler: createDiscordMessageHandlerMock, })); -vi.mock("../discord/src/monitor/native-command.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/native-command.js", () => ({ createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }), createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }), createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }), createDiscordNativeCommand: createDiscordNativeCommandMock, })); -vi.mock("../discord/src/monitor/presence.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/presence.js", () => ({ resolveDiscordPresenceUpdate: () => undefined, })); -vi.mock("../discord/src/monitor/provider.allowlist.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/provider.allowlist.js", () => ({ resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock, })); -vi.mock("../discord/src/monitor/provider.lifecycle.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/provider.lifecycle.js", () => ({ runDiscordGatewayLifecycle: monitorLifecycleMock, })); -vi.mock("../discord/src/monitor/rest-fetch.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/rest-fetch.js", () => ({ resolveDiscordRestFetch: () => async () => undefined, })); -vi.mock("../discord/src/monitor/thread-bindings.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/thread-bindings.js", () => ({ createNoopThreadBindingManager: createNoopThreadBindingManagerMock, createThreadBindingManager: createThreadBindingManagerMock, reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, diff --git a/extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts b/test/helpers/extensions/telegram-plugin-command.ts similarity index 100% rename from extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts rename to test/helpers/extensions/telegram-plugin-command.ts From 8139f83175b14067021ab3f7433181f77f7430c1 Mon Sep 17 00:00:00 2001 From: Kwest OG <50209930+yassinebkr@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:26:12 +0100 Subject: [PATCH 105/393] fix(telegram): persist sticky IPv4 fallback across polling restarts (fixes #48177) (#48282) * fix(telegram): persist sticky IPv4 fallback across polling restarts (fixes #48177) Hoist resolveTelegramTransport() out of createTelegramBot() so the transport (and its sticky IPv4 fallback state) persists across polling restarts. Previously, each polling restart created a new transport with stickyIpv4FallbackEnabled=false, causing repeated IPv6 timeouts on hosts with unstable IPv6 connectivity. Changes: - bot.ts: accept optional telegramTransport in TelegramBotOptions - monitor.ts: resolve transport once before polling loop - polling-session.ts: pass transport through to bot creation AI-assisted (Claude Sonnet 4). Tested: tsc --noEmit clean. * Update extensions/telegram/src/polling-session.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * style: fix oxfmt formatting in bot.ts * test: cover telegram transport reuse across restarts * fix: preserve telegram sticky IPv4 fallback across polling restarts (#48282) (thanks @yassinebkr) --------- Co-authored-by: Yassine Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + extensions/telegram/src/bot.ts | 12 ++++--- extensions/telegram/src/monitor.test.ts | 42 ++++++++++++++++++++++ extensions/telegram/src/monitor.ts | 7 ++++ extensions/telegram/src/polling-session.ts | 4 +++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4850dc72d..84702324273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: trigger overflow recovery from the tool-result guard once post-compaction context still exceeds the safe threshold, so long tool loops compact before the next model call hard-fails. (#29371) thanks @keshav55. - macOS/exec approvals: harden exec-host request HMAC verification to use a timing-safe compare and keep malformed or truncated signatures fail-closed in focused IPC auth coverage. - Gateway/exec approvals: surface requested env override keys in gateway-host approval prompts so operators can review surviving env context without inheriting noisy base host env. +- Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr. - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. ### Fixes diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index 6d1d7bc24b2..450c68b4aad 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -38,7 +38,7 @@ import { type TelegramUpdateKeyContext, } from "./bot-updates.js"; import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js"; -import { resolveTelegramTransport } from "./fetch.js"; +import { resolveTelegramTransport, type TelegramTransport } from "./fetch.js"; import { tagTelegramNetworkError } from "./network-errors.js"; import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; import { getTelegramSequentialKey } from "./sequential-key.js"; @@ -65,6 +65,8 @@ export type TelegramBotOptions = { mediaGroupFlushMs?: number; textFragmentGapMs?: number; }; + /** Pre-resolved Telegram transport to reuse across bot instances. If not provided, creates a new one. */ + telegramTransport?: TelegramTransport; }; export { getTelegramSequentialKey }; @@ -132,9 +134,11 @@ export function createTelegramBot(opts: TelegramBotOptions) { : null; const telegramCfg = account.config; - const telegramTransport = resolveTelegramTransport(opts.proxyFetch, { - network: telegramCfg.network, - }); + const telegramTransport = + opts.telegramTransport ?? + resolveTelegramTransport(opts.proxyFetch, { + network: telegramCfg.network, + }); const shouldProvideFetch = Boolean(telegramTransport.fetch); // grammY's ApiClientOptions types still track `node-fetch` types; Node 22+ global fetch // (undici) is structurally compatible at runtime but not assignable in TS. diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index c4a898c5a6d..d75b01c4608 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -82,6 +82,12 @@ const { readTelegramUpdateOffsetSpy } = vi.hoisted(() => ({ const { startTelegramWebhookSpy } = vi.hoisted(() => ({ startTelegramWebhookSpy: vi.fn(async () => ({ server: { close: vi.fn() }, stop: vi.fn() })), })); +const { resolveTelegramTransportSpy } = vi.hoisted(() => ({ + resolveTelegramTransportSpy: vi.fn(() => ({ + fetch: globalThis.fetch, + sourceFetch: globalThis.fetch, + })), +})); type RunnerStub = { task: () => Promise; @@ -267,6 +273,10 @@ vi.mock("./webhook.js", () => ({ startTelegramWebhook: startTelegramWebhookSpy, })); +vi.mock("./fetch.js", () => ({ + resolveTelegramTransport: resolveTelegramTransportSpy, +})); + vi.mock("./update-offset-store.js", () => ({ readTelegramUpdateOffset: readTelegramUpdateOffsetSpy, writeTelegramUpdateOffset: vi.fn(async () => undefined), @@ -298,6 +308,10 @@ describe("monitorTelegramProvider (grammY)", () => { computeBackoff.mockClear(); sleepWithAbort.mockClear(); startTelegramWebhookSpy.mockClear(); + resolveTelegramTransportSpy.mockReset().mockImplementation(() => ({ + fetch: globalThis.fetch, + sourceFetch: globalThis.fetch, + })); registerUnhandledRejectionHandlerMock.mockClear(); resetUnhandledRejection(); createTelegramBotErrors.length = 0; @@ -499,6 +513,34 @@ describe("monitorTelegramProvider (grammY)", () => { expect(runSpy).toHaveBeenCalledTimes(2); }); + it("reuses the resolved transport across polling restarts", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const telegramTransport = { + fetch: globalThis.fetch, + sourceFetch: globalThis.fetch, + }; + resolveTelegramTransportSpy.mockReturnValueOnce(telegramTransport); + + const abort = new AbortController(); + mockRunOnceWithStalledPollingRunner(); + mockRunOnceAndAbort(abort); + + const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); + await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1)); + + vi.advanceTimersByTime(120_000); + await monitor; + + expect(resolveTelegramTransportSpy).toHaveBeenCalledTimes(1); + expect(createTelegramBotCalls).toHaveLength(2); + expect(createTelegramBotCalls[0]?.telegramTransport).toBe(telegramTransport); + expect(createTelegramBotCalls[1]?.telegramTransport).toBe(telegramTransport); + } finally { + vi.useRealTimers(); + } + }); + it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => { const abort = new AbortController(); const { stop } = mockRunOnceWithStalledPollingRunner(); diff --git a/extensions/telegram/src/monitor.ts b/extensions/telegram/src/monitor.ts index 11530ad66ef..e703266f0f0 100644 --- a/extensions/telegram/src/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -9,6 +9,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; +import { resolveTelegramTransport } from "./fetch.js"; import { isRecoverableTelegramNetworkError, isTelegramPollingNetworkError, @@ -178,6 +179,11 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { return; } + // Create transport once to preserve sticky IPv4 fallback state across polling restarts + const telegramTransport = resolveTelegramTransport(proxyFetch, { + network: account.config.network, + }); + pollingSession = new TelegramPollingSession({ token, config: cfg, @@ -189,6 +195,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { getLastUpdateId: () => lastUpdateId, persistUpdateId, log, + telegramTransport, }); await pollingSession.runUntilAbort(); } finally { diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index 89342994387..59cbec7d589 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -4,6 +4,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import { formatDurationPrecise } from "openclaw/plugin-sdk/infra-runtime"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; +import { type TelegramTransport } from "./fetch.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; const TELEGRAM_POLL_RESTART_POLICY = { @@ -47,6 +48,8 @@ type TelegramPollingSessionOpts = { getLastUpdateId: () => number | null; persistUpdateId: (updateId: number) => Promise; log: (line: string) => void; + /** Pre-resolved Telegram transport to reuse across bot instances */ + telegramTransport?: TelegramTransport; }; export class TelegramPollingSession { @@ -135,6 +138,7 @@ export class TelegramPollingSession { lastUpdateId: this.opts.getLastUpdateId(), onUpdateId: this.opts.persistUpdateId, }, + telegramTransport: this.opts.telegramTransport, }); } catch (err) { await this.#waitBeforeRetryOnRecoverableSetupError(err, "Telegram setup network error"); From ea15819ecf2b2a2dee919599b2b38e42277cfabc Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 17 Mar 2026 17:27:52 +0100 Subject: [PATCH 106/393] ACP: harden startup and move configured routing behind plugin seams (#48197) * ACPX: keep plugin-local runtime installs out of dist * Gateway: harden ACP startup and service PATH * ACP: reinitialize error-state configured bindings * ACP: classify pre-turn runtime failures as session init failures * Plugins: move configured ACP routing behind channel seams * Telegram tests: align startup probe assertions after rebase * Discord: harden ACP configured binding recovery * ACP: recover Discord bindings after stale runtime exits * ACPX: replace dead sessions during ensure * Discord: harden ACP binding recovery * Discord: fix review follow-ups * ACP bindings: load channel snapshots across workspaces * ACP bindings: cache snapshot channel plugin resolution * Experiments: add ACP pluginification holy grail plan * Experiments: rename ACP pluginification plan doc * Experiments: drop old ACP pluginification doc path * ACP: move configured bindings behind plugin services * Experiments: update bindings capability architecture plan * Bindings: isolate configured binding routing and targets * Discord tests: fix runtime env helper path * Tests: fix channel binding CI regressions * Tests: normalize ACP workspace assertion on Windows * Bindings: isolate configured binding registry * Bindings: finish configured binding cleanup * Bindings: finish generic cleanup * Bindings: align runtime approval callbacks * ACP: delete residual bindings barrel * Bindings: restore legacy compatibility * Revert "Bindings: restore legacy compatibility" This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe. * Tests: drop ACP route legacy helper names * Discord/ACP: fix binding regressions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com> --- CHANGELOG.md | 3 + .../acp-pluginification-architecture-plan.md | 519 ++++++++++++++++++ extensions/acpx/src/config.test.ts | 19 + extensions/acpx/src/config.ts | 35 +- extensions/acpx/src/ensure.test.ts | 8 +- extensions/acpx/src/ensure.ts | 8 +- .../src/runtime-internals/process.test.ts | 54 +- .../acpx/src/runtime-internals/process.ts | 68 ++- extensions/acpx/src/runtime.test.ts | 84 +++ extensions/acpx/src/runtime.ts | 263 +++++++-- .../acpx/src/test-utils/runtime-fixtures.ts | 19 +- extensions/discord/src/channel.test.ts | 179 +++++- extensions/discord/src/channel.ts | 25 +- .../discord/src/monitor/gateway-plugin.ts | 117 +++- ...age-handler.preflight.acp-bindings.test.ts | 278 +++++++++- .../monitor/message-handler.preflight.test.ts | 89 ++- .../src/monitor/message-handler.preflight.ts | 137 ++++- .../native-command.plugin-dispatch.test.ts | 256 +++++++-- .../discord/src/monitor/native-command.ts | 32 +- .../src/monitor/provider.lifecycle.test.ts | 117 +++- .../discord/src/monitor/provider.lifecycle.ts | 146 ++++- .../src/monitor/provider.proxy.test.ts | 98 +++- .../monitor/thread-bindings.lifecycle.test.ts | 175 ++++-- .../src/monitor/thread-bindings.manager.ts | 223 ++++---- .../src/monitor/thread-session-close.test.ts | 12 +- extensions/feishu/src/bot.test.ts | 106 +++- extensions/feishu/src/bot.ts | 22 +- extensions/feishu/src/channel.ts | 12 +- extensions/feishu/src/probe.test.ts | 68 +-- extensions/lobster/src/lobster-tool.test.ts | 1 + .../bot-message-context.acp-bindings.test.ts | 128 ++++- .../telegram/src/bot-message-context.ts | 12 +- .../bot-native-commands.session-meta.test.ts | 240 ++++++-- .../telegram/src/bot-native-commands.ts | 8 +- .../bot/delivery.resolve-media-retry.test.ts | 35 +- extensions/telegram/src/channel.test.ts | 128 ++++- extensions/telegram/src/channel.ts | 12 +- extensions/telegram/src/conversation-route.ts | 21 +- extensions/telegram/src/send.test-harness.ts | 9 + .../telegram/src/target-writeback.test.ts | 19 +- src/acp/control-plane/manager.core.ts | 349 ++++++++---- src/acp/control-plane/manager.test.ts | 177 ++++++ src/acp/persistent-bindings.lifecycle.test.ts | 100 ++++ src/acp/persistent-bindings.lifecycle.ts | 36 +- src/acp/persistent-bindings.resolve.ts | 324 +---------- src/acp/persistent-bindings.route.ts | 81 --- src/acp/persistent-bindings.test.ts | 209 +++++-- src/acp/persistent-bindings.ts | 19 - src/acp/persistent-bindings.types.ts | 70 +++ src/acp/runtime/session-meta.ts | 1 + src/auto-reply/reply/acp-reset-target.ts | 4 +- src/auto-reply/reply/commands-core.ts | 4 +- .../reply/dispatch-from-config.test.ts | 57 +- src/auto-reply/reply/dispatch-from-config.ts | 9 +- src/auto-reply/reply/route-reply.test.ts | 25 +- src/bindings/records.ts | 48 ++ src/channels/plugins/acp-bindings.test.ts | 252 +++++++++ .../acp-configured-binding-consumer.ts | 155 ++++++ .../plugins/acp-stateful-target-driver.ts | 102 ++++ src/channels/plugins/binding-provider.ts | 14 + src/channels/plugins/binding-registry.ts | 46 ++ src/channels/plugins/binding-routing.ts | 91 +++ src/channels/plugins/binding-targets.test.ts | 209 +++++++ src/channels/plugins/binding-targets.ts | 69 +++ src/channels/plugins/binding-types.ts | 53 ++ .../plugins/configured-binding-builtins.ts | 13 + .../plugins/configured-binding-compiler.ts | 240 ++++++++ .../plugins/configured-binding-consumers.ts | 69 +++ .../plugins/configured-binding-match.ts | 116 ++++ .../plugins/configured-binding-registry.ts | 116 ++++ .../configured-binding-session-lookup.ts | 74 +++ .../plugins/stateful-target-builtins.ts | 13 + .../plugins/stateful-target-drivers.ts | 89 +++ src/channels/plugins/types.adapters.ts | 34 +- src/channels/plugins/types.plugin.ts | 4 +- src/channels/plugins/types.ts | 4 +- src/commands/daemon-install-helpers.test.ts | 24 + src/commands/daemon-install-helpers.ts | 4 + .../daemon-install-plan.shared.test.ts | 11 + src/commands/daemon-install-plan.shared.ts | 9 + .../node-daemon-install-helpers.test.ts | 93 ++++ src/commands/node-daemon-install-helpers.ts | 8 +- src/config/bindings.ts | 2 + src/config/sessions/sessions.test.ts | 68 +++ src/config/sessions/store.ts | 70 +++ src/daemon/service-env.test.ts | 37 ++ src/daemon/service-env.ts | 16 +- src/gateway/server-plugins.test.ts | 32 ++ src/gateway/server-plugins.ts | 2 + src/gateway/test-helpers.mocks.ts | 1 + src/plugin-sdk/conversation-runtime.ts | 41 +- src/plugin-sdk/discord.ts | 11 +- src/plugin-sdk/feishu.ts | 5 + src/plugin-sdk/index.test.ts | 39 +- src/plugin-sdk/index.ts | 17 + src/plugin-sdk/telegram.ts | 6 +- src/plugins/conversation-binding.test.ts | 115 ++++ src/plugins/conversation-binding.ts | 78 ++- src/plugins/registry.ts | 30 + src/plugins/types.ts | 23 + src/test-utils/channel-plugins.ts | 1 + test/helpers/extensions/plugin-api.ts | 1 + 102 files changed, 6606 insertions(+), 1199 deletions(-) create mode 100644 experiments/acp-pluginification-architecture-plan.md create mode 100644 src/acp/persistent-bindings.lifecycle.test.ts delete mode 100644 src/acp/persistent-bindings.route.ts delete mode 100644 src/acp/persistent-bindings.ts create mode 100644 src/bindings/records.ts create mode 100644 src/channels/plugins/acp-bindings.test.ts create mode 100644 src/channels/plugins/acp-configured-binding-consumer.ts create mode 100644 src/channels/plugins/acp-stateful-target-driver.ts create mode 100644 src/channels/plugins/binding-provider.ts create mode 100644 src/channels/plugins/binding-registry.ts create mode 100644 src/channels/plugins/binding-routing.ts create mode 100644 src/channels/plugins/binding-targets.test.ts create mode 100644 src/channels/plugins/binding-targets.ts create mode 100644 src/channels/plugins/binding-types.ts create mode 100644 src/channels/plugins/configured-binding-builtins.ts create mode 100644 src/channels/plugins/configured-binding-compiler.ts create mode 100644 src/channels/plugins/configured-binding-consumers.ts create mode 100644 src/channels/plugins/configured-binding-match.ts create mode 100644 src/channels/plugins/configured-binding-registry.ts create mode 100644 src/channels/plugins/configured-binding-session-lookup.ts create mode 100644 src/channels/plugins/stateful-target-builtins.ts create mode 100644 src/channels/plugins/stateful-target-drivers.ts create mode 100644 src/commands/node-daemon-install-helpers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 84702324273..c6bd8a223eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,7 @@ Docs: https://docs.openclaw.ai - Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham. - Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw. - Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk. +- ACP/acpx: keep plugin-local backend installs under `extensions/acpx` in live repo checkouts so rebuilds no longer delete the runtime binary, and avoid package-lock churn during runtime repair. - Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman. - Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj. - Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx. @@ -127,6 +128,8 @@ Docs: https://docs.openclaw.ai - Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc. - macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67. - macOS/launch at login: stop emitting `KeepAlive` for the desktop app launch agent so OpenClaw no longer relaunches immediately after a manual quit while launch at login remains enabled. (#40213) Thanks @stablegenius49. +- ACP/gateway startup: use direct Telegram and Discord startup/status helpers instead of routing probes through the plugin runtime, and prepend the selected daemon Node bin dir to service PATH so plugin-local installs can still find `npm` and `pnpm`. +- ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime. ## 2026.3.13 diff --git a/experiments/acp-pluginification-architecture-plan.md b/experiments/acp-pluginification-architecture-plan.md new file mode 100644 index 00000000000..b055c1800ce --- /dev/null +++ b/experiments/acp-pluginification-architecture-plan.md @@ -0,0 +1,519 @@ +# Bindings Capability Architecture Plan + +Status: in progress + +## Summary + +The goal is not to move all ACP code out of core. + +The goal is to make `bindings` a small core capability, keep the ACP session kernel in core, and move ACP-specific binding policy plus codex app server policy out of core. + +That gives us a lightweight core without hiding core semantics behind plugin indirection. + +## Current Conclusion + +The current architecture should converge on this split: + +- Core owns the generic binding capability. +- Core owns the generic ACP session kernel. +- Channel plugins own channel-specific binding semantics. +- ACP backend plugins own runtime protocol details. +- Product-level consumers like ACP configured bindings and the codex app server sit on top of the binding capability instead of hardcoding their own binding plumbing. + +This is different from "everything becomes a plugin". + +## Why This Changed + +The current codebase already shows that there are really three different layers: + +- binding and conversation ownership +- long-lived session and runtime-handle orchestration +- product-specific turn logic + +Those layers should not all be forced into one runtime engine. + +Today the duplication is mostly in the execution/control-plane shape, not in storage or binding plumbing: + +- the main harness has its own turn engine +- ACP has its own session control plane +- the codex app server plugin path likely owns its own app-level turn engine outside this repo + +The right move is to share the stable control-plane contracts, not to force all three into one giant executor. + +## Verified Current State + +### Generic binding pieces already exist + +- `src/infra/outbound/session-binding-service.ts` already provides a generic binding store and adapter model. +- `src/plugins/conversation-binding.ts` already lets plugins request a conversation binding and stores plugin-owned binding metadata. +- `src/plugins/types.ts` already exposes plugin-facing binding APIs. +- `src/plugins/types.ts` already exposes the generic `inbound_claim` hook. + +### ACP is only partially pluginified + +- `src/channels/plugins/configured-binding-registry.ts` now owns generic configured binding compilation and lookup. +- `src/channels/plugins/binding-routing.ts` and `src/channels/plugins/binding-targets.ts` now own the generic route and target lifecycle seams. +- ACP now plugs into that seam through `src/channels/plugins/acp-configured-binding-consumer.ts` and `src/channels/plugins/acp-stateful-target-driver.ts`. +- `src/acp/persistent-bindings.lifecycle.ts` still owns configured ACP ensure and reset behavior. +- runtime-created plugin conversation bindings still use a separate path in `src/plugins/conversation-binding.ts`. + +### Codex app server is already closer to the desired shape + +From this repo's side, the codex app server path is much thinner: + +- a plugin binds a conversation +- core stores that binding +- inbound dispatch targets the plugin's `inbound_claim` hook + +What core does not provide for the codex app server path is an ACP-like shared session kernel. If the app server needs retries, long-lived runtime handles, cancellation, or session health logic, it must own that itself today. + +## The Durable Split + +### 1. Core Binding Capability + +This should become the primary shared seam. + +Responsibilities: + +- canonical `ConversationRef` +- binding record storage +- configured binding compilation +- runtime-created binding storage +- fast binding lookup on inbound +- binding touch/unbind lifecycle +- generic dispatch handoff to the binding target + +What core binding capability must not own: + +- Discord thread rules +- Telegram topic rules +- Feishu chat rules +- ACP session orchestration +- codex app server business logic + +### 2. Core Stateful Target Kernel + +This is the small generic kernel for long-lived bound targets. + +Responsibilities: + +- ensure target ready +- run turn +- cancel turn +- close target +- reset target +- status and health +- persistence of target metadata +- retries and runtime-handle safety +- per-target serialization and concurrency + +ACP is the first real implementation of this shape. + +This kernel should stay in core because it is mandatory infrastructure and has strict startup, reset, and recovery semantics. + +### 3. Channel Binding Providers + +Each channel plugin should own the meaning of "this channel conversation maps to this binding rule". + +Responsibilities: + +- normalize configured binding targets +- normalize inbound conversations +- match inbound conversations against compiled bindings +- define channel-specific matching priority +- optionally provide binding description text for status and logs + +This is where Discord channel vs thread logic, Telegram topic rules, and Feishu conversation rules belong. + +### 4. Product Consumers + +Bindings are a shared capability. Different products should consume it differently. + +ACP configured bindings: + +- compile config rules +- resolve a target session +- ensure the ACP session is ready through the ACP kernel + +Codex app server: + +- create runtime-requested bindings +- claim inbound messages through plugin hooks +- optionally adopt the shared stateful target contract later if it really needs long-lived session orchestration + +Main harness: + +- does not need to become "a binding product" +- may eventually share small lifecycle contracts, but it should not be forced into the same engine as ACP + +## The Key Architectural Decision + +The shared abstraction should be: + +- `bindings` as the capability +- `stateful target drivers` as an optional lower-level contract + +The shared abstraction should not be: + +- "one runtime engine for main harness, ACP, and codex app server" + +That would overfit very different systems into one executor. + +## Stable Nouns + +Core should understand only stable nouns. + +The stable nouns are: + +- `ConversationRef` +- `BindingRule` +- `CompiledBinding` +- `BindingResolution` +- `BindingTargetDescriptor` +- `StatefulTargetDriver` +- `StatefulTargetHandle` + +ACP, codex app server, and future products should compile down to those nouns instead of leaking product-specific routing rules through core. + +## Proposed Capability Model + +### Binding capability + +The binding capability should support both configured bindings and runtime-created bindings. + +Required operations: + +- compile configured bindings at startup or reload +- resolve a binding from an inbound `ConversationRef` +- create a runtime binding +- touch and unbind an existing binding +- dispatch a resolved binding to its target + +### Binding target descriptor + +A resolved binding should point to a typed target descriptor rather than ad hoc ACP- or plugin-specific metadata blobs. + +The descriptor should be able to represent at least: + +- plugin-owned inbound claim targets +- stateful target drivers + +That means the same binding capability can support both: + +- codex app server plugin-bound conversations +- ACP configured bindings + +without pretending they are the same product. + +### Stateful target driver + +This is the reusable control-plane contract for long-lived bound targets. + +Required operations: + +- `ensureReady` +- `runTurn` +- `cancel` +- `close` +- `reset` +- `status` +- `health` + +ACP should remain the first built-in driver. + +If the codex app server later proves that it also needs durable session handles, it can either: + +- use a driver that consumes this contract, or +- keep its own product-owned runtime if that remains simpler + +That should be a product decision, not something forced by the binding capability. + +## Why ACP Kernel Stays In Core + +ACP's kernel should remain in core because session lifecycle, persistence, retries, cancellation, and runtime-handle safety are generic platform machinery. + +Those concerns are not channel-specific, and they are not codex-app-server-specific. + +If we move that machinery into an ordinary plugin, we create circular bootstrapping: + +- channels need it during startup and inbound routing +- reset and recovery need it when plugins may already be degraded +- failure semantics become special-case core logic anyway + +If we later wrap it in a "built-in capability module", that is still effectively core. + +## What Should Move Out Of Core + +The following should move out of ACP-shaped core code: + +- channel-specific configured binding matching +- channel-specific binding target normalization +- channel-specific recovery UX +- ACP-specific route wrapping helpers as named ACP seams +- codex app server fallback policy beyond generic plugin-bound dispatch behavior + +The following should stay: + +- generic binding storage and dispatch +- generic ACP control plane +- generic stateful target driver contract + +## Current Problems To Remove + +### Residual cleanup is now small + +Most ACP-era compatibility names are gone from the generic seam. + +The remaining cleanup is smaller: + +- `src/acp/persistent-bindings.ts` compatibility barrel can be deleted once tests stop importing it +- ACP-named tests and mocks can be renamed over time for consistency +- docs should stop describing already-removed ACP wrappers as if they still exist + +### Configured binding implementation is still too monolithic + +`src/channels/plugins/configured-binding-registry.ts` still mixes: + +- registry compilation +- cache invalidation +- inbound matching +- materialization of binding targets +- session-key reverse lookup + +That file is now generic, but still too large and too coupled. + +### Runtime-created plugin bindings still use a separate stack + +`src/plugins/conversation-binding.ts` is still a separate implementation path for plugin-created bindings. + +That means configured bindings and runtime-created bindings share storage, but not one consistent capability layer. + +### Generic registries still hardcode ACP as a built-in + +`src/channels/plugins/configured-binding-consumers.ts` and `src/channels/plugins/stateful-target-drivers.ts` still import ACP directly. + +That is acceptable for now, but the clean final shape is to keep ACP built in while registering it from a dedicated bootstrap point instead of wiring it inside the generic registry files. + +## Target Contracts + +### Channel binding provider contract + +Conceptually, each channel plugin should support: + +- `compileConfiguredBinding(binding, cfg) -> CompiledBinding | null` +- `resolveInboundConversation(event) -> ConversationRef | null` +- `matchInboundConversation(compiledBinding, conversation) -> BindingMatch | null` +- `describeBinding(compiledBinding) -> string | undefined` + +### Binding capability contract + +Core should support: + +- `compileConfiguredBindings(cfg, plugins) -> CompiledBindingRegistry` +- `resolveBinding(conversationRef) -> BindingResolution | null` +- `createRuntimeBinding(target, conversationRef, metadata) -> BindingRecord` +- `touchBinding(bindingId)` +- `unbindBinding(bindingId | target)` +- `dispatchResolvedBinding(bindingResolution, inboundEvent)` + +### Stateful target driver contract + +Core should support: + +- `ensureReady(targetRef, cfg)` +- `runTurn(targetRef, input)` +- `cancel(targetRef, reason)` +- `close(targetRef, reason)` +- `reset(targetRef, reason)` +- `status(targetRef)` +- `health(targetRef)` + +## File-Level Transition Plan + +### Keep + +- `src/infra/outbound/session-binding-service.ts` +- `src/acp/control-plane/*` +- `extensions/acpx/*` + +### Generalize + +- `src/plugins/conversation-binding.ts` + - fold runtime-created plugin bindings into the same generic binding capability instead of keeping a separate implementation stack +- `src/channels/plugins/configured-binding-registry.ts` + - split into compiler, matcher, and session-key resolution modules with a thin facade +- `src/channels/plugins/types.adapters.ts` + - finish removing ACP-era aliases after the deprecation window +- `src/plugin-sdk/conversation-runtime.ts` + - export only the generic binding capability surfaces +- `src/acp/persistent-bindings.lifecycle.ts` + - either become a generic stateful target driver consumer or be renamed to ACP driver-specific lifecycle code + +### Shrink Or Delete + +- `src/acp/persistent-bindings.ts` + - delete the compatibility barrel once tests import the real modules directly +- `src/acp/persistent-bindings.resolve.ts` + - keep only while ACP-specific compatibility helpers are still useful to internal callers +- ACP-named test files + - rename over time once the behavior is stable and there is no risk of mixing behavioral and naming churn + +## Recommended Refactor Order + +### Completed groundwork + +The current branch has already completed most of the first migration wave: + +- stable generic binding nouns exist +- configured bindings compile through a generic registry +- inbound routing goes through generic binding resolution +- configured binding lookup no longer performs fallback plugin discovery +- ACP is expressed as a configured-binding consumer plus a built-in stateful target driver + +The remaining work is cleanup and unification, not first-principles redesign. + +### Phase 1: Freeze the nouns + +Introduce and document the stable binding and target types: + +- `ConversationRef` +- `CompiledBinding` +- `BindingResolution` +- `BindingTargetDescriptor` +- `StatefulTargetDriver` + +Do this before more movement so the rest of the refactor has firm vocabulary. + +### Phase 2: Promote bindings to a first-class core capability + +Refactor the existing generic binding store into an explicit capability layer. + +Requirements: + +- runtime-created bindings stay supported +- configured bindings become first-class +- lookup becomes channel-agnostic + +### Phase 3: Compile configured bindings at startup and reload + +Move configured binding compilation off the inbound hot path. + +Requirements: + +- load enabled channel plugins once +- compile configured bindings once +- rebuild on config or plugin reload +- inbound path becomes pure registry lookup + +### Phase 4: Expand the channel provider seam + +Replace the ACP-specific adapter shape with a generic channel binding provider contract. + +Requirements: + +- channel plugins own normalization and matching +- core no longer knows channel-specific configured binding rules + +### Phase 5: Re-express ACP as a binding consumer plus built-in stateful target driver + +Move ACP configured binding policy to the new binding capability while keeping ACP runtime orchestration in core. + +Requirements: + +- ACP configured bindings resolve through the generic binding registry +- ACP target readiness uses the ACP driver contract +- ACP-specific naming disappears from generic binding code + +### Phase 6: Finish residual ACP cleanup + +Remove the last compatibility leftovers and stale naming. + +Requirements: + +- delete `src/acp/persistent-bindings.ts` +- rename ACP-named tests where that improves clarity without changing behavior +- keep docs synchronized with the actual generic seam instead of the earlier transition state + +### Phase 7: Split the configured binding registry by responsibility + +Refactor `src/channels/plugins/configured-binding-registry.ts` into smaller modules. + +Suggested split: + +- compiler module +- inbound matcher module +- session-key reverse lookup module +- thin public facade + +Requirements: + +- caching behavior remains unchanged +- matching behavior remains unchanged +- session-key resolution behavior remains unchanged + +### Phase 8: Keep codex app server on the same binding capability + +Do not force the codex app server into ACP semantics. + +Requirements: + +- codex app server keeps runtime-created bindings through the same binding capability +- inbound claim remains the default delivery path +- only adopt the stateful target driver seam if the app server truly needs long-lived target orchestration +- `src/plugins/conversation-binding.ts` stops being a separate binding stack and becomes a consumer of the generic binding capability + +### Phase 9: Decouple built-in ACP registration from generic registry files + +Keep ACP built in, but stop importing it directly from the generic registry modules. + +Requirements: + +- `src/channels/plugins/configured-binding-consumers.ts` no longer hardcodes ACP imports +- `src/channels/plugins/stateful-target-drivers.ts` no longer hardcodes ACP imports +- ACP still registers by default during normal startup +- generic registry files remain product-agnostic + +### Phase 10: Remove ACP-shaped compatibility facades + +Once all call sites are on the generic capability: + +- delete ACP-shaped routing helpers +- delete hot-path plugin bootstrapping logic +- keep only thin compatibility exports if external plugins still need a deprecation window + +## Success Criteria + +The architecture is done when all of these are true: + +- no inbound configured-binding resolution performs plugin discovery +- no channel-specific binding semantics remain in generic core binding code +- ACP still uses a core session kernel +- codex app server and ACP both sit on top of the same binding capability +- the binding capability can represent both configured and runtime-created bindings +- runtime-created plugin bindings do not use a separate implementation stack +- long-lived target orchestration is shared through a small core driver contract +- generic registry files do not import ACP directly +- ACP-era alias names are gone from the generic/plugin SDK surface +- the main harness is not forced into the ACP engine +- external plugins can use the same capability without internal imports + +## Non-Goals + +These are not goals of the remaining refactor: + +- moving the ACP session kernel into an ordinary plugin +- forcing the main harness, ACP, and codex app server into one executor +- making every channel implement its own retry and session-safety logic +- keeping ACP-shaped naming in the long-term generic binding layer + +## Bottom Line + +The right 20-year split is: + +- bindings are the shared core capability +- ACP session orchestration remains a small built-in core kernel +- channel plugins own binding semantics +- backend plugins own runtime protocol details +- product consumers like ACP configured bindings and codex app server build on the same binding capability without being forced into one runtime engine + +That is the leanest core that still has honest boundaries. diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index 5a19d6f43e8..bd75ee1198d 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -39,6 +39,25 @@ describe("acpx plugin config parsing", () => { } }); + it("prefers the workspace plugin root for dist/extensions/acpx bundles", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-workspace-")); + const workspacePluginRoot = path.join(repoRoot, "extensions", "acpx"); + const bundledPluginRoot = path.join(repoRoot, "dist", "extensions", "acpx"); + try { + fs.mkdirSync(workspacePluginRoot, { recursive: true }); + fs.mkdirSync(bundledPluginRoot, { recursive: true }); + fs.writeFileSync(path.join(workspacePluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(workspacePluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(bundledPluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(bundledPluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + + const moduleUrl = pathToFileURL(path.join(bundledPluginRoot, "index.js")).href; + expect(resolveAcpxPluginRoot(moduleUrl)).toBe(workspacePluginRoot); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("resolves bundled acpx with pinned version by default", () => { const resolved = resolveAcpxPluginConfig({ rawConfig: { diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index d6bfb3a44db..e604b69db7c 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -13,14 +13,18 @@ export const ACPX_PINNED_VERSION = "0.1.16"; export const ACPX_VERSION_ANY = "any"; const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx"; -export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string { +function isAcpxPluginRoot(dir: string): boolean { + return ( + fs.existsSync(path.join(dir, "openclaw.plugin.json")) && + fs.existsSync(path.join(dir, "package.json")) + ); +} + +function resolveNearestAcpxPluginRoot(moduleUrl: string): string { let cursor = path.dirname(fileURLToPath(moduleUrl)); for (let i = 0; i < 3; i += 1) { // Bundled entries live at the plugin root while source files still live under src/. - if ( - fs.existsSync(path.join(cursor, "openclaw.plugin.json")) && - fs.existsSync(path.join(cursor, "package.json")) - ) { + if (isAcpxPluginRoot(cursor)) { return cursor; } const parent = path.dirname(cursor); @@ -32,10 +36,29 @@ export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): stri return path.resolve(path.dirname(fileURLToPath(moduleUrl)), ".."); } +function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null { + if ( + path.basename(currentRoot) !== "acpx" || + path.basename(path.dirname(currentRoot)) !== "extensions" || + path.basename(path.dirname(path.dirname(currentRoot))) !== "dist" + ) { + return null; + } + const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx"); + return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null; +} + +export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string { + const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl); + // In a live repo checkout, dist/ can be rebuilt out from under the running gateway. + // Prefer the stable source plugin root when a built extension is running beside it. + return resolveWorkspaceAcpxPluginRoot(resolvedRoot) ?? resolvedRoot; +} + export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot(); export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME); export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string { - return `npm install --omit=dev --no-save acpx@${version}`; + return `npm install --omit=dev --no-save --package-lock=false acpx@${version}`; } export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand(); diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts index c0bb5469b29..b834a671906 100644 --- a/extensions/acpx/src/ensure.test.ts +++ b/extensions/acpx/src/ensure.test.ts @@ -85,7 +85,13 @@ describe("acpx ensure", () => { }); expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({ command: "npm", - args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`], + args: [ + "install", + "--omit=dev", + "--no-save", + "--package-lock=false", + `acpx@${ACPX_PINNED_VERSION}`, + ], cwd: "/plugin", stripProviderAuthEnvVars, }); diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 9b85d53f618..05825b75bc9 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -233,7 +233,13 @@ export async function ensureAcpx(params: { const install = await spawnAndCollect({ command: "npm", - args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`], + args: [ + "install", + "--omit=dev", + "--no-save", + "--package-lock=false", + `acpx@${installVersion}`, + ], cwd: pluginRoot, stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, }); diff --git a/extensions/acpx/src/runtime-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts index ef0492308ae..90b7560c47e 100644 --- a/extensions/acpx/src/runtime-internals/process.test.ts +++ b/extensions/acpx/src/runtime-internals/process.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -64,6 +64,58 @@ describe("resolveSpawnCommand", () => { }); }); + it("routes node shebang wrappers through the current node runtime on posix", async () => { + const dir = await createTempDir(); + const scriptPath = path.join(dir, "acpx"); + await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8"); + await chmod(scriptPath, 0o755); + + const resolved = resolveSpawnCommand( + { + command: scriptPath, + args: ["--help"], + }, + undefined, + { + platform: "linux", + env: {}, + execPath: "/custom/node", + }, + ); + + expect(resolved).toEqual({ + command: "/custom/node", + args: [scriptPath, "--help"], + }); + }); + + it("routes PATH-resolved node shebang wrappers through the current node runtime on posix", async () => { + const dir = await createTempDir(); + const binDir = path.join(dir, "bin"); + const scriptPath = path.join(binDir, "acpx"); + await mkdir(binDir, { recursive: true }); + await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8"); + await chmod(scriptPath, 0o755); + + const resolved = resolveSpawnCommand( + { + command: "acpx", + args: ["--help"], + }, + undefined, + { + platform: "linux", + env: { PATH: binDir }, + execPath: "/custom/node", + }, + ); + + expect(resolved).toEqual({ + command: "/custom/node", + args: [scriptPath, "--help"], + }); + }); + it("routes .js command execution through node on windows", () => { const resolved = resolveSpawnCommand( { diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index 2724f467ab1..60b85114bcb 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { existsSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; import type { WindowsSpawnProgram, WindowsSpawnProgramCandidate, @@ -57,11 +58,76 @@ const DEFAULT_RUNTIME: SpawnRuntime = { execPath: process.execPath, }; +function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) { + return false; + } + if (platform === "win32") { + return true; + } + accessSync(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function resolveExecutableFromPath(command: string, runtime: SpawnRuntime): string | undefined { + const pathEnv = runtime.env.PATH ?? runtime.env.Path; + if (!pathEnv) { + return undefined; + } + for (const entry of pathEnv.split(path.delimiter).filter(Boolean)) { + const candidate = path.join(entry, command); + if (isExecutableFile(candidate, runtime.platform)) { + return candidate; + } + } + return undefined; +} + +function resolveNodeShebangScriptPath(command: string, runtime: SpawnRuntime): string | undefined { + const commandPath = + path.isAbsolute(command) || command.includes(path.sep) + ? command + : resolveExecutableFromPath(command, runtime); + if (!commandPath || !isExecutableFile(commandPath, runtime.platform)) { + return undefined; + } + try { + const firstLine = readFileSync(commandPath, "utf8").split(/\r?\n/, 1)[0] ?? ""; + if (/^#!.*(?:\/usr\/bin\/env\s+node\b|\/node(?:js)?\b)/.test(firstLine)) { + return commandPath; + } + } catch { + return undefined; + } + return undefined; +} + export function resolveSpawnCommand( params: { command: string; args: string[] }, options?: SpawnCommandOptions, runtime: SpawnRuntime = DEFAULT_RUNTIME, ): ResolvedSpawnCommand { + if (runtime.platform !== "win32") { + const nodeShebangScript = resolveNodeShebangScriptPath(params.command, runtime); + if (nodeShebangScript) { + options?.onResolved?.({ + command: params.command, + cacheHit: false, + strictWindowsCmdWrapper: options?.strictWindowsCmdWrapper === true, + resolution: "direct", + }); + return { + command: runtime.execPath, + args: [nodeShebangScript, ...params.args], + }; + } + } + const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true; const cacheKey = params.command; const cachedProgram = options?.cache; diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 198a0367b59..5c65b032f34 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -154,6 +154,90 @@ describe("AcpxRuntime", () => { expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId); }); + it("replaces dead named sessions returned by sessions ensure", async () => { + process.env.MOCK_ACPX_STATUS_STATUS = "dead"; + process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const sessionKey = "agent:codex:acp:dead-session"; + + const handle = await runtime.ensureSession({ + sessionKey, + agent: "codex", + mode: "persistent", + }); + + expect(handle.backend).toBe("acpx"); + const logs = await readMockRuntimeLogEntries(logPath); + const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); + const statusIndex = logs.findIndex((entry) => entry.kind === "status"); + const newIndex = logs.findIndex((entry) => entry.kind === "new"); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(ensureIndex); + expect(newIndex).toBeGreaterThan(statusIndex); + } finally { + delete process.env.MOCK_ACPX_STATUS_STATUS; + delete process.env.MOCK_ACPX_STATUS_SUMMARY; + } + }); + + it("reuses a live named session when sessions ensure exits before returning identifiers", async () => { + process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1"; + process.env.MOCK_ACPX_STATUS_STATUS = "alive"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const sessionKey = "agent:codex:acp:ensure-fallback-alive"; + + const handle = await runtime.ensureSession({ + sessionKey, + agent: "codex", + mode: "persistent", + }); + + expect(handle.backend).toBe("acpx"); + expect(handle.acpxRecordId).toBe("rec-" + sessionKey); + const logs = await readMockRuntimeLogEntries(logPath); + const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); + const statusIndex = logs.findIndex((entry) => entry.kind === "status"); + const newIndex = logs.findIndex((entry) => entry.kind === "new"); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(ensureIndex); + expect(newIndex).toBe(-1); + } finally { + delete process.env.MOCK_ACPX_ENSURE_EXIT_1; + delete process.env.MOCK_ACPX_STATUS_STATUS; + } + }); + + it("creates a fresh named session when sessions ensure exits and status is dead", async () => { + process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1"; + process.env.MOCK_ACPX_STATUS_STATUS = "dead"; + process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const sessionKey = "agent:codex:acp:ensure-fallback-dead"; + + const handle = await runtime.ensureSession({ + sessionKey, + agent: "codex", + mode: "persistent", + }); + + expect(handle.backend).toBe("acpx"); + const logs = await readMockRuntimeLogEntries(logPath); + const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); + const statusIndex = logs.findIndex((entry) => entry.kind === "status"); + const newIndex = logs.findIndex((entry) => entry.kind === "new"); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(ensureIndex); + expect(newIndex).toBeGreaterThan(statusIndex); + } finally { + delete process.env.MOCK_ACPX_ENSURE_EXIT_1; + delete process.env.MOCK_ACPX_STATUS_STATUS; + delete process.env.MOCK_ACPX_STATUS_SUMMARY; + } + }); + it("serializes text plus image attachments into ACP prompt blocks", async () => { const { runtime, logPath } = await createMockRuntimeFixture(); diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index e55ef360424..a528de476af 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -92,6 +92,26 @@ function formatAcpxExitMessage(params: { return stderr || `acpx exited with code ${params.exitCode ?? "unknown"}`; } +function summarizeLogText(text: string, maxChars = 240): string { + const normalized = text.trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars)}...`; +} + +function findSessionIdentifierEvent(events: AcpxJsonObject[]): AcpxJsonObject | undefined { + return events.find( + (event) => + asOptionalString(event.agentSessionId) || + asOptionalString(event.acpxSessionId) || + asOptionalString(event.acpxRecordId), + ); +} + export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string { const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`; @@ -252,6 +272,146 @@ export class AcpxRuntime implements AcpRuntime { this.healthy = result.ok; } + private async createNamedSession(params: { + agent: string; + cwd: string; + sessionName: string; + resumeSessionId?: string; + }): Promise { + const command = params.resumeSessionId + ? [ + "sessions", + "new", + "--name", + params.sessionName, + "--resume-session", + params.resumeSessionId, + ] + : ["sessions", "new", "--name", params.sessionName]; + return await this.runControlCommand({ + args: await this.buildVerbArgs({ + agent: params.agent, + cwd: params.cwd, + command, + }), + cwd: params.cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + }); + } + + private async shouldReplaceEnsuredSession(params: { + sessionName: string; + agent: string; + cwd: string; + }): Promise { + const args = await this.buildVerbArgs({ + agent: params.agent, + cwd: params.cwd, + command: ["status", "--session", params.sessionName], + }); + let events: AcpxJsonObject[]; + try { + events = await this.runControlCommand({ + args, + cwd: params.cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + ignoreNoSession: true, + }); + } catch (error) { + this.logger?.warn?.( + `acpx ensureSession status probe failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(error instanceof Error ? error.message : String(error)) || ""}`, + ); + return false; + } + + const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION"); + if (noSession) { + this.logger?.warn?.( + `acpx ensureSession replacing missing named session: session=${params.sessionName} cwd=${params.cwd}`, + ); + return true; + } + + const detail = events.find((event) => !toAcpxErrorEvent(event)); + const status = asTrimmedString(detail?.status)?.toLowerCase(); + if (status === "dead") { + const summary = summarizeLogText(asOptionalString(detail?.summary) ?? ""); + this.logger?.warn?.( + `acpx ensureSession replacing dead named session: session=${params.sessionName} cwd=${params.cwd} status=${status} summary=${summary || ""}`, + ); + return true; + } + + return false; + } + + private async recoverEnsureFailure(params: { + sessionName: string; + agent: string; + cwd: string; + error: unknown; + }): Promise { + const errorMessage = summarizeLogText( + params.error instanceof Error ? params.error.message : String(params.error), + ); + this.logger?.warn?.( + `acpx ensureSession probing named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} error=${errorMessage || ""}`, + ); + const args = await this.buildVerbArgs({ + agent: params.agent, + cwd: params.cwd, + command: ["status", "--session", params.sessionName], + }); + let events: AcpxJsonObject[]; + try { + events = await this.runControlCommand({ + args, + cwd: params.cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + ignoreNoSession: true, + }); + } catch (statusError) { + this.logger?.warn?.( + `acpx ensureSession status fallback failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(statusError instanceof Error ? statusError.message : String(statusError)) || ""}`, + ); + return null; + } + + const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION"); + if (noSession) { + this.logger?.warn?.( + `acpx ensureSession creating named session after ensure failure and missing status: session=${params.sessionName} cwd=${params.cwd}`, + ); + return await this.createNamedSession({ + agent: params.agent, + cwd: params.cwd, + sessionName: params.sessionName, + }); + } + + const detail = events.find((event) => !toAcpxErrorEvent(event)); + const status = asTrimmedString(detail?.status)?.toLowerCase(); + if (status === "dead") { + this.logger?.warn?.( + `acpx ensureSession replacing dead named session after ensure failure: session=${params.sessionName} cwd=${params.cwd}`, + ); + return await this.createNamedSession({ + agent: params.agent, + cwd: params.cwd, + sessionName: params.sessionName, + }); + } + + if (status === "alive" || findSessionIdentifierEvent(events)) { + this.logger?.warn?.( + `acpx ensureSession reusing live named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} status=${status || "unknown"}`, + ); + return events; + } + + return null; + } + async ensureSession(input: AcpRuntimeEnsureInput): Promise { const sessionName = asTrimmedString(input.sessionKey); if (!sessionName) { @@ -264,45 +424,80 @@ export class AcpxRuntime implements AcpRuntime { const cwd = asTrimmedString(input.cwd) || this.config.cwd; const mode = input.mode; const resumeSessionId = asTrimmedString(input.resumeSessionId); - const ensureSubcommand = resumeSessionId - ? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId] - : ["sessions", "ensure", "--name", sessionName]; - const ensureCommand = await this.buildVerbArgs({ - agent, - cwd, - command: ensureSubcommand, - }); - - let events = await this.runControlCommand({ - args: ensureCommand, - cwd, - fallbackCode: "ACP_SESSION_INIT_FAILED", - }); - let ensuredEvent = events.find( - (event) => - asOptionalString(event.agentSessionId) || - asOptionalString(event.acpxSessionId) || - asOptionalString(event.acpxRecordId), - ); - - if (!ensuredEvent && !resumeSessionId) { - const newCommand = await this.buildVerbArgs({ + let events: AcpxJsonObject[]; + if (resumeSessionId) { + events = await this.createNamedSession({ agent, cwd, - command: ["sessions", "new", "--name", sessionName], + sessionName, + resumeSessionId, }); - events = await this.runControlCommand({ - args: newCommand, - cwd, - fallbackCode: "ACP_SESSION_INIT_FAILED", - }); - ensuredEvent = events.find( - (event) => - asOptionalString(event.agentSessionId) || - asOptionalString(event.acpxSessionId) || - asOptionalString(event.acpxRecordId), + } else { + try { + events = await this.runControlCommand({ + args: await this.buildVerbArgs({ + agent, + cwd, + command: ["sessions", "ensure", "--name", sessionName], + }), + cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + }); + } catch (error) { + const recovered = await this.recoverEnsureFailure({ + sessionName, + agent, + cwd, + error, + }); + if (!recovered) { + throw error; + } + events = recovered; + } + } + if (events.length === 0) { + this.logger?.warn?.( + `acpx ensureSession returned no events after sessions ensure: session=${sessionName} agent=${agent} cwd=${cwd}`, ); } + let ensuredEvent = findSessionIdentifierEvent(events); + + if ( + ensuredEvent && + !resumeSessionId && + (await this.shouldReplaceEnsuredSession({ + sessionName, + agent, + cwd, + })) + ) { + events = await this.createNamedSession({ + agent, + cwd, + sessionName, + }); + if (events.length === 0) { + this.logger?.warn?.( + `acpx ensureSession returned no events after replacing dead session: session=${sessionName} agent=${agent} cwd=${cwd}`, + ); + } + ensuredEvent = findSessionIdentifierEvent(events); + } + + if (!ensuredEvent && !resumeSessionId) { + events = await this.createNamedSession({ + agent, + cwd, + sessionName, + }); + if (events.length === 0) { + this.logger?.warn?.( + `acpx ensureSession returned no events after sessions new: session=${sessionName} agent=${agent} cwd=${cwd}`, + ); + } + ensuredEvent = findSessionIdentifierEvent(events); + } if (!ensuredEvent) { throw new AcpRuntimeError( "ACP_SESSION_INIT_FAILED", diff --git a/extensions/acpx/src/test-utils/runtime-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts index ebf5052f450..4ebe57b3e2a 100644 --- a/extensions/acpx/src/test-utils/runtime-fixtures.ts +++ b/extensions/acpx/src/test-utils/runtime-fixtures.ts @@ -76,6 +76,17 @@ const setValue = command === "set" ? String(args[commandIndex + 2] || "") : ""; if (command === "sessions" && args[commandIndex + 1] === "ensure") { writeLog({ kind: "ensure", agent, args, sessionName: ensureName }); + if (process.env.MOCK_ACPX_ENSURE_EXIT_1 === "1") { + emitJson({ + jsonrpc: "2.0", + id: null, + error: { + code: -32603, + message: "mock ensure failure", + }, + }); + process.exit(1); + } if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") { emitJson({ action: "session_ensured", name: ensureName }); } else { @@ -173,11 +184,14 @@ if (command === "set") { if (command === "status") { writeLog({ kind: "status", agent, args, sessionName: sessionFromOption }); + const status = process.env.MOCK_ACPX_STATUS_STATUS || (sessionFromOption ? "alive" : "no-session"); + const summary = process.env.MOCK_ACPX_STATUS_SUMMARY || ""; emitJson({ acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null, acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null, agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null, - status: sessionFromOption ? "alive" : "no-session", + status, + ...(summary ? { summary } : {}), pid: 4242, uptime: 120, }); @@ -382,6 +396,9 @@ export async function readMockRuntimeLogEntries( export async function cleanupMockRuntimeFixtures(): Promise { delete process.env.MOCK_ACPX_LOG; delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS; + delete process.env.MOCK_ACPX_ENSURE_EXIT_1; + delete process.env.MOCK_ACPX_STATUS_STATUS; + delete process.env.MOCK_ACPX_STATUS_SUMMARY; sharedMockCliScriptPath = null; logFileSequence = 0; while (tempDirs.length > 0) { diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 0a4ead6c3fd..5e47dda6334 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -1,8 +1,87 @@ -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/discord"; -import { describe, expect, it, vi } from "vitest"; +import type { + ChannelAccountSnapshot, + ChannelGatewayContext, + OpenClawConfig, + PluginRuntime, +} from "openclaw/plugin-sdk/discord"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import type { ResolvedDiscordAccount } from "./accounts.js"; import { discordPlugin } from "./channel.js"; import { setDiscordRuntime } from "./runtime.js"; +const probeDiscordMock = vi.hoisted(() => vi.fn()); +const monitorDiscordProviderMock = vi.hoisted(() => vi.fn()); +const auditDiscordChannelPermissionsMock = vi.hoisted(() => vi.fn()); + +vi.mock("./probe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeDiscord: probeDiscordMock, + }; +}); + +vi.mock("./monitor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + monitorDiscordProvider: monitorDiscordProviderMock, + }; +}); + +vi.mock("./audit.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + auditDiscordChannelPermissions: auditDiscordChannelPermissionsMock, + }; +}); + +function createCfg(): OpenClawConfig { + return { + channels: { + discord: { + enabled: true, + token: "discord-token", + }, + }, + } as OpenClawConfig; +} + +function createStartAccountCtx(params: { + cfg: OpenClawConfig; + accountId: string; + runtime: ReturnType; +}): ChannelGatewayContext { + const account = discordPlugin.config.resolveAccount( + params.cfg, + params.accountId, + ) as ResolvedDiscordAccount; + const snapshot: ChannelAccountSnapshot = { + accountId: params.accountId, + configured: true, + enabled: true, + running: false, + }; + return { + accountId: params.accountId, + account, + cfg: params.cfg, + runtime: params.runtime, + abortSignal: new AbortController().signal, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getStatus: () => snapshot, + setStatus: vi.fn(), + }; +} + +afterEach(() => { + probeDiscordMock.mockReset(); + monitorDiscordProviderMock.mockReset(); + auditDiscordChannelPermissionsMock.mockReset(); +}); + describe("discordPlugin outbound", () => { it("forwards mediaLocalRoots to sendMessageDiscord", async () => { const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" })); @@ -33,4 +112,100 @@ describe("discordPlugin outbound", () => { ); expect(result).toMatchObject({ channel: "discord", messageId: "m1" }); }); + + it("uses direct Discord probe helpers for status probes", async () => { + const runtimeProbeDiscord = vi.fn(async () => { + throw new Error("runtime Discord probe should not be used"); + }); + setDiscordRuntime({ + channel: { + discord: { + probeDiscord: runtimeProbeDiscord, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + probeDiscordMock.mockResolvedValue({ + ok: true, + bot: { username: "Bob" }, + application: { + intents: { + messageContent: "limited", + guildMembers: "disabled", + presence: "disabled", + }, + }, + elapsedMs: 1, + }); + + const cfg = createCfg(); + const account = discordPlugin.config.resolveAccount(cfg, "default"); + + await discordPlugin.status!.probeAccount!({ + account, + timeoutMs: 5000, + cfg, + }); + + expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 5000, { + includeApplication: true, + }); + expect(runtimeProbeDiscord).not.toHaveBeenCalled(); + }); + + it("uses direct Discord startup helpers before monitoring", async () => { + const runtimeProbeDiscord = vi.fn(async () => { + throw new Error("runtime Discord probe should not be used"); + }); + const runtimeMonitorDiscordProvider = vi.fn(async () => { + throw new Error("runtime Discord monitor should not be used"); + }); + setDiscordRuntime({ + channel: { + discord: { + probeDiscord: runtimeProbeDiscord, + monitorDiscordProvider: runtimeMonitorDiscordProvider, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + probeDiscordMock.mockResolvedValue({ + ok: true, + bot: { username: "Bob" }, + application: { + intents: { + messageContent: "limited", + guildMembers: "disabled", + presence: "disabled", + }, + }, + elapsedMs: 1, + }); + monitorDiscordProviderMock.mockResolvedValue(undefined); + + const cfg = createCfg(); + await discordPlugin.gateway!.startAccount!( + createStartAccountCtx({ + cfg, + accountId: "default", + runtime: createRuntimeEnv(), + }), + ); + + expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, { + includeApplication: true, + }); + expect(monitorDiscordProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + token: "discord-token", + accountId: "default", + }), + ); + expect(runtimeProbeDiscord).not.toHaveBeenCalled(); + expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 8cae9c04323..c4ff4827038 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -35,17 +35,18 @@ import { resolveDiscordAccount, type ResolvedDiscordAccount, } from "./accounts.js"; -import { collectDiscordAuditChannelIds } from "./audit.js"; +import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js"; import { isDiscordExecApprovalClientEnabled, shouldSuppressLocalDiscordExecApprovalPrompt, } from "./exec-approvals.js"; +import { monitorDiscordProvider } from "./monitor.js"; import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./normalize.js"; -import type { DiscordProbe } from "./probe.js"; +import { probeDiscord, type DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; @@ -491,11 +492,15 @@ export const discordPlugin: ChannelPlugin = { silent: silent ?? undefined, }), }, - acpBindings: { - normalizeConfiguredBindingTarget: ({ conversationId }) => + bindings: { + compileConfiguredBinding: ({ conversationId }) => normalizeDiscordAcpConversationId(conversationId), - matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => - matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => + matchDiscordAcpConversation({ + bindingConversationId: compiledBinding.conversationId, + conversationId, + parentConversationId, + }), }, status: { defaultRuntime: { @@ -514,7 +519,7 @@ export const discordPlugin: ChannelPlugin = { buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot, { includeMode: false }), probeAccount: async ({ account, timeoutMs }) => - getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { + probeDiscord(account.token, timeoutMs, { includeApplication: true, }), formatCapabilitiesProbe: ({ probe }) => { @@ -620,7 +625,7 @@ export const discordPlugin: ChannelPlugin = { elapsedMs: 0, }; } - const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({ + const audit = await auditDiscordChannelPermissions({ token: botToken, accountId: account.accountId, channelIds, @@ -661,7 +666,7 @@ export const discordPlugin: ChannelPlugin = { const token = account.token.trim(); let discordBotLabel = ""; try { - const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, { + const probe = await probeDiscord(token, 2500, { includeApplication: true, }); const username = probe.ok ? probe.bot?.username?.trim() : null; @@ -689,7 +694,7 @@ export const discordPlugin: ChannelPlugin = { } } ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); - return getDiscordRuntime().channel.discord.monitorDiscordProvider({ + return monitorDiscordProvider({ token, accountId: account.accountId, config: ctx.cfg, diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 109135a3684..5acab8d5339 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -9,6 +9,7 @@ import WebSocket from "ws"; const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; +const DISCORD_GATEWAY_INFO_TIMEOUT_MS = 10_000; type DiscordGatewayMetadataResponse = Pick; type DiscordGatewayFetchInit = Record & { @@ -19,6 +20,8 @@ type DiscordGatewayFetch = ( init?: DiscordGatewayFetchInit, ) => Promise; +type DiscordGatewayMetadataError = Error & { transient?: boolean }; + export function resolveDiscordGatewayIntents( intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig, ): number { @@ -64,14 +67,36 @@ function createGatewayMetadataError(params: { transient: boolean; cause?: unknown; }): Error { - if (params.transient) { - return new Error("Failed to get gateway information from Discord: fetch failed", { - cause: params.cause ?? new Error(params.detail), - }); - } - return new Error(`Failed to get gateway information from Discord: ${params.detail}`, { - cause: params.cause, + const error = new Error( + params.transient + ? "Failed to get gateway information from Discord: fetch failed" + : `Failed to get gateway information from Discord: ${params.detail}`, + { + cause: params.cause ?? (params.transient ? new Error(params.detail) : undefined), + }, + ) as DiscordGatewayMetadataError; + Object.defineProperty(error, "transient", { + value: params.transient, + enumerable: false, }); + return error; +} + +function isTransientGatewayMetadataError(error: unknown): boolean { + return Boolean((error as DiscordGatewayMetadataError | undefined)?.transient); +} + +function createDefaultGatewayInfo(): APIGatewayBotInfo { + return { + url: DEFAULT_DISCORD_GATEWAY_URL, + shards: 1, + session_start_limit: { + total: 1, + remaining: 1, + reset_after: 0, + max_concurrency: 1, + }, + }; } async function fetchDiscordGatewayInfo(params: { @@ -134,6 +159,65 @@ async function fetchDiscordGatewayInfo(params: { } } +async function fetchDiscordGatewayInfoWithTimeout(params: { + token: string; + fetchImpl: DiscordGatewayFetch; + fetchInit?: DiscordGatewayFetchInit; + timeoutMs?: number; +}): Promise { + const timeoutMs = Math.max(1, params.timeoutMs ?? DISCORD_GATEWAY_INFO_TIMEOUT_MS); + const abortController = new AbortController(); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortController.abort(); + reject( + createGatewayMetadataError({ + detail: `Discord API /gateway/bot timed out after ${timeoutMs}ms`, + transient: true, + cause: new Error("gateway metadata timeout"), + }), + ); + }, timeoutMs); + timeoutId.unref?.(); + }); + + try { + return await Promise.race([ + fetchDiscordGatewayInfo({ + token: params.token, + fetchImpl: params.fetchImpl, + fetchInit: { + ...params.fetchInit, + signal: abortController.signal, + }, + }), + timeoutPromise, + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + +function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; error: unknown }): { + info: APIGatewayBotInfo; + usedFallback: boolean; +} { + if (!isTransientGatewayMetadataError(params.error)) { + throw params.error; + } + const message = params.error instanceof Error ? params.error.message : String(params.error); + params.runtime?.log?.( + `discord: gateway metadata lookup failed transiently; using default gateway url (${message})`, + ); + return { + info: createDefaultGatewayInfo(), + usedFallback: true, + }; +} + function createGatewayPlugin(params: { options: { reconnect: { maxAttempts: number }; @@ -143,19 +227,29 @@ function createGatewayPlugin(params: { fetchImpl: DiscordGatewayFetch; fetchInit?: DiscordGatewayFetchInit; wsAgent?: HttpsProxyAgent; + runtime?: RuntimeEnv; }): GatewayPlugin { class SafeGatewayPlugin extends GatewayPlugin { + private gatewayInfoUsedFallback = false; + constructor() { super(params.options); } override async registerClient(client: Parameters[0]) { - if (!this.gatewayInfo) { - this.gatewayInfo = await fetchDiscordGatewayInfo({ + if (!this.gatewayInfo || this.gatewayInfoUsedFallback) { + const resolved = await fetchDiscordGatewayInfoWithTimeout({ token: client.options.token, fetchImpl: params.fetchImpl, fetchInit: params.fetchInit, - }); + }) + .then((info) => ({ + info, + usedFallback: false, + })) + .catch((error) => resolveGatewayInfoWithFallback({ runtime: params.runtime, error })); + this.gatewayInfo = resolved.info; + this.gatewayInfoUsedFallback = resolved.usedFallback; } return super.registerClient(client); } @@ -187,6 +281,7 @@ export function createDiscordGatewayPlugin(params: { return createGatewayPlugin({ options, fetchImpl: (input, init) => fetch(input, init as RequestInit), + runtime: params.runtime, }); } @@ -201,12 +296,14 @@ export function createDiscordGatewayPlugin(params: { fetchImpl: (input, init) => undiciFetch(input, init), fetchInit: { dispatcher: fetchAgent }, wsAgent, + runtime: params.runtime, }); } catch (err) { params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); return createGatewayPlugin({ options, fetchImpl: (input, init) => fetch(input, init as RequestInit), + runtime: params.runtime, }); } } diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 01bac15e856..982b9589b22 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -1,14 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); -const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn()); +const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); +const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../../src/acp/persistent-bindings.js", () => ({ - ensureConfiguredAcpBindingSession: (...args: unknown[]) => - ensureConfiguredAcpBindingSessionMock(...args), - resolveConfiguredAcpBindingRecord: (...args: unknown[]) => - resolveConfiguredAcpBindingRecordMock(...args), -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureConfiguredBindingRouteReady: (...args: unknown[]) => + ensureConfiguredBindingRouteReadyMock(...args), + resolveConfiguredBindingRoute: (...args: unknown[]) => + resolveConfiguredBindingRouteMock(...args), + }; +}); import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; @@ -52,6 +56,77 @@ function createConfiguredDiscordBinding() { } as const; } +function createConfiguredDiscordRoute() { + const configuredBinding = createConfiguredDiscordBinding(); + return { + bindingResolution: { + conversation: { + channel: "discord", + accountId: "default", + conversationId: CHANNEL_ID, + }, + compiledBinding: { + channel: "discord", + accountPattern: "default", + binding: { + type: "acp", + agentId: "codex", + match: { + channel: "discord", + accountId: "default", + peer: { + kind: "channel", + id: CHANNEL_ID, + }, + }, + }, + bindingConversationId: CHANNEL_ID, + target: { + conversationId: CHANNEL_ID, + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ conversationId: CHANNEL_ID }), + matchInboundConversation: () => ({ conversationId: CHANNEL_ID }), + }, + targetFactory: { + driverId: "acp", + materialize: () => ({ + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: CHANNEL_ID, + }, + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }, + configuredBinding, + boundSessionKey: configuredBinding.record.targetSessionKey, + route: { + agentId: "codex", + accountId: "default", + channel: "discord", + sessionKey: configuredBinding.record.targetSessionKey, + mainSessionKey: "agent:codex:main", + matchedBy: "binding.channel", + lastRoutePolicy: "bound", + }, + } as const; +} + function createBasePreflightParams(overrides?: Record) { const message = createDiscordMessage({ id: "m-1", @@ -94,13 +169,10 @@ function createBasePreflightParams(overrides?: Record) { describe("preflightDiscordMessage configured ACP bindings", () => { beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); - ensureConfiguredAcpBindingSessionMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredDiscordBinding()); - ensureConfiguredAcpBindingSessionMock.mockResolvedValue({ - ok: true, - sessionKey: "agent:codex:acp:binding:discord:default:abc123", - }); + ensureConfiguredBindingRouteReadyMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredDiscordRoute()); + ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); }); it("does not initialize configured ACP bindings for rejected messages", async () => { @@ -121,8 +193,8 @@ describe("preflightDiscordMessage configured ACP bindings", () => { ); expect(result).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled(); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled(); }); it("initializes configured ACP bindings only after preflight accepts the message", async () => { @@ -144,8 +216,176 @@ describe("preflightDiscordMessage configured ACP bindings", () => { ); expect(result).not.toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); }); + + it("accepts plain messages in configured ACP-bound channels without a mention", async () => { + const message = createDiscordMessage({ + id: "m-no-mention", + channelId: CHANNEL_ID, + content: "hello", + mentionedUsers: [], + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }); + + const result = await preflightDiscordMessage( + createBasePreflightParams({ + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + guildEntries: { + [GUILD_ID]: { + id: GUILD_ID, + channels: { + [CHANNEL_ID]: { + allow: true, + enabled: true, + requireMention: true, + }, + }, + }, + }, + }), + ); + + expect(result).not.toBeNull(); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); + expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); + }); + + it("hydrates empty guild message payloads from REST before ensuring configured ACP bindings", async () => { + const message = createDiscordMessage({ + id: "m-rest", + channelId: CHANNEL_ID, + content: "", + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }); + const restGet = vi.fn(async () => ({ + id: "m-rest", + content: "hello from rest", + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + mention_everyone: false, + author: { + id: "user-1", + username: "alice", + }, + })); + const client = { + ...createGuildTextClient(CHANNEL_ID), + rest: { + get: restGet, + }, + } as unknown as Parameters[0]["client"]; + + const result = await preflightDiscordMessage( + createBasePreflightParams({ + client, + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + guildEntries: { + [GUILD_ID]: { + id: GUILD_ID, + channels: { + [CHANNEL_ID]: { + allow: true, + enabled: true, + requireMention: false, + }, + }, + }, + }, + }), + ); + + expect(restGet).toHaveBeenCalledTimes(1); + expect(result?.messageText).toBe("hello from rest"); + expect(result?.data.message.content).toBe("hello from rest"); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); + }); + + it("hydrates sticker-only guild message payloads from REST before ensuring configured ACP bindings", async () => { + const message = createDiscordMessage({ + id: "m-rest-sticker", + channelId: CHANNEL_ID, + content: "", + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }); + const restGet = vi.fn(async () => ({ + id: "m-rest-sticker", + content: "", + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + mention_everyone: false, + sticker_items: [ + { + id: "sticker-1", + name: "wave", + }, + ], + author: { + id: "user-1", + username: "alice", + }, + })); + const client = { + ...createGuildTextClient(CHANNEL_ID), + rest: { + get: restGet, + }, + } as unknown as Parameters[0]["client"]; + + const result = await preflightDiscordMessage( + createBasePreflightParams({ + client, + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + guildEntries: { + [GUILD_ID]: { + id: GUILD_ID, + channels: { + [CHANNEL_ID]: { + allow: true, + enabled: true, + requireMention: false, + }, + }, + }, + }, + }), + ); + + expect(restGet).toHaveBeenCalledTimes(1); + expect(result?.messageText).toBe(" (1 sticker)"); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 2fb14bafe8e..0067de03c4e 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const transcribeFirstAudioMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../../src/media-understanding/audio-preflight.js", () => ({ +vi.mock("./preflight-audio.runtime.js", () => ({ transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args), })); import { @@ -229,16 +229,16 @@ describe("resolvePreflightMentionRequirement", () => { expect( resolvePreflightMentionRequirement({ shouldRequireMention: true, - isBoundThreadSession: false, + bypassMentionRequirement: false, }), ).toBe(true); }); - it("disables mention requirement for bound thread sessions", () => { + it("disables mention requirement when the route explicitly bypasses mentions", () => { expect( resolvePreflightMentionRequirement({ shouldRequireMention: true, - isBoundThreadSession: true, + bypassMentionRequirement: true, }), ).toBe(false); }); @@ -247,7 +247,7 @@ describe("resolvePreflightMentionRequirement", () => { expect( resolvePreflightMentionRequirement({ shouldRequireMention: false, - isBoundThreadSession: false, + bypassMentionRequirement: false, }), ).toBe(false); }); @@ -378,6 +378,69 @@ describe("preflightDiscordMessage", () => { expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); }); + it("drops hydrated bound-thread webhook echoes after fetching an empty payload", async () => { + const threadBinding = createThreadBinding({ + targetKind: "session", + targetSessionKey: "agent:main:acp:discord-thread-1", + }); + const threadId = "thread-webhook-hydrated-1"; + const parentId = "channel-parent-webhook-hydrated-1"; + const message = createDiscordMessage({ + id: "m-webhook-hydrated-1", + channelId: threadId, + content: "", + webhookId: undefined, + author: { + id: "relay-bot-1", + bot: true, + username: "Relay", + }, + }); + const restGet = vi.fn(async () => ({ + id: message.id, + content: "webhook relay", + webhook_id: "wh-1", + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + mention_everyone: false, + author: { + id: "relay-bot-1", + username: "Relay", + bot: true, + }, + })); + const client = { + ...createThreadClient({ threadId, parentId }), + rest: { + get: restGet, + }, + } as unknown as DiscordClient; + + const result = await preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: { + allowBots: true, + } as DiscordConfig, + data: createGuildEvent({ + channelId: threadId, + guildId: "guild-1", + author: message.author, + message, + }), + client, + }), + threadBindings: { + getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), + } as import("./thread-bindings.js").ThreadBindingManager, + }); + + expect(restGet).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + it("bypasses mention gating in bound threads for allowed bot senders", async () => { const threadBinding = createThreadBinding(); const threadId = "thread-bot-focus"; @@ -655,8 +718,8 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await preflightDiscordMessage( - createPreflightArgs({ + const result = await preflightDiscordMessage({ + ...createPreflightArgs({ cfg: { ...DEFAULT_PREFLIGHT_CFG, messages: { @@ -674,7 +737,17 @@ describe("preflightDiscordMessage", () => { }), client, }), - ); + guildEntries: { + "guild-1": { + channels: { + [channelId]: { + allow: true, + requireMention: true, + }, + }, + }, + }, + }); expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); expect(transcribeFirstAudioMock).toHaveBeenCalledWith( diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 0a402518927..9094cabb645 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1,4 +1,5 @@ -import { ChannelType, MessageType, type User } from "@buape/carbon"; +import { ChannelType, MessageType, type Message, type User } from "@buape/carbon"; +import { Routes, type APIMessage } from "discord-api-types/v10"; import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; @@ -6,8 +7,8 @@ import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runt import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService, @@ -95,12 +96,12 @@ function isBoundThreadBotSystemMessage(params: { export function resolvePreflightMentionRequirement(params: { shouldRequireMention: boolean; - isBoundThreadSession: boolean; + bypassMentionRequirement: boolean; }): boolean { if (!params.shouldRequireMention) { return false; } - return !params.isBoundThreadSession; + return !params.bypassMentionRequirement; } export function shouldIgnoreBoundThreadWebhookMessage(params: { @@ -131,6 +132,95 @@ export function shouldIgnoreBoundThreadWebhookMessage(params: { return webhookId === boundWebhookId; } +function mergeFetchedDiscordMessage(base: Message, fetched: APIMessage): Message { + const baseReferenced = ( + base as unknown as { + referencedMessage?: { + mentionedUsers?: unknown[]; + mentionedRoles?: unknown[]; + mentionedEveryone?: boolean; + }; + } + ).referencedMessage; + const fetchedMentions = Array.isArray(fetched.mentions) + ? fetched.mentions.map((mention) => ({ + ...mention, + globalName: mention.global_name ?? undefined, + })) + : undefined; + const referencedMessage = fetched.referenced_message + ? ({ + ...((base as { referencedMessage?: object }).referencedMessage ?? {}), + ...fetched.referenced_message, + mentionedUsers: Array.isArray(fetched.referenced_message.mentions) + ? fetched.referenced_message.mentions.map((mention) => ({ + ...mention, + globalName: mention.global_name ?? undefined, + })) + : (baseReferenced?.mentionedUsers ?? []), + mentionedRoles: + fetched.referenced_message.mention_roles ?? baseReferenced?.mentionedRoles ?? [], + mentionedEveryone: + fetched.referenced_message.mention_everyone ?? baseReferenced?.mentionedEveryone ?? false, + } satisfies Record) + : (base as { referencedMessage?: Message }).referencedMessage; + const rawData = { + ...((base as { rawData?: Record }).rawData ?? {}), + message_snapshots: + fetched.message_snapshots ?? + (base as { rawData?: { message_snapshots?: unknown } }).rawData?.message_snapshots, + sticker_items: + (fetched as { sticker_items?: unknown }).sticker_items ?? + (base as { rawData?: { sticker_items?: unknown } }).rawData?.sticker_items, + }; + return { + ...base, + ...fetched, + content: fetched.content ?? base.content, + attachments: fetched.attachments ?? base.attachments, + embeds: fetched.embeds ?? base.embeds, + stickers: + (fetched as { stickers?: unknown }).stickers ?? + (fetched as { sticker_items?: unknown }).sticker_items ?? + base.stickers, + mentionedUsers: fetchedMentions ?? base.mentionedUsers, + mentionedRoles: fetched.mention_roles ?? base.mentionedRoles, + mentionedEveryone: fetched.mention_everyone ?? base.mentionedEveryone, + referencedMessage, + rawData, + } as unknown as Message; +} + +async function hydrateDiscordMessageIfEmpty(params: { + client: DiscordMessagePreflightParams["client"]; + message: Message; + messageChannelId: string; +}): Promise { + const currentText = resolveDiscordMessageText(params.message, { + includeForwarded: true, + }); + if (currentText) { + return params.message; + } + const rest = params.client.rest as { get?: (route: string) => Promise } | undefined; + if (typeof rest?.get !== "function") { + return params.message; + } + try { + const fetched = (await rest.get( + Routes.channelMessage(params.messageChannelId, params.message.id), + )) as APIMessage | null | undefined; + if (!fetched) { + return params.message; + } + logVerbose(`discord: hydrated empty inbound payload via REST for ${params.message.id}`); + return mergeFetchedDiscordMessage(params.message, fetched); + } catch (err) { + logVerbose(`discord: failed to hydrate message ${params.message.id}: ${String(err)}`); + return params.message; + } +} + export async function preflightDiscordMessage( params: DiscordMessagePreflightParams, ): Promise { @@ -138,7 +228,7 @@ export async function preflightDiscordMessage( return null; } const logger = getChildLogger({ module: "discord-auto-reply" }); - const message = params.data.message; + let message = params.data.message; const author = params.data.author; if (!author) { return null; @@ -160,6 +250,15 @@ export async function preflightDiscordMessage( return null; } + message = await hydrateDiscordMessageIfEmpty({ + client: params.client, + message, + messageChannelId, + }); + if (isPreflightAborted(params.abortSignal)) { + return null; + } + const pluralkitConfig = params.discordConfig?.pluralkit; const webhookId = resolveDiscordWebhookId(message); const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId; @@ -197,6 +296,7 @@ export async function preflightDiscordMessage( } const isDirectMessage = channelInfo?.type === ChannelType.DM; const isGroupDm = channelInfo?.type === ChannelType.GroupDM; + const data = message === params.data.message ? params.data : { ...params.data, message }; logDebug( `[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`, ); @@ -359,16 +459,18 @@ export async function preflightDiscordMessage( }) ?? undefined; const configuredRoute = threadBinding == null - ? resolveConfiguredAcpRoute({ + ? resolveConfiguredBindingRoute({ cfg: freshCfg, route, - channel: "discord", - accountId: params.accountId, - conversationId: messageChannelId, - parentConversationId: earlyThreadParentId, + conversation: { + channel: "discord", + accountId: params.accountId, + conversationId: messageChannelId, + parentConversationId: earlyThreadParentId, + }, }) : null; - const configuredBinding = configuredRoute?.configuredBinding ?? null; + const configuredBinding = configuredRoute?.bindingResolution ?? null; if (!threadBinding && configuredBinding) { threadBinding = configuredBinding.record; } @@ -394,6 +496,7 @@ export async function preflightDiscordMessage( }); const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined; const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel); + const bypassMentionRequirement = isBoundThreadSession || Boolean(configuredBinding); if ( isBoundThreadBotSystemMessage({ isBoundThreadSession, @@ -579,7 +682,7 @@ export async function preflightDiscordMessage( }); const shouldRequireMention = resolvePreflightMentionRequirement({ shouldRequireMention: shouldRequireMentionByConfig, - isBoundThreadSession, + bypassMentionRequirement, }); // Preflight audio transcription for mention detection in guilds. @@ -764,13 +867,13 @@ export async function preflightDiscordMessage( return null; } if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg: freshCfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { logVerbose( - `discord: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `discord: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); return null; } @@ -794,7 +897,7 @@ export async function preflightDiscordMessage( replyToMode: params.replyToMode, ackReactionScope: params.ackReactionScope, groupPolicy: params.groupPolicy, - data: params.data, + data, client: params.client, message, messageChannelId, diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 1009c583a81..2b49292b037 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -2,6 +2,7 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js"; import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { ChatType } from "../../../../src/channels/chat-type.js"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import * as pluginCommandsModule from "../../../../src/plugins/commands.js"; import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js"; @@ -11,17 +12,17 @@ import { } from "./native-command.test-helpers.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; -type ResolveConfiguredAcpBindingRecordFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredAcpRoute; -type EnsureConfiguredAcpBindingSessionFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredAcpRouteReady; +type ResolveConfiguredBindingRouteFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; +type EnsureConfiguredBindingRouteReadyFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; const persistentBindingMocks = vi.hoisted(() => ({ - resolveConfiguredAcpBindingRecord: vi.fn((params) => ({ - configuredBinding: null, + resolveConfiguredAcpBindingRecord: vi.fn((params) => ({ + bindingResolution: null, route: params.route, })), - ensureConfiguredAcpBindingSession: vi.fn(async () => ({ + ensureConfiguredAcpBindingSession: vi.fn(async () => ({ ok: true, })), })); @@ -30,8 +31,8 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - resolveConfiguredAcpRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord, - ensureConfiguredAcpRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession, + resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord, + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession, }; }); @@ -65,12 +66,7 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } -function createStatusCommand(cfg: OpenClawConfig) { - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; +function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) { return createDiscordNativeCommand({ command: commandSpec, cfg, @@ -147,39 +143,145 @@ async function expectPairCommandReply(params: { ); } -function setConfiguredBinding(channelId: string, boundSessionKey: string) { - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({ - configuredBinding: { - spec: { - channel: "discord", - accountId: params.accountId, - conversationId: channelId, - parentConversationId: params.parentConversationId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: `config:acp:discord:${params.accountId}:${channelId}`, - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: params.accountId, - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }, - boundSessionKey, - boundAgentId: "codex", - route: { - ...params.route, +function createStatusCommand(cfg: OpenClawConfig) { + return createNativeCommand(cfg, { + name: "status", + description: "Status", + acceptsArgs: false, + }); +} + +function resolveConversationFromParams(params: Parameters[0]) { + if ("conversation" in params) { + return params.conversation; + } + return { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + ...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}), + }; +} + +function createConfiguredBindingResolution(params: { + conversation: ReturnType; + boundSessionKey: string; +}) { + const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-") + ? "direct" + : "channel"; + const configuredBinding = { + spec: { + channel: "discord" as const, + accountId: params.conversation.accountId, + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), agentId: "codex", - sessionKey: boundSessionKey, - matchedBy: "binding.channel", + mode: "persistent" as const, }, - })); + record: { + bindingId: `config:acp:discord:${params.conversation.accountId}:${params.conversation.conversationId}`, + targetSessionKey: params.boundSessionKey, + targetKind: "session" as const, + conversation: params.conversation, + status: "active" as const, + boundAt: 0, + }, + }; + return { + conversation: params.conversation, + compiledBinding: { + channel: "discord" as const, + binding: { + type: "acp" as const, + agentId: "codex", + match: { + channel: "discord", + accountId: params.conversation.accountId, + peer: { + kind: peerKind, + id: params.conversation.conversationId, + }, + }, + acp: { + mode: "persistent" as const, + }, + }, + bindingConversationId: params.conversation.conversationId, + target: { + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }), + matchInboundConversation: () => ({ + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }), + }, + targetFactory: { + driverId: "acp" as const, + materialize: () => ({ + record: configuredBinding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp", + sessionKey: params.boundSessionKey, + agentId: "codex", + }, + }), + }, + }, + match: { + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }, + record: configuredBinding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp", + sessionKey: params.boundSessionKey, + agentId: "codex", + }, + }; +} + +function setConfiguredBinding(channelId: string, boundSessionKey: string) { + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => { + const conversation = resolveConversationFromParams(params); + const bindingResolution = createConfiguredBindingResolution({ + conversation: { + ...conversation, + conversationId: channelId, + }, + boundSessionKey, + }); + return { + bindingResolution, + boundSessionKey, + boundAgentId: "codex", + route: { + ...params.route, + agentId: "codex", + sessionKey: boundSessionKey, + matchedBy: "binding.channel", + }, + }; + }); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, }); @@ -234,7 +336,7 @@ describe("Discord native plugin command dispatch", () => { clearPluginCommands(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({ - configuredBinding: null, + bindingResolution: null, route: params.route, })); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset(); @@ -519,4 +621,64 @@ describe("Discord native plugin command dispatch", () => { boundSessionKey, }); }); + + it("allows recovery commands through configured ACP bindings even when ensure fails", async () => { + const guildId = "1459246755253325866"; + const channelId = "1479098716916023408"; + const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface"; + const cfg = { + commands: { + useAccessGroups: false, + }, + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: channelId }, + }, + acp: { + mode: "persistent", + }, + }, + ], + } as OpenClawConfig; + const interaction = createInteraction({ + channelType: ChannelType.GuildText, + channelId, + guildId, + guildName: "Ops", + }); + const command = createNativeCommand(cfg, { + name: "new", + description: "Start a new session.", + acceptsArgs: true, + }); + + setConfiguredBinding(channelId, boundSessionKey); + persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ + ok: false, + error: "acpx exited with code 1", + }); + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { + ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; + }; + expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); + expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + expect(interaction.reply).not.toHaveBeenCalledWith( + expect.objectContaining({ + content: "Configured ACP binding is unavailable right now. Please try again.", + }), + ); + }); }); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index ed50aff52a3..1876acbde0a 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -24,8 +24,8 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; @@ -194,6 +194,11 @@ function buildDiscordCommandOptions(params: { }) satisfies CommandOptions; } +function shouldBypassConfiguredAcpEnsure(commandName: string): boolean { + const normalized = commandName.trim().toLowerCase(); + return normalized === "acp" || normalized === "new" || normalized === "reset"; +} + function readDiscordCommandArgs( interaction: CommandInteraction, definitions?: CommandArgDefinition[], @@ -1617,24 +1622,27 @@ async function dispatchDiscordCommandInteraction(params: { const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined; const configuredRoute = threadBinding == null - ? resolveConfiguredAcpRoute({ + ? resolveConfiguredBindingRoute({ cfg, route, - channel: "discord", - accountId, - conversationId: channelId, - parentConversationId: threadParentId, + conversation: { + channel: "discord", + accountId, + conversationId: channelId, + parentConversationId: threadParentId, + }, }) : null; - const configuredBinding = configuredRoute?.configuredBinding ?? null; - if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const configuredBinding = configuredRoute?.bindingResolution ?? null; + const commandName = command.nativeName ?? command.key; + if (configuredBinding && !shouldBypassConfiguredAcpEnsure(commandName)) { + const ensured = await ensureConfiguredBindingRouteReady({ cfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { logVerbose( - `discord native command: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `discord native command: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); await respond("Configured ACP binding is unavailable right now. Please try again."); return; diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index f03dce881c2..9de21e92d0d 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -228,6 +228,65 @@ describe("runDiscordGatewayLifecycle", () => { expect(connectedCall![0].lastConnectedAt).toBeTypeOf("number"); }); + it("forces a fresh reconnect when startup never reaches READY, then recovers", async () => { + vi.useFakeTimers(); + try { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const { emitter, gateway } = createGatewayHarness(); + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + gateway.connect.mockImplementation((_resume?: boolean) => { + setTimeout(() => { + gateway.isConnected = true; + }, 1_000); + }); + + const { lifecycleParams, runtimeError } = createLifecycleHarness({ gateway }); + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + await vi.advanceTimersByTimeAsync(15_000 + 1_000); + await expect(lifecyclePromise).resolves.toBeUndefined(); + + expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("gateway was not ready after 15000ms"), + ); + expect(gateway.disconnect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledWith(false); + } finally { + vi.useRealTimers(); + } + }); + + it("fails fast when startup never reaches READY after a forced reconnect", async () => { + vi.useFakeTimers(); + try { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const { emitter, gateway } = createGatewayHarness(); + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } = + createLifecycleHarness({ gateway }); + + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + lifecyclePromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(15_000 * 2 + 1_000); + await expect(lifecyclePromise).rejects.toThrow( + "discord gateway did not reach READY within 15000ms after a forced reconnect", + ); + + expect(gateway.disconnect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledWith(false); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + releaseEarlyGatewayErrorGuard, + }); + } finally { + vi.useRealTimers(); + } + }); + it("handles queued disallowed intents errors without waiting for gateway events", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { @@ -276,6 +335,51 @@ describe("runDiscordGatewayLifecycle", () => { }); }); + it("surfaces fatal startup gateway errors while waiting for READY", async () => { + vi.useFakeTimers(); + try { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const pendingGatewayErrors: unknown[] = []; + const { emitter, gateway } = createGatewayHarness(); + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + const { + lifecycleParams, + start, + stop, + threadStop, + runtimeError, + releaseEarlyGatewayErrorGuard, + } = createLifecycleHarness({ + gateway, + pendingGatewayErrors, + }); + + setTimeout(() => { + pendingGatewayErrors.push(new Error("Fatal Gateway error: 4001")); + }, 1_000); + + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + lifecyclePromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(1_500); + await expect(lifecyclePromise).rejects.toThrow("Fatal Gateway error: 4001"); + + expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("discord gateway error: Error: Fatal Gateway error: 4001"), + ); + expect(gateway.disconnect).not.toHaveBeenCalled(); + expect(gateway.connect).not.toHaveBeenCalled(); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + releaseEarlyGatewayErrorGuard, + }); + } finally { + vi.useRealTimers(); + } + }); + it("retries stalled HELLO with resume before forcing fresh identify", async () => { vi.useFakeTimers(); try { @@ -288,8 +392,11 @@ describe("runDiscordGatewayLifecycle", () => { }, sequence: 123, }); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce(async () => { + emitter.emit("debug", "WebSocket connection closed with code 1006"); + gateway.isConnected = false; await emitGatewayOpenAndWait(emitter); await emitGatewayOpenAndWait(emitter); await emitGatewayOpenAndWait(emitter); @@ -324,8 +431,13 @@ describe("runDiscordGatewayLifecycle", () => { }, sequence: 456, }); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce(async () => { + emitter.emit("debug", "WebSocket connection closed with code 1006"); + gateway.isConnected = false; + await emitGatewayOpenAndWait(emitter); + await emitGatewayOpenAndWait(emitter); // Successful reconnect (READY/RESUMED sets isConnected=true), then @@ -342,10 +454,11 @@ describe("runDiscordGatewayLifecycle", () => { const { lifecycleParams } = createLifecycleHarness({ gateway }); await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined(); - expect(gateway.connect).toHaveBeenCalledTimes(3); + expect(gateway.connect).toHaveBeenCalledTimes(4); expect(gateway.connect).toHaveBeenNthCalledWith(1, true); expect(gateway.connect).toHaveBeenNthCalledWith(2, true); expect(gateway.connect).toHaveBeenNthCalledWith(3, true); + expect(gateway.connect).toHaveBeenNthCalledWith(4, true); expect(gateway.connect).not.toHaveBeenCalledWith(false); } finally { vi.useRealTimers(); @@ -357,6 +470,7 @@ describe("runDiscordGatewayLifecycle", () => { try { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce( (waitParams: WaitForDiscordGatewayStopParams) => @@ -382,6 +496,7 @@ describe("runDiscordGatewayLifecycle", () => { try { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); let resolveWait: (() => void) | undefined; waitForDiscordGatewayStopMock.mockImplementationOnce( diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index 0d5fbd66b25..b2a9e8a6019 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -15,6 +15,37 @@ type ExecApprovalsHandler = { stop: () => Promise; }; +const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000; +const DISCORD_GATEWAY_READY_POLL_MS = 250; + +type GatewayReadyWaitResult = "ready" | "timeout" | "stopped"; + +async function waitForDiscordGatewayReady(params: { + gateway?: Pick; + abortSignal?: AbortSignal; + timeoutMs: number; + beforePoll?: () => Promise<"continue" | "stop"> | "continue" | "stop"; +}): Promise { + const deadlineAt = Date.now() + params.timeoutMs; + while (!params.abortSignal?.aborted) { + const pollDecision = await params.beforePoll?.(); + if (pollDecision === "stop") { + return "stopped"; + } + if (params.gateway?.isConnected) { + return "ready"; + } + if (Date.now() >= deadlineAt) { + return "timeout"; + } + await new Promise((resolve) => { + const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_POLL_MS); + timeout.unref?.(); + }); + } + return "stopped"; +} + export async function runDiscordGatewayLifecycle(params: { accountId: string; client: Client; @@ -242,20 +273,6 @@ export async function runDiscordGatewayLifecycle(params: { }; gatewayEmitter?.on("debug", onGatewayDebug); - // If the gateway is already connected when the lifecycle starts (the - // "WebSocket connection opened" debug event was emitted before we - // registered the listener above), push the initial connected status now. - // Guard against lifecycleStopping: if the abortSignal was already aborted, - // onAbort() ran synchronously above and pushed connected: false — don't - // contradict it with a spurious connected: true. - if (gateway?.isConnected && !lifecycleStopping) { - const at = Date.now(); - pushStatus({ - ...createConnectedChannelStatusPatch(at), - lastDisconnect: null, - }); - } - let sawDisallowedIntents = false; const logGatewayError = (err: unknown) => { if (params.isDisallowedIntentsError(err)) { @@ -277,28 +294,107 @@ export async function runDiscordGatewayLifecycle(params: { params.isDisallowedIntentsError(err) ); }; + const drainPendingGatewayErrors = (): "continue" | "stop" => { + const pendingGatewayErrors = params.pendingGatewayErrors ?? []; + if (pendingGatewayErrors.length === 0) { + return "continue"; + } + const queuedErrors = [...pendingGatewayErrors]; + pendingGatewayErrors.length = 0; + for (const err of queuedErrors) { + logGatewayError(err); + if (!shouldStopOnGatewayError(err)) { + continue; + } + if (params.isDisallowedIntentsError(err)) { + return "stop"; + } + throw err; + } + return "continue"; + }; try { if (params.execApprovalsHandler) { await params.execApprovalsHandler.start(); } // Drain gateway errors emitted before lifecycle listeners were attached. - const pendingGatewayErrors = params.pendingGatewayErrors ?? []; - if (pendingGatewayErrors.length > 0) { - const queuedErrors = [...pendingGatewayErrors]; - pendingGatewayErrors.length = 0; - for (const err of queuedErrors) { - logGatewayError(err); - if (!shouldStopOnGatewayError(err)) { - continue; - } - if (params.isDisallowedIntentsError(err)) { + if (drainPendingGatewayErrors() === "stop") { + return; + } + + // Carbon starts the gateway during client construction, before OpenClaw can + // attach lifecycle listeners. Require a READY/RESUMED-connected gateway + // before continuing so the monitor does not look healthy while silently + // missing inbound events. + if (gateway && !gateway.isConnected && !lifecycleStopping) { + const initialReady = await waitForDiscordGatewayReady({ + gateway, + abortSignal: params.abortSignal, + timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS, + beforePoll: drainPendingGatewayErrors, + }); + if (initialReady === "stopped" || lifecycleStopping) { + return; + } + if (initialReady === "timeout" && !lifecycleStopping) { + params.runtime.error?.( + danger( + `discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; forcing a fresh reconnect`, + ), + ); + const startupRetryAt = Date.now(); + pushStatus({ + connected: false, + lastEventAt: startupRetryAt, + lastDisconnect: { + at: startupRetryAt, + error: "startup-not-ready", + }, + }); + gateway?.disconnect(); + gateway?.connect(false); + const reconnected = await waitForDiscordGatewayReady({ + gateway, + abortSignal: params.abortSignal, + timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS, + beforePoll: drainPendingGatewayErrors, + }); + if (reconnected === "stopped" || lifecycleStopping) { return; } - throw err; + if (reconnected === "timeout" && !lifecycleStopping) { + const error = new Error( + `discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after a forced reconnect`, + ); + const startupFailureAt = Date.now(); + pushStatus({ + connected: false, + lastEventAt: startupFailureAt, + lastDisconnect: { + at: startupFailureAt, + error: "startup-reconnect-timeout", + }, + lastError: error.message, + }); + throw error; + } } } + // If the gateway is already connected when the lifecycle starts (or becomes + // connected during the startup readiness guard), push the initial connected + // status now. Guard against lifecycleStopping: if the abortSignal was + // already aborted, onAbort() ran synchronously above and pushed connected: + // false, so don't contradict it with a spurious connected: true. + if (gateway?.isConnected && !lifecycleStopping) { + const at = Date.now(); + pushStatus({ + ...createConnectedChannelStatusPatch(at), + lastDisconnect: null, + }); + } + await waitForDiscordGatewayStop({ gateway: gateway ? { diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 72da5136c7a..f8e9f52c198 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -142,11 +142,30 @@ describe("createDiscordGatewayPlugin", () => { }); await expect(registerGatewayClient(plugin)).rejects.toThrow( - "Failed to get gateway information from Discord: fetch failed", + "Failed to get gateway information from Discord", ); expect(baseRegisterClientSpy).not.toHaveBeenCalled(); } + async function expectGatewayRegisterFallback(response: Response) { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue(response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await registerGatewayClient(plugin); + + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe( + "wss://gateway.discord.gg/", + ); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("discord: gateway metadata lookup failed transiently"), + ); + } + async function registerGatewayClientWithMetadata(params: { plugin: unknown; fetchMock: typeof globalFetchMock; @@ -161,6 +180,7 @@ describe("createDiscordGatewayPlugin", () => { beforeEach(() => { vi.stubGlobal("fetch", globalFetchMock); + vi.useRealTimers(); baseRegisterClientSpy.mockClear(); globalFetchMock.mockClear(); restProxyAgentSpy.mockClear(); @@ -190,7 +210,7 @@ describe("createDiscordGatewayPlugin", () => { }); it("maps plain-text Discord 503 responses to fetch failed", async () => { - await expectGatewayRegisterFetchFailure({ + await expectGatewayRegisterFallback({ ok: false, status: 503, text: async () => @@ -198,6 +218,14 @@ describe("createDiscordGatewayPlugin", () => { } as Response); }); + it("keeps fatal Discord metadata failures fatal", async () => { + await expectGatewayRegisterFetchFailure({ + ok: false, + status: 401, + text: async () => "401: Unauthorized", + } as Response); + }); + it("uses proxy agent for gateway WebSocket when configured", async () => { const runtime = createRuntime(); @@ -255,7 +283,7 @@ describe("createDiscordGatewayPlugin", () => { }); it("maps body read failures to fetch failed", async () => { - await expectGatewayRegisterFetchFailure({ + await expectGatewayRegisterFallback({ ok: true, status: 200, text: async () => { @@ -263,4 +291,68 @@ describe("createDiscordGatewayPlugin", () => { }, } as unknown as Response); }); + + it("falls back to the default gateway url when metadata lookup times out", async () => { + vi.useFakeTimers(); + const runtime = createRuntime(); + globalFetchMock.mockImplementation(() => new Promise(() => {})); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + const registerPromise = registerGatewayClient(plugin); + await vi.advanceTimersByTimeAsync(10_000); + await registerPromise; + + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe( + "wss://gateway.discord.gg/", + ); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("discord: gateway metadata lookup failed transiently"), + ); + }); + + it("refreshes fallback gateway metadata on the next register attempt", async () => { + const runtime = createRuntime(); + globalFetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + text: async () => + "upstream connect error or disconnect/reset before headers. reset reason: overflow", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + url: "wss://gateway.discord.gg/?v=10", + shards: 8, + session_start_limit: { + total: 1000, + remaining: 999, + reset_after: 120_000, + max_concurrency: 16, + }, + }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await registerGatewayClient(plugin); + await registerGatewayClient(plugin); + + expect(globalFetchMock).toHaveBeenCalledTimes(2); + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(2); + expect( + (plugin as unknown as { gatewayInfo?: { url?: string; shards?: number } }).gatewayInfo, + ).toMatchObject({ + url: "wss://gateway.discord.gg/?v=10", + shards: 8, + }); + }); }); diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index ed221645fcf..237cc6b8081 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeConfigSnapshot, @@ -12,12 +13,12 @@ import { getSessionBindingService } from "../../../../src/infra/outbound/session const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({})); - const restGet = vi.fn(async () => ({ + const restGet = vi.fn(async (..._args: unknown[]) => ({ id: "thread-1", type: 11, parent_id: "parent-1", })); - const restPost = vi.fn(async () => ({ + const restPost = vi.fn(async (..._args: unknown[]) => ({ id: "wh-created", token: "tok-created", })); @@ -45,47 +46,151 @@ vi.mock("../send.js", () => ({ sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord, })); -vi.mock("../client.js", () => ({ - createDiscordRestClient: hoisted.createDiscordRestClient, -})); - vi.mock("../send.messages.js", () => ({ createThreadDiscord: hoisted.createThreadDiscord, })); -vi.mock("../../../../src/acp/runtime/session-meta.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - readAcpSessionEntry: hoisted.readAcpSessionEntry, - }; -}); - +const { __testing, createThreadBindingManager } = await import("./thread-bindings.manager.js"); const { - __testing, autoBindSpawnedDiscordSubagent, - createThreadBindingManager, reconcileAcpThreadBindingsOnStartup, - resolveThreadBindingInactivityExpiresAt, - resolveThreadBindingIntroText, - resolveThreadBindingMaxAgeExpiresAt, setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} = await import("./thread-bindings.js"); +} = await import("./thread-bindings.lifecycle.js"); +const { resolveThreadBindingInactivityExpiresAt, resolveThreadBindingMaxAgeExpiresAt } = + await import("./thread-bindings.state.js"); +const { resolveThreadBindingIntroText } = await import("./thread-bindings.messages.js"); +const discordClientModule = await import("../client.js"); +const discordThreadBindingApi = await import("./thread-bindings.discord-api.js"); +const acpRuntime = await import("openclaw/plugin-sdk/acp-runtime"); describe("thread binding lifecycle", () => { beforeEach(() => { __testing.resetThreadBindingsForTests(); clearRuntimeConfigSnapshot(); - hoisted.sendMessageDiscord.mockClear(); - hoisted.sendWebhookMessageDiscord.mockClear(); - hoisted.restGet.mockClear(); - hoisted.restPost.mockClear(); - hoisted.createDiscordRestClient.mockClear(); - hoisted.createThreadDiscord.mockClear(); + vi.restoreAllMocks(); + hoisted.sendMessageDiscord.mockReset().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockReset().mockResolvedValue({}); + hoisted.restGet.mockReset().mockResolvedValue({ + id: "thread-1", + type: 11, + parent_id: "parent-1", + }); + hoisted.restPost.mockReset().mockResolvedValue({ + id: "wh-created", + token: "tok-created", + }); + hoisted.createDiscordRestClient.mockReset().mockImplementation((..._args: unknown[]) => ({ + rest: { + get: hoisted.restGet, + post: hoisted.restPost, + }, + })); + hoisted.createThreadDiscord.mockReset().mockResolvedValue({ id: "thread-created" }); hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null); + vi.spyOn(discordClientModule, "createDiscordRestClient").mockImplementation( + (...args) => + hoisted.createDiscordRestClient(...args) as unknown as ReturnType< + typeof discordClientModule.createDiscordRestClient + >, + ); + vi.spyOn(discordThreadBindingApi, "createWebhookForChannel").mockImplementation( + async (params) => { + const rest = hoisted.createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; + const created = (await rest.post("mock:channel-webhook")) as { + id?: string; + token?: string; + }; + return { + webhookId: typeof created?.id === "string" ? created.id.trim() || undefined : undefined, + webhookToken: + typeof created?.token === "string" ? created.token.trim() || undefined : undefined, + }; + }, + ); + vi.spyOn(discordThreadBindingApi, "resolveChannelIdForBinding").mockImplementation( + async (params) => { + const explicit = params.channelId?.trim(); + if (explicit) { + return explicit; + } + const rest = hoisted.createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; + const channel = (await rest.get("mock:channel-resolve")) as { + id?: string; + type?: number; + parent_id?: string; + parentId?: string; + }; + const channelId = typeof channel?.id === "string" ? channel.id.trim() : ""; + const parentId = + typeof channel?.parent_id === "string" + ? channel.parent_id.trim() + : typeof channel?.parentId === "string" + ? channel.parentId.trim() + : ""; + const isThreadType = + channel?.type === ChannelType.PublicThread || + channel?.type === ChannelType.PrivateThread || + channel?.type === ChannelType.AnnouncementThread; + if (parentId && isThreadType) { + return parentId; + } + return channelId || null; + }, + ); + vi.spyOn(discordThreadBindingApi, "createThreadForBinding").mockImplementation( + async (params) => { + const created = await hoisted.createThreadDiscord( + params.channelId, + { + name: params.threadName, + autoArchiveMinutes: 60, + }, + { + accountId: params.accountId, + token: params.token, + cfg: params.cfg, + }, + ); + return typeof created?.id === "string" ? created.id.trim() || null : null; + }, + ); + vi.spyOn(discordThreadBindingApi, "maybeSendBindingMessage").mockImplementation( + async (params) => { + if ( + params.preferWebhook !== false && + params.record.webhookId && + params.record.webhookToken + ) { + await hoisted.sendWebhookMessageDiscord(params.text, { + cfg: params.cfg, + webhookId: params.record.webhookId, + webhookToken: params.record.webhookToken, + accountId: params.record.accountId, + threadId: params.record.threadId, + }); + return; + } + await hoisted.sendMessageDiscord(`channel:${params.record.threadId}`, params.text, { + cfg: params.cfg, + accountId: params.record.accountId, + }); + }, + ); + vi.spyOn(acpRuntime, "readAcpSessionEntry").mockImplementation(hoisted.readAcpSessionEntry); vi.useRealTimers(); }); @@ -93,7 +198,7 @@ describe("thread binding lifecycle", () => { createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 24 * 60 * 60 * 1000, maxAgeMs: 0, }); @@ -139,7 +244,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 60_000, maxAgeMs: 0, }); @@ -159,6 +264,7 @@ describe("thread binding lifecycle", () => { hoisted.sendWebhookMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeUndefined(); expect(hoisted.restGet).not.toHaveBeenCalled(); @@ -177,7 +283,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 0, maxAgeMs: 60_000, }); @@ -195,6 +301,7 @@ describe("thread binding lifecycle", () => { hoisted.sendMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeUndefined(); expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1); @@ -214,6 +321,7 @@ describe("thread binding lifecycle", () => { hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET")); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeDefined(); expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled(); @@ -234,6 +342,7 @@ describe("thread binding lifecycle", () => { }); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeUndefined(); expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled(); @@ -334,7 +443,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 60_000, maxAgeMs: 0, }); @@ -358,6 +467,7 @@ describe("thread binding lifecycle", () => { expect(updated[0]?.idleTimeoutMs).toBe(0); await vi.advanceTimersByTimeAsync(240_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeDefined(); } finally { @@ -371,7 +481,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 60_000, maxAgeMs: 0, }); @@ -417,6 +527,7 @@ describe("thread binding lifecycle", () => { hoisted.sendMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-2")).toBeDefined(); expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled(); diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index f6d5f7d3d90..5c37ac4bbf0 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -69,6 +69,8 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) { } } +const SWEEPERS_BY_ACCOUNT_ID = new Map Promise>(); + function resolveEffectiveBindingExpiresAt(params: { record: ThreadBindingRecord; defaultIdleTimeoutMs: number; @@ -200,6 +202,111 @@ export function createThreadBindingManager( const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token; let sweepTimer: NodeJS.Timeout | null = null; + const runSweepOnce = async () => { + const bindings = manager.listBindings(); + if (bindings.length === 0) { + return; + } + let rest: ReturnType["rest"] | null = null; + for (const snapshotBinding of bindings) { + // Re-read live state after any awaited work from earlier iterations. + // This avoids unbinding based on stale snapshot data when activity touches + // happen while the sweeper loop is in-flight. + const binding = manager.getByThreadId(snapshotBinding.threadId); + if (!binding) { + continue; + } + const now = Date.now(); + const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({ + record: binding, + defaultIdleTimeoutMs: idleTimeoutMs, + }); + const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({ + record: binding, + defaultMaxAgeMs: maxAgeMs, + }); + const expirationCandidates: Array<{ + reason: "idle-expired" | "max-age-expired"; + at: number; + }> = []; + if (inactivityExpiresAt != null && now >= inactivityExpiresAt) { + expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt }); + } + if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) { + expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt }); + } + if (expirationCandidates.length > 0) { + expirationCandidates.sort((a, b) => a.at - b.at); + const reason = expirationCandidates[0]?.reason ?? "idle-expired"; + manager.unbindThread({ + threadId: binding.threadId, + reason, + sendFarewell: true, + farewellText: resolveThreadBindingFarewellText({ + reason, + idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({ + record: binding, + defaultIdleTimeoutMs: idleTimeoutMs, + }), + maxAgeMs: resolveThreadBindingMaxAgeMs({ + record: binding, + defaultMaxAgeMs: maxAgeMs, + }), + }), + }); + continue; + } + if (isDirectConversationBindingId(binding.threadId)) { + continue; + } + if (!rest) { + try { + const cfg = resolveCurrentCfg(); + rest = createDiscordRestClient( + { + accountId, + token: resolveCurrentToken(), + }, + cfg, + ).rest; + } catch { + return; + } + } + try { + const channel = await rest.get(Routes.channel(binding.threadId)); + if (!channel || typeof channel !== "object") { + logVerbose( + `discord thread binding sweep probe returned invalid payload for ${binding.threadId}`, + ); + continue; + } + if (isThreadArchived(channel)) { + manager.unbindThread({ + threadId: binding.threadId, + reason: "thread-archived", + sendFarewell: true, + }); + } + } catch (err) { + if (isDiscordThreadGoneError(err)) { + logVerbose( + `discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`, + ); + manager.unbindThread({ + threadId: binding.threadId, + reason: "thread-delete", + sendFarewell: false, + }); + continue; + } + logVerbose( + `discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`, + ); + } + } + }; + SWEEPERS_BY_ACCOUNT_ID.set(accountId, runSweepOnce); const manager: ThreadBindingManager = { accountId, @@ -444,6 +551,7 @@ export function createThreadBindingManager( clearInterval(sweepTimer); sweepTimer = null; } + SWEEPERS_BY_ACCOUNT_ID.delete(accountId); unregisterManager(accountId, manager); unregisterSessionBindingAdapter({ channel: "discord", @@ -455,110 +563,13 @@ export function createThreadBindingManager( if (params.enableSweeper !== false) { sweepTimer = setInterval(() => { - void (async () => { - const bindings = manager.listBindings(); - if (bindings.length === 0) { - return; - } - let rest; - try { - const cfg = resolveCurrentCfg(); - rest = createDiscordRestClient( - { - accountId, - token: resolveCurrentToken(), - }, - cfg, - ).rest; - } catch { - return; - } - for (const snapshotBinding of bindings) { - // Re-read live state after any awaited work from earlier iterations. - // This avoids unbinding based on stale snapshot data when activity touches - // happen while the sweeper loop is in-flight. - const binding = manager.getByThreadId(snapshotBinding.threadId); - if (!binding) { - continue; - } - const now = Date.now(); - const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({ - record: binding, - defaultIdleTimeoutMs: idleTimeoutMs, - }); - const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({ - record: binding, - defaultMaxAgeMs: maxAgeMs, - }); - const expirationCandidates: Array<{ - reason: "idle-expired" | "max-age-expired"; - at: number; - }> = []; - if (inactivityExpiresAt != null && now >= inactivityExpiresAt) { - expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt }); - } - if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) { - expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt }); - } - if (expirationCandidates.length > 0) { - expirationCandidates.sort((a, b) => a.at - b.at); - const reason = expirationCandidates[0]?.reason ?? "idle-expired"; - manager.unbindThread({ - threadId: binding.threadId, - reason, - sendFarewell: true, - farewellText: resolveThreadBindingFarewellText({ - reason, - idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({ - record: binding, - defaultIdleTimeoutMs: idleTimeoutMs, - }), - maxAgeMs: resolveThreadBindingMaxAgeMs({ - record: binding, - defaultMaxAgeMs: maxAgeMs, - }), - }), - }); - continue; - } - if (isDirectConversationBindingId(binding.threadId)) { - continue; - } - try { - const channel = await rest.get(Routes.channel(binding.threadId)); - if (!channel || typeof channel !== "object") { - logVerbose( - `discord thread binding sweep probe returned invalid payload for ${binding.threadId}`, - ); - continue; - } - if (isThreadArchived(channel)) { - manager.unbindThread({ - threadId: binding.threadId, - reason: "thread-archived", - sendFarewell: true, - }); - } - } catch (err) { - if (isDiscordThreadGoneError(err)) { - logVerbose( - `discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`, - ); - manager.unbindThread({ - threadId: binding.threadId, - reason: "thread-delete", - sendFarewell: false, - }); - continue; - } - logVerbose( - `discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`, - ); - } - } - })(); + void runSweepOnce(); }, THREAD_BINDINGS_SWEEP_INTERVAL_MS); - sweepTimer.unref?.(); + // Keep the production process free to exit, but avoid breaking fake-timer + // sweeper tests where unref'd intervals may never fire. + if (!(process.env.VITEST || process.env.NODE_ENV === "test")) { + sweepTimer.unref?.(); + } } registerSessionBindingAdapter({ @@ -690,4 +701,10 @@ export const __testing = { resolveThreadBindingsPath, resolveThreadBindingThreadName, resetThreadBindingsForTests, + runThreadBindingSweepForAccount: async (accountId?: string) => { + const sweep = SWEEPERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)); + if (sweep) { + await sweep(); + } + }, }; diff --git a/extensions/discord/src/monitor/thread-session-close.test.ts b/extensions/discord/src/monitor/thread-session-close.test.ts index f2109150c66..a5cca87119c 100644 --- a/extensions/discord/src/monitor/thread-session-close.test.ts +++ b/extensions/discord/src/monitor/thread-session-close.test.ts @@ -6,10 +6,14 @@ const hoisted = vi.hoisted(() => { return { updateSessionStore, resolveStorePath }; }); -vi.mock("../../../../src/config/sessions.js", () => ({ - updateSessionStore: hoisted.updateSessionStore, - resolveStorePath: hoisted.resolveStorePath, -})); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateSessionStore: hoisted.updateSessionStore, + resolveStorePath: hoisted.resolveStorePath, + }; +}); const { closeDiscordThreadSessions } = await import("./thread-session-close.js"); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 910fa03f28c..0995632e3a1 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -21,8 +21,8 @@ const { mockResolveAgentRoute, mockReadSessionUpdatedAt, mockResolveStorePath, - mockResolveConfiguredAcpRoute, - mockEnsureConfiguredAcpRouteReady, + mockResolveConfiguredBindingRoute, + mockEnsureConfiguredBindingRouteReady, mockResolveBoundConversation, mockTouchBinding, } = vi.hoisted(() => ({ @@ -50,11 +50,12 @@ const { })), mockReadSessionUpdatedAt: vi.fn(), mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"), - mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({ + mockResolveConfiguredBindingRoute: vi.fn(({ route }) => ({ + bindingResolution: null, configuredBinding: null, route, })), - mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })), + mockEnsureConfiguredBindingRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })), mockResolveBoundConversation: vi.fn(() => null), mockTouchBinding: vi.fn(), })); @@ -78,12 +79,12 @@ vi.mock("./client.js", () => ({ })); vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const original = - await importOriginal(); + const actual = await importOriginal(); return { - ...original, - resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params), - ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params), + ...actual, + resolveConfiguredBindingRoute: (params: unknown) => mockResolveConfiguredBindingRoute(params), + ensureConfiguredBindingRouteReady: (params: unknown) => + mockEnsureConfiguredBindingRouteReady(params), getSessionBindingService: () => ({ resolveByConversation: mockResolveBoundConversation, touch: mockTouchBinding, @@ -91,6 +92,13 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { }; }); +vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ + getSessionBindingService: () => ({ + resolveByConversation: mockResolveBoundConversation, + touch: mockTouchBinding, + }), +})); + function createRuntimeEnv(): RuntimeEnv { return { log: vi.fn(), @@ -138,14 +146,15 @@ describe("buildFeishuAgentBody", () => { describe("handleFeishuMessage ACP routing", () => { beforeEach(() => { vi.clearAllMocks(); - mockResolveConfiguredAcpRoute.mockReset().mockImplementation( + mockResolveConfiguredBindingRoute.mockReset().mockImplementation( ({ route }) => ({ + bindingResolution: null, configuredBinding: null, route, }) as any, ); - mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true }); + mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true }); mockResolveBoundConversation.mockReset().mockReturnValue(null); mockTouchBinding.mockReset(); mockResolveAgentRoute.mockReset().mockReturnValue({ @@ -218,7 +227,37 @@ describe("handleFeishuMessage ACP routing", () => { }); it("ensures configured ACP routes for Feishu DMs", async () => { - mockResolveConfiguredAcpRoute.mockReturnValue({ + mockResolveConfiguredBindingRoute.mockReturnValue({ + bindingResolution: { + configuredBinding: { + spec: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:feishu:default:ou_sender_1", + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + }, + status: "active", + boundAt: 0, + metadata: { source: "config" }, + }, + }, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: "agent:codex:acp:binding:feishu:default:abc123", + agentId: "codex", + }, + }, configuredBinding: { spec: { channel: "feishu", @@ -268,12 +307,42 @@ describe("handleFeishuMessage ACP routing", () => { }, }); - expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1); - expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1); + expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); + expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); }); it("surfaces configured ACP initialization failures to the Feishu conversation", async () => { - mockResolveConfiguredAcpRoute.mockReturnValue({ + mockResolveConfiguredBindingRoute.mockReturnValue({ + bindingResolution: { + configuredBinding: { + spec: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:feishu:default:ou_sender_1", + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + }, + status: "active", + boundAt: 0, + metadata: { source: "config" }, + }, + }, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: "agent:codex:acp:binding:feishu:default:abc123", + agentId: "codex", + }, + }, configuredBinding: { spec: { channel: "feishu", @@ -305,7 +374,7 @@ describe("handleFeishuMessage ACP routing", () => { matchedBy: "binding.channel", }, } as any); - mockEnsureConfiguredAcpRouteReady.mockResolvedValue({ + mockEnsureConfiguredBindingRouteReady.mockResolvedValue({ ok: false, error: "runtime unavailable", } as any); @@ -433,14 +502,15 @@ describe("handleFeishuMessage command authorization", () => { mockListFeishuThreadMessages.mockReset().mockResolvedValue([]); mockReadSessionUpdatedAt.mockReturnValue(undefined); mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json"); - mockResolveConfiguredAcpRoute.mockReset().mockImplementation( + mockResolveConfiguredBindingRoute.mockReset().mockImplementation( ({ route }) => ({ + bindingResolution: null, configuredBinding: null, route, }) as any, ); - mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true }); + mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true }); mockResolveBoundConversation.mockReset().mockReturnValue(null); mockTouchBinding.mockReset(); mockResolveAgentRoute.mockReturnValue({ diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 6181d32f4af..bc47d6d934f 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1,6 +1,6 @@ import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; @@ -1251,15 +1251,17 @@ export async function handleFeishuMessage(params: { const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined; let configuredBinding = null; if (feishuAcpConversationSupported) { - const configuredRoute = resolveConfiguredAcpRoute({ + const configuredRoute = resolveConfiguredBindingRoute({ cfg: effectiveCfg, route, - channel: "feishu", - accountId: account.accountId, - conversationId: currentConversationId, - parentConversationId, + conversation: { + channel: "feishu", + accountId: account.accountId, + conversationId: currentConversationId, + parentConversationId, + }, }); - configuredBinding = configuredRoute.configuredBinding; + configuredBinding = configuredRoute.bindingResolution; route = configuredRoute.route; // Bound Feishu conversations intentionally require an exact live conversation-id match. @@ -1292,9 +1294,9 @@ export async function handleFeishuMessage(params: { } if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg: effectiveCfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { const replyTargetMessageId = diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 52d2e04aa1a..6111eeabffa 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -822,11 +822,15 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, - acpBindings: { - normalizeConfiguredBindingTarget: ({ conversationId }) => + bindings: { + compileConfiguredBinding: ({ conversationId }) => normalizeFeishuAcpConversationId(conversationId), - matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => - matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => + matchFeishuAcpConversation({ + bindingConversationId: compiledBinding.conversationId, + conversationId, + parentConversationId, + }), }, setup: feishuSetupAdapter, setupWizard: feishuSetupWizard, diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index ec1ebdc5b77..f394aec8b3e 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -1,19 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const clientCtorMock = vi.hoisted(() => vi.fn()); -const mockBaseHttpInstance = vi.hoisted(() => ({ - request: vi.fn().mockResolvedValue({}), - get: vi.fn().mockResolvedValue({}), - post: vi.fn().mockResolvedValue({}), - put: vi.fn().mockResolvedValue({}), - patch: vi.fn().mockResolvedValue({}), - delete: vi.fn().mockResolvedValue({}), - head: vi.fn().mockResolvedValue({}), - options: vi.fn().mockResolvedValue({}), +const createFeishuClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, })); -import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js"; -import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js"; +async function importProbeModule(scope: string) { + void scope; + vi.resetModules(); + return await import("./probe.js"); +} + +let FEISHU_PROBE_REQUEST_TIMEOUT_MS: typeof import("./probe.js").FEISHU_PROBE_REQUEST_TIMEOUT_MS; +let probeFeishu: typeof import("./probe.js").probeFeishu; +let clearProbeCache: typeof import("./probe.js").clearProbeCache; const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret const DEFAULT_SUCCESS_RESPONSE = { @@ -35,15 +36,9 @@ function makeRequestFn(response: Record) { return vi.fn().mockResolvedValue(response); } -function installClientCtor(requestFn: unknown) { - clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) { - this.request = requestFn; - } as never); -} - function setupClient(response: Record) { const requestFn = makeRequestFn(response); - installClientCtor(requestFn); + createFeishuClientMock.mockReturnValue({ request: requestFn }); return requestFn; } @@ -53,7 +48,12 @@ function setupSuccessClient() { async function expectDefaultSuccessResult( creds = DEFAULT_CREDS, - expected: Awaited> = DEFAULT_SUCCESS_RESULT, + expected: { + ok: true; + appId: string; + botName: string; + botOpenId: string; + } = DEFAULT_SUCCESS_RESULT, ) { const result = await probeFeishu(creds); expect(result).toEqual(expected); @@ -73,7 +73,7 @@ async function expectErrorResultCached(params: { expectedError: string; ttlMs: number; }) { - installClientCtor(params.requestFn); + createFeishuClientMock.mockReturnValue({ request: params.requestFn }); const first = await probeFeishu(DEFAULT_CREDS); const second = await probeFeishu(DEFAULT_CREDS); @@ -106,27 +106,16 @@ async function readSequentialDefaultProbePair() { } describe("probeFeishu", () => { - beforeEach(() => { + beforeEach(async () => { + ({ FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } = await importProbeModule( + `probe-${Date.now()}-${Math.random()}`, + )); clearProbeCache(); - clearClientCache(); - vi.clearAllMocks(); - setFeishuClientRuntimeForTest({ - sdk: { - AppType: { SelfBuild: "self" } as never, - Domain: { - Feishu: "https://open.feishu.cn", - Lark: "https://open.larksuite.com", - } as never, - Client: clientCtorMock as never, - defaultHttpInstance: mockBaseHttpInstance as never, - }, - }); + vi.restoreAllMocks(); }); afterEach(() => { clearProbeCache(); - clearClientCache(); - setFeishuClientRuntimeForTest(); }); it("returns error when credentials are missing", async () => { @@ -168,7 +157,7 @@ describe("probeFeishu", () => { it("returns timeout error when request exceeds timeout", async () => { await withFakeTimers(async () => { const requestFn = vi.fn().mockImplementation(() => new Promise(() => {})); - installClientCtor(requestFn); + createFeishuClientMock.mockReturnValue({ request: requestFn }); const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 }); await vi.advanceTimersByTimeAsync(1_000); @@ -179,6 +168,7 @@ describe("probeFeishu", () => { }); it("returns aborted when abort signal is already aborted", async () => { + createFeishuClientMock.mockClear(); const abortController = new AbortController(); abortController.abort(); @@ -188,7 +178,7 @@ describe("probeFeishu", () => { ); expect(result).toMatchObject({ ok: false, error: "probe aborted" }); - expect(clientCtorMock).not.toHaveBeenCalled(); + expect(createFeishuClientMock).not.toHaveBeenCalled(); }); it("returns cached result on subsequent calls within TTL", async () => { const requestFn = setupSuccessClient(); diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index b154e067116..62c0fed6d81 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -49,6 +49,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerImageGenerationProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, + onConversationBindingResolved() {}, registerHook() {}, registerHttpRoute() {}, registerCommand() {}, diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 1f9adb41a72..44aa89a7623 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -1,14 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); -const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn()); +const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); +const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../src/acp/persistent-bindings.js", () => ({ - ensureConfiguredAcpBindingSession: (...args: unknown[]) => - ensureConfiguredAcpBindingSessionMock(...args), - resolveConfiguredAcpBindingRecord: (...args: unknown[]) => - resolveConfiguredAcpBindingRecordMock(...args), -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureConfiguredBindingRouteReady: (...args: unknown[]) => + ensureConfiguredBindingRouteReadyMock(...args), + resolveConfiguredBindingRoute: (...args: unknown[]) => + resolveConfiguredBindingRouteMock(...args), + }; +}); import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; @@ -43,15 +47,92 @@ function createConfiguredTelegramBinding() { } as const; } +function createConfiguredTelegramRoute() { + const configuredBinding = createConfiguredTelegramBinding(); + return { + bindingResolution: { + conversation: { + channel: "telegram", + accountId: "work", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + compiledBinding: { + channel: "telegram", + accountPattern: "work", + binding: { + type: "acp", + agentId: "codex", + match: { + channel: "telegram", + accountId: "work", + peer: { + kind: "group", + id: "-1001234567890:topic:42", + }, + }, + }, + bindingConversationId: "-1001234567890:topic:42", + target: { + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }), + matchInboundConversation: () => ({ + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }), + }, + targetFactory: { + driverId: "acp", + materialize: () => ({ + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }, + configuredBinding, + boundSessionKey: configuredBinding.record.targetSessionKey, + route: { + agentId: "codex", + accountId: "work", + channel: "telegram", + sessionKey: configuredBinding.record.targetSessionKey, + mainSessionKey: "agent:codex:main", + matchedBy: "binding.channel", + lastRoutePolicy: "bound", + }, + } as const; +} + describe("buildTelegramMessageContext ACP configured bindings", () => { beforeEach(() => { - ensureConfiguredAcpBindingSessionMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding()); - ensureConfiguredAcpBindingSessionMock.mockResolvedValue({ - ok: true, - sessionKey: "agent:codex:acp:binding:telegram:work:abc123", - }); + ensureConfiguredBindingRouteReadyMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredTelegramRoute()); + ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); }); it("treats configured topic bindings as explicit route matches on non-default accounts", async () => { @@ -68,7 +149,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123"); - expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); }); it("skips ACP session initialization when topic access is denied", async () => { @@ -86,8 +167,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }); expect(ctx).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled(); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled(); }); it("defers ACP session initialization for unauthorized control commands", async () => { @@ -109,14 +190,13 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }); expect(ctx).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled(); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled(); }); it("drops inbound processing when configured ACP binding initialization fails", async () => { - ensureConfiguredAcpBindingSessionMock.mockResolvedValue({ + ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: false, - sessionKey: "agent:codex:acp:binding:telegram:work:abc123", error: "gateway unavailable", }); @@ -130,7 +210,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }); expect(ctx).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index b569b1aeb1e..78ba9f02492 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -7,7 +7,7 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; -import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; @@ -201,24 +201,24 @@ export const buildTelegramMessageContext = async ({ if (!configuredBinding) { return true; } - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg: freshCfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (ensured.ok) { logVerbose( - `telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`, + `telegram: using configured ACP binding for ${configuredBinding.record.conversation.conversationId} -> ${configuredBindingSessionKey}`, ); return true; } logVerbose( - `telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `telegram: configured ACP binding unavailable for ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); logInboundDrop({ log: logVerbose, channel: "telegram", reason: "configured ACP binding unavailable", - target: configuredBinding.spec.conversationId, + target: configuredBinding.record.conversation.conversationId, }); return false; }; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 0a75b12fc1a..7540f22b1ac 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js"; import { createDeferred, createNativeCommandTestParams, @@ -14,10 +15,10 @@ import { // All mocks scoped to this file only — does not affect bot-native-commands.test.ts -type ResolveConfiguredAcpBindingRecordFn = - typeof import("../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord; -type EnsureConfiguredAcpBindingSessionFn = - typeof import("../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession; +type ResolveConfiguredBindingRouteFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; +type EnsureConfiguredBindingRouteReadyFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyWithBufferedBlockDispatcherParams = @@ -34,10 +35,12 @@ const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { }; const persistentBindingMocks = vi.hoisted(() => ({ - resolveConfiguredAcpBindingRecord: vi.fn(() => null), - ensureConfiguredAcpBindingSession: vi.fn(async () => ({ + resolveConfiguredBindingRoute: vi.fn(({ route }) => ({ + bindingResolution: null, + route, + })), + ensureConfiguredBindingRouteReady: vi.fn(async () => ({ ok: true, - sessionKey: "agent:codex:acp:binding:telegram:default:seed", })), })); const sessionMocks = vi.hoisted(() => ({ @@ -59,12 +62,58 @@ const sessionBindingMocks = vi.hoisted(() => ({ touch: vi.fn(), })); -vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord, - ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession, + resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, + getSessionBindingService: () => ({ + bind: vi.fn(), + getCapabilities: vi.fn(), + listBySession: vi.fn(), + resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), + touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), + unbind: vi.fn(), + }), + }; +}); +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), + recordInboundSessionMetaSafe: vi.fn( + async (params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; + ctx: unknown; + onError?: (error: unknown) => void; + }) => { + const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, { + agentId: params.agentId, + }); + try { + await sessionMocks.recordSessionMetaFromInbound({ + storePath, + sessionKey: params.sessionKey, + ctx: params.ctx, + }); + } catch (error) { + params.onError?.(error); + } + }, + ), + }; +}); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, + listSkillCommandsForAgents: vi.fn(() => []), }; }); vi.mock("../../../src/config/sessions.js", () => ({ @@ -74,15 +123,6 @@ vi.mock("../../../src/config/sessions.js", () => ({ vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); -vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({ - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), -})); -vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, -})); -vi.mock("../../../src/channels/reply-prefix.js", () => ({ - createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), -})); vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ getSessionBindingService: () => ({ bind: vi.fn(), @@ -93,10 +133,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ unbind: vi.fn(), }), })); -vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) }; -}); vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: vi.fn(() => []), matchPluginCommand: vi.fn(() => null), @@ -233,13 +269,93 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) { status: "active", boundAt: 0, }, - } satisfies import("../../../src/acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; + } as const; +} + +function createConfiguredBindingRoute( + route: ResolvedAgentRoute, + binding: ReturnType | null, +) { + return { + bindingResolution: binding + ? { + conversation: binding.record.conversation, + compiledBinding: { + channel: "telegram" as const, + binding: { + type: "acp" as const, + agentId: binding.spec.agentId, + match: { + channel: "telegram", + accountId: binding.spec.accountId, + peer: { + kind: "group" as const, + id: binding.spec.conversationId, + }, + }, + acp: { + mode: binding.spec.mode, + }, + }, + bindingConversationId: binding.spec.conversationId, + target: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + agentId: binding.spec.agentId, + provider: { + compileConfiguredBinding: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + matchInboundConversation: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + }, + targetFactory: { + driverId: "acp" as const, + materialize: () => ({ + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + } + : null, + ...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}), + route, + }; } function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled(); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled(); expect(sendMessage).toHaveBeenCalledWith( -1001234567890, "You are not authorized to use this command.", @@ -249,13 +365,12 @@ function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType { beforeEach(() => { - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear(); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear(); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: "agent:codex:acp:binding:telegram:default:seed", - }); + persistentBindingMocks.resolveConfiguredBindingRoute.mockClear(); + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); replyMocks.dispatchReplyWithBufferedBlockDispatcher @@ -403,13 +518,18 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( - createConfiguredAcpTopicBinding(boundSessionKey), + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), ); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); const { handler } = registerAndResolveStatusHandler({ cfg: {}, @@ -418,8 +538,8 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(createTelegramTopicCommandContext()); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); const dispatchCall = ( replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< [{ ctx?: { CommandTargetSessionKey?: string } }] @@ -488,12 +608,19 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( - createConfiguredAcpTopicBinding(boundSessionKey), + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), ); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: false, - sessionKey: boundSessionKey, error: "gateway unavailable", }); @@ -514,13 +641,18 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( - createConfiguredAcpTopicBinding(boundSessionKey), + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), ); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); const { handler, sendMessage } = registerAndResolveCommandHandler({ commandName: "new", @@ -535,7 +667,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null); + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); const { handler, sendMessage } = registerAndResolveCommandHandler({ commandName: "new", diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index c496c1b97f6..0e513131133 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -18,7 +18,7 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, @@ -490,13 +490,13 @@ export const registerTelegramNativeCommands = ({ topicAgentId, }); if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { logVerbose( - `telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `telegram native command: configured ACP binding unavailable for topic ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); await withTelegramApiErrorLogging({ operation: "sendMessage", diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 55fec660a82..54dcf963997 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -6,31 +6,32 @@ import type { TelegramContext } from "./types.js"; const saveMediaBuffer = vi.fn(); const fetchRemoteMedia = vi.fn(); -vi.mock("../../../../src/media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), + fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), }; }); -vi.mock("../../../../src/media/fetch.js", () => ({ - fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), -})); - -vi.mock("../../../../src/globals.js", () => ({ - danger: (s: string) => s, - warn: (s: string) => s, - logVerbose: () => {}, -})); +vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logVerbose: () => {}, + warn: (s: string) => s, + danger: (s: string) => s, + }; +}); vi.mock("../sticker-cache.js", () => ({ cacheSticker: () => {}, getCachedSticker: () => null, })); -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -const { resolveMedia } = await import("./delivery.js"); +let resolveMedia: typeof import("./delivery.js").resolveMedia; + const MAX_MEDIA_BYTES = 10_000_000; const BOT_TOKEN = "tok123"; @@ -164,10 +165,12 @@ async function flushRetryTimers() { } describe("resolveMedia getFile retry", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resolveMedia } = await import("./delivery.js")); vi.useFakeTimers(); - fetchRemoteMedia.mockClear(); - saveMediaBuffer.mockClear(); + fetchRemoteMedia.mockReset(); + saveMediaBuffer.mockReset(); }); afterEach(() => { diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index bac2de59f0b..6c1f4da5e73 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -13,6 +13,36 @@ import * as monitorModule from "./monitor.js"; import * as probeModule from "./probe.js"; import { setTelegramRuntime } from "./runtime.js"; +const probeTelegramMock = vi.hoisted(() => vi.fn()); +const collectTelegramUnmentionedGroupIdsMock = vi.hoisted(() => vi.fn()); +const auditTelegramGroupMembershipMock = vi.hoisted(() => vi.fn()); +const monitorTelegramProviderMock = vi.hoisted(() => vi.fn()); + +vi.mock("./probe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeTelegram: probeTelegramMock, + }; +}); + +vi.mock("./audit.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + collectTelegramUnmentionedGroupIds: collectTelegramUnmentionedGroupIdsMock, + auditTelegramGroupMembership: auditTelegramGroupMembershipMock, + }; +}); + +vi.mock("./monitor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + monitorTelegramProvider: monitorTelegramProviderMock, + }; +}); + function createCfg(): OpenClawConfig { return { channels: { @@ -156,7 +186,9 @@ describe("telegramPlugin duplicate token guard", () => { }); it("blocks startup for duplicate token accounts before polling starts", async () => { - const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true }); + const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ + probeOk: true, + }); await expect( telegramPlugin.gateway!.startAccount!( @@ -168,15 +200,23 @@ describe("telegramPlugin duplicate token guard", () => { ), ).rejects.toThrow("Duplicate Telegram bot token"); + expect(probeTelegramMock).not.toHaveBeenCalled(); + expect(monitorTelegramProviderMock).not.toHaveBeenCalled(); expect(probeTelegram).not.toHaveBeenCalled(); expect(monitorTelegramProvider).not.toHaveBeenCalled(); }); it("passes webhookPort through to monitor startup options", async () => { - const { monitorTelegramProvider } = installGatewayRuntime({ + const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true, botUsername: "opsbot", }); + probeTelegramMock.mockResolvedValue({ + ok: true, + bot: { username: "opsbot" }, + elapsedMs: 1, + }); + monitorTelegramProviderMock.mockResolvedValue(undefined); const cfg = createCfg(); cfg.channels!.telegram!.accounts!.ops = { @@ -194,18 +234,39 @@ describe("telegramPlugin duplicate token guard", () => { }), ); - expect(monitorTelegramProvider).toHaveBeenCalledWith( + expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 2500, { + accountId: "ops", + proxyUrl: undefined, + network: undefined, + }); + expect(monitorTelegramProviderMock).toHaveBeenCalledWith( expect.objectContaining({ useWebhook: true, webhookPort: 9876, }), ); + expect(probeTelegram).toHaveBeenCalled(); + expect(monitorTelegramProvider).toHaveBeenCalled(); }); it("passes account proxy and network settings into Telegram probes", async () => { - const { probeTelegram } = installGatewayRuntime({ - probeOk: true, - botUsername: "opsbot", + const runtimeProbeTelegram = vi.fn(async () => { + throw new Error("runtime probe should not be used"); + }); + setTelegramRuntime({ + channel: { + telegram: { + probeTelegram: runtimeProbeTelegram, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + probeTelegramMock.mockResolvedValue({ + ok: true, + bot: { username: "opsbot" }, + elapsedMs: 1, }); const cfg = createCfg(); @@ -218,7 +279,7 @@ describe("telegramPlugin duplicate token guard", () => { cfg, }); - expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, { + expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 5000, { accountId: "ops", proxyUrl: "http://127.0.0.1:8888", network: { @@ -226,19 +287,40 @@ describe("telegramPlugin duplicate token guard", () => { dnsResultOrder: "ipv4first", }, }); + expect(runtimeProbeTelegram).not.toHaveBeenCalled(); }); it("passes account proxy and network settings into Telegram membership audits", async () => { - const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({ - probeOk: true, - botUsername: "opsbot", + const runtimeCollectUnmentionedGroupIds = vi.fn(() => { + throw new Error("runtime audit helper should not be used"); }); - - collectUnmentionedGroupIds.mockReturnValue({ + const runtimeAuditGroupMembership = vi.fn(async () => { + throw new Error("runtime audit helper should not be used"); + }); + setTelegramRuntime({ + channel: { + telegram: { + collectUnmentionedGroupIds: runtimeCollectUnmentionedGroupIds, + auditGroupMembership: runtimeAuditGroupMembership, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + collectTelegramUnmentionedGroupIdsMock.mockReturnValue({ groupIds: ["-100123"], unresolvedGroups: 0, hasWildcardUnmentionedGroups: false, }); + auditTelegramGroupMembershipMock.mockResolvedValue({ + ok: true, + checkedGroups: 1, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups: [], + elapsedMs: 1, + }); const cfg = createCfg(); configureOpsProxyNetwork(cfg); @@ -257,7 +339,10 @@ describe("telegramPlugin duplicate token guard", () => { cfg, }); - expect(auditGroupMembership).toHaveBeenCalledWith({ + expect(collectTelegramUnmentionedGroupIdsMock).toHaveBeenCalledWith({ + "-100123": { requireMention: false }, + }); + expect(auditTelegramGroupMembershipMock).toHaveBeenCalledWith({ token: "token-ops", botId: 123, groupIds: ["-100123"], @@ -268,6 +353,8 @@ describe("telegramPlugin duplicate token guard", () => { }, timeoutMs: 5000, }); + expect(runtimeCollectUnmentionedGroupIds).not.toHaveBeenCalled(); + expect(runtimeAuditGroupMembership).not.toHaveBeenCalled(); }); it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => { @@ -391,7 +478,11 @@ describe("telegramPlugin duplicate token guard", () => { }); it("does not crash startup when a resolved account token is undefined", async () => { - const { monitorTelegramProvider } = installGatewayRuntime({ probeOk: false }); + const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ + probeOk: false, + }); + probeTelegramMock.mockResolvedValue({ ok: false, elapsedMs: 1 }); + monitorTelegramProviderMock.mockResolvedValue(undefined); const cfg = createCfg(); const ctx = createStartAccountCtx({ @@ -405,11 +496,18 @@ describe("telegramPlugin duplicate token guard", () => { } as ResolvedTelegramAccount; await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined(); - expect(monitorTelegramProvider).toHaveBeenCalledWith( + expect(probeTelegramMock).toHaveBeenCalledWith("", 2500, { + accountId: "ops", + proxyUrl: undefined, + network: undefined, + }); + expect(monitorTelegramProviderMock).toHaveBeenCalledWith( expect.objectContaining({ token: "", }), ); + expect(probeTelegram).toHaveBeenCalled(); + expect(monitorTelegramProvider).toHaveBeenCalled(); }); }); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 6dfe12870a2..0e2ce964b95 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -333,11 +333,15 @@ export const telegramPlugin: ChannelPlugin + bindings: { + compileConfiguredBinding: ({ conversationId }) => normalizeTelegramAcpConversationId(conversationId), - matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => - matchTelegramAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => + matchTelegramAcpConversation({ + bindingConversationId: compiledBinding.conversationId, + conversationId, + parentConversationId, + }), }, security: { resolveDmPolicy: resolveTelegramDmPolicy, diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index 26c3b039312..5d777763cde 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -1,5 +1,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveConfiguredAcpRoute } from "openclaw/plugin-sdk/conversation-runtime"; +import { + resolveConfiguredBindingRoute, + type ConfiguredBindingRouteResult, +} from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import { @@ -31,7 +34,7 @@ export function resolveTelegramConversationRoute(params: { topicAgentId?: string | null; }): { route: ReturnType; - configuredBinding: ReturnType["configuredBinding"]; + configuredBinding: ConfiguredBindingRouteResult["bindingResolution"]; configuredBindingSessionKey: string; } { const peerId = params.isGroup @@ -94,15 +97,17 @@ export function resolveTelegramConversationRoute(params: { ); } - const configuredRoute = resolveConfiguredAcpRoute({ + const configuredRoute = resolveConfiguredBindingRoute({ cfg: params.cfg, route, - channel: "telegram", - accountId: params.accountId, - conversationId: peerId, - parentConversationId: params.isGroup ? String(params.chatId) : undefined, + conversation: { + channel: "telegram", + accountId: params.accountId, + conversationId: peerId, + parentConversationId: params.isGroup ? String(params.chatId) : undefined, + }, }); - let configuredBinding = configuredRoute.configuredBinding; + let configuredBinding = configuredRoute.bindingResolution; let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? ""; route = configuredRoute.route; diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index f313141dab0..9b82310ef04 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -61,6 +61,14 @@ vi.mock("grammy", () => ({ botCtorSpy(token, options); } }, + HttpError: class HttpError extends Error { + constructor( + message = "HttpError", + public error?: unknown, + ) { + super(message); + } + }, InputFile: class {}, })); @@ -94,5 +102,6 @@ export function installTelegramSendTestHooks() { } export async function importTelegramSendModule() { + vi.resetModules(); return await import("./send.js"); } diff --git a/extensions/telegram/src/target-writeback.test.ts b/extensions/telegram/src/target-writeback.test.ts index bb8b2129924..8403f7e1b0f 100644 --- a/extensions/telegram/src/target-writeback.test.ts +++ b/extensions/telegram/src/target-writeback.test.ts @@ -7,29 +7,24 @@ const loadCronStore = vi.fn(); const resolveCronStorePath = vi.fn(); const saveCronStore = vi.fn(); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, readConfigFileSnapshotForWrite, writeConfigFile, - }; -}); - -vi.mock("../../../src/cron/store.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, loadCronStore, resolveCronStorePath, saveCronStore, }; }); -const { maybePersistResolvedTelegramTarget } = await import("./target-writeback.js"); - describe("maybePersistResolvedTelegramTarget", () => { - beforeEach(() => { + let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget; + + beforeEach(async () => { + vi.resetModules(); + ({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js")); readConfigFileSnapshotForWrite.mockReset(); writeConfigFile.mockReset(); loadCronStore.mockReset(); diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index b15aa3bd72e..58f74b72918 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -603,137 +603,164 @@ export class AcpSessionManager { } await this.evictIdleRuntimeHandles({ cfg: input.cfg }); await this.withSessionActor(sessionKey, async () => { - const resolution = this.resolveSession({ - cfg: input.cfg, - sessionKey, - }); - const resolvedMeta = requireReadySessionMeta(resolution); - - const { - runtime, - handle: ensuredHandle, - meta: ensuredMeta, - } = await this.ensureRuntimeHandle({ - cfg: input.cfg, - sessionKey, - meta: resolvedMeta, - }); - let handle = ensuredHandle; - const meta = ensuredMeta; - await this.applyRuntimeControls({ - sessionKey, - runtime, - handle, - meta, - }); const turnStartedAt = Date.now(); const actorKey = normalizeActorKey(sessionKey); - - await this.setSessionState({ - cfg: input.cfg, - sessionKey, - state: "running", - clearLastError: true, - }); - - const internalAbortController = new AbortController(); - const onCallerAbort = () => { - internalAbortController.abort(); - }; - if (input.signal?.aborted) { - internalAbortController.abort(); - } else if (input.signal) { - input.signal.addEventListener("abort", onCallerAbort, { once: true }); - } - - const activeTurn: ActiveTurnState = { - runtime, - handle, - abortController: internalAbortController, - }; - this.activeTurnBySession.set(actorKey, activeTurn); - - let streamError: AcpRuntimeError | null = null; - try { - const combinedSignal = - input.signal && typeof AbortSignal.any === "function" - ? AbortSignal.any([input.signal, internalAbortController.signal]) - : internalAbortController.signal; - for await (const event of runtime.runTurn({ - handle, - text: input.text, - attachments: input.attachments, - mode: input.mode, - requestId: input.requestId, - signal: combinedSignal, - })) { - if (event.type === "error") { - streamError = new AcpRuntimeError( - normalizeAcpErrorCode(event.code), - event.message?.trim() || "ACP turn failed before completion.", - ); - } - if (input.onEvent) { - await input.onEvent(event); - } - } - if (streamError) { - throw streamError; - } - this.recordTurnCompletion({ - startedAt: turnStartedAt, - }); - await this.setSessionState({ + for (let attempt = 0; attempt < 2; attempt += 1) { + const resolution = this.resolveSession({ cfg: input.cfg, sessionKey, - state: "idle", - clearLastError: true, }); - } catch (error) { - const acpError = toAcpRuntimeError({ - error, - fallbackCode: "ACP_TURN_FAILED", - fallbackMessage: "ACP turn failed before completion.", - }); - this.recordTurnCompletion({ - startedAt: turnStartedAt, - errorCode: acpError.code, - }); - await this.setSessionState({ - cfg: input.cfg, - sessionKey, - state: "error", - lastError: acpError.message, - }); - throw acpError; - } finally { - if (input.signal) { - input.signal.removeEventListener("abort", onCallerAbort); - } - if (this.activeTurnBySession.get(actorKey) === activeTurn) { - this.activeTurnBySession.delete(actorKey); - } - if (meta.mode !== "oneshot") { - ({ handle } = await this.reconcileRuntimeSessionIdentifiers({ + const resolvedMeta = requireReadySessionMeta(resolution); + let runtime: AcpRuntime | undefined; + let handle: AcpRuntimeHandle | undefined; + let meta: SessionAcpMeta | undefined; + let activeTurn: ActiveTurnState | undefined; + let internalAbortController: AbortController | undefined; + let onCallerAbort: (() => void) | undefined; + let activeTurnStarted = false; + let sawTurnOutput = false; + let retryFreshHandle = false; + try { + const ensured = await this.ensureRuntimeHandle({ cfg: input.cfg, + sessionKey, + meta: resolvedMeta, + }); + runtime = ensured.runtime; + handle = ensured.handle; + meta = ensured.meta; + await this.applyRuntimeControls({ sessionKey, runtime, handle, meta, - failOnStatusError: false, - })); - } - if (meta.mode === "oneshot") { - try { - await runtime.close({ - handle, - reason: "oneshot-complete", - }); - } catch (error) { - logVerbose(`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`); - } finally { - this.clearCachedRuntimeState(sessionKey); + }); + + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "running", + clearLastError: true, + }); + + internalAbortController = new AbortController(); + onCallerAbort = () => { + internalAbortController?.abort(); + }; + if (input.signal?.aborted) { + internalAbortController.abort(); + } else if (input.signal) { + input.signal.addEventListener("abort", onCallerAbort, { once: true }); } + + activeTurn = { + runtime, + handle, + abortController: internalAbortController, + }; + this.activeTurnBySession.set(actorKey, activeTurn); + activeTurnStarted = true; + + let streamError: AcpRuntimeError | null = null; + const combinedSignal = + input.signal && typeof AbortSignal.any === "function" + ? AbortSignal.any([input.signal, internalAbortController.signal]) + : internalAbortController.signal; + for await (const event of runtime.runTurn({ + handle, + text: input.text, + attachments: input.attachments, + mode: input.mode, + requestId: input.requestId, + signal: combinedSignal, + })) { + if (event.type === "error") { + streamError = new AcpRuntimeError( + normalizeAcpErrorCode(event.code), + event.message?.trim() || "ACP turn failed before completion.", + ); + } else if (event.type === "text_delta" || event.type === "tool_call") { + sawTurnOutput = true; + } + if (input.onEvent) { + await input.onEvent(event); + } + } + if (streamError) { + throw streamError; + } + this.recordTurnCompletion({ + startedAt: turnStartedAt, + }); + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "idle", + clearLastError: true, + }); + return; + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: activeTurnStarted ? "ACP_TURN_FAILED" : "ACP_SESSION_INIT_FAILED", + fallbackMessage: activeTurnStarted + ? "ACP turn failed before completion." + : "Could not initialize ACP session runtime.", + }); + retryFreshHandle = this.shouldRetryTurnWithFreshHandle({ + attempt, + sessionKey, + error: acpError, + sawTurnOutput, + }); + if (retryFreshHandle) { + continue; + } + this.recordTurnCompletion({ + startedAt: turnStartedAt, + errorCode: acpError.code, + }); + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "error", + lastError: acpError.message, + }); + throw acpError; + } finally { + if (input.signal && onCallerAbort) { + input.signal.removeEventListener("abort", onCallerAbort); + } + if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) { + this.activeTurnBySession.delete(actorKey); + } + if (!retryFreshHandle && runtime && handle && meta && meta.mode !== "oneshot") { + ({ handle } = await this.reconcileRuntimeSessionIdentifiers({ + cfg: input.cfg, + sessionKey, + runtime, + handle, + meta, + failOnStatusError: false, + })); + } + if (!retryFreshHandle && runtime && handle && meta && meta.mode === "oneshot") { + try { + await runtime.close({ + handle, + reason: "oneshot-complete", + }); + } catch (error) { + logVerbose( + `acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`, + ); + } finally { + this.clearCachedRuntimeState(sessionKey); + } + } + } + if (retryFreshHandle) { + continue; } } }); @@ -864,7 +891,9 @@ export class AcpSessionManager { }); if ( input.allowBackendUnavailable && - (acpError.code === "ACP_BACKEND_MISSING" || acpError.code === "ACP_BACKEND_UNAVAILABLE") + (acpError.code === "ACP_BACKEND_MISSING" || + acpError.code === "ACP_BACKEND_UNAVAILABLE" || + this.isRecoverableAcpxExitError(acpError.message)) ) { // Treat unavailable backends as terminal for this cached handle so it // cannot continue counting against maxConcurrentSessions. @@ -916,7 +945,17 @@ export class AcpSessionManager { const agentMatches = cached.agent === agent; const modeMatches = cached.mode === mode; const cwdMatches = (cached.cwd ?? "") === (cwd ?? ""); - if (backendMatches && agentMatches && modeMatches && cwdMatches) { + if ( + backendMatches && + agentMatches && + modeMatches && + cwdMatches && + (await this.isCachedRuntimeHandleReusable({ + sessionKey: params.sessionKey, + runtime: cached.runtime, + handle: cached.handle, + })) + ) { return { runtime: cached.runtime, handle: cached.handle, @@ -1020,6 +1059,49 @@ export class AcpSessionManager { }; } + private async isCachedRuntimeHandleReusable(params: { + sessionKey: string; + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + }): Promise { + if (!params.runtime.getStatus) { + return true; + } + try { + const status = await params.runtime.getStatus({ + handle: params.handle, + }); + if (this.isRuntimeStatusUnavailable(status)) { + this.clearCachedRuntimeState(params.sessionKey); + logVerbose( + `acp-manager: evicting cached runtime handle for ${params.sessionKey} after unhealthy status probe: ${status.summary ?? "status unavailable"}`, + ); + return false; + } + return true; + } catch (error) { + this.clearCachedRuntimeState(params.sessionKey); + logVerbose( + `acp-manager: evicting cached runtime handle for ${params.sessionKey} after status probe failed: ${String(error)}`, + ); + return false; + } + } + + private isRuntimeStatusUnavailable(status: AcpRuntimeStatus | undefined): boolean { + if (!status) { + return false; + } + const detailsStatus = + typeof status.details?.status === "string" ? status.details.status.trim().toLowerCase() : ""; + if (detailsStatus === "dead" || detailsStatus === "no-session") { + return true; + } + const summaryMatch = status.summary?.match(/\bstatus=([^\s]+)/i); + const summaryStatus = summaryMatch?.[1]?.trim().toLowerCase() ?? ""; + return summaryStatus === "dead" || summaryStatus === "no-session"; + } + private async persistRuntimeOptions(params: { cfg: OpenClawConfig; sessionKey: string; @@ -1103,6 +1185,29 @@ export class AcpSessionManager { this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1); } + private shouldRetryTurnWithFreshHandle(params: { + attempt: number; + sessionKey: string; + error: AcpRuntimeError; + sawTurnOutput: boolean; + }): boolean { + if (params.attempt > 0 || params.sawTurnOutput) { + return false; + } + if (!this.isRecoverableAcpxExitError(params.error.message)) { + return false; + } + this.clearCachedRuntimeState(params.sessionKey); + logVerbose( + `acp-manager: retrying ${params.sessionKey} with a fresh runtime handle after early turn failure: ${params.error.message}`, + ); + return true; + } + + private isRecoverableAcpxExitError(message: string): boolean { + return /^acpx exited with code \d+/i.test(message.trim()); + } + private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise { const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg); if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) { diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 66faa84b1d3..7229e34914d 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -354,6 +354,52 @@ describe("AcpSessionManager", () => { expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); }); + it("re-ensures cached runtime handles when the backend reports the session is dead", async () => { + const runtimeState = createRuntime(); + runtimeState.getStatus + .mockResolvedValueOnce({ + summary: "status=alive", + details: { status: "alive" }, + }) + .mockResolvedValueOnce({ + summary: "status=dead", + details: { status: "dead" }, + }) + .mockResolvedValueOnce({ + summary: "status=alive", + details: { status: "alive" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "first", + mode: "prompt", + requestId: "r1", + }); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "second", + mode: "prompt", + requestId: "r2", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + expect(runtimeState.getStatus).toHaveBeenCalledTimes(3); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); + }); + it("rehydrates runtime handles after a manager restart", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ @@ -531,6 +577,61 @@ describe("AcpSessionManager", () => { expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); }); + it("drops cached runtime handles when close sees a stale acpx process-exit error", async () => { + const runtimeState = createRuntime(); + runtimeState.close.mockRejectedValueOnce(new Error("acpx exited with code 1")); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + const limitedCfg = { + acp: { + ...baseCfg.acp, + maxConcurrentSessions: 1, + }, + } as OpenClawConfig; + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + text: "first", + mode: "prompt", + requestId: "r1", + }); + + const closeResult = await manager.closeSession({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + reason: "manual-close", + allowBackendUnavailable: true, + }); + expect(closeResult.runtimeClosed).toBe(false); + expect(closeResult.runtimeNotice).toBe("acpx exited with code 1"); + + await expect( + manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-b", + text: "second", + mode: "prompt", + requestId: "r2", + }), + ).resolves.toBeUndefined(); + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + }); + it("evicts idle cached runtimes before enforcing max concurrent limits", async () => { vi.useFakeTimers(); try { @@ -807,6 +908,82 @@ describe("AcpSessionManager", () => { expect(states.at(-1)).toBe("error"); }); + it("marks the session as errored when runtime ensure fails before turn start", async () => { + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockRejectedValue(new Error("acpx exited with code 1")); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: { + ...readySessionMeta(), + state: "running", + }, + }); + + const manager = new AcpSessionManager(); + await expect( + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }), + ).rejects.toMatchObject({ + code: "ACP_SESSION_INIT_FAILED", + message: "acpx exited with code 1", + }); + + const states = extractStatesFromUpserts(); + expect(states).not.toContain("running"); + expect(states.at(-1)).toBe("error"); + }); + + it("retries once with a fresh runtime handle after an early acpx exit", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + runtimeState.runTurn + .mockImplementationOnce(async function* () { + yield { + type: "error" as const, + message: "acpx exited with code 1", + }; + }) + .mockImplementationOnce(async function* () { + yield { type: "done" as const }; + }); + + const manager = new AcpSessionManager(); + await expect( + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }), + ).resolves.toBeUndefined(); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); + const states = extractStatesFromUpserts(); + expect(states).toContain("running"); + expect(states).toContain("idle"); + expect(states).not.toContain("error"); + }); + it("persists runtime mode changes through setSessionRuntimeMode", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ diff --git a/src/acp/persistent-bindings.lifecycle.test.ts b/src/acp/persistent-bindings.lifecycle.test.ts new file mode 100644 index 00000000000..44e159d887f --- /dev/null +++ b/src/acp/persistent-bindings.lifecycle.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import type { OpenClawConfig } from "../config/config.js"; + +const managerMocks = vi.hoisted(() => ({ + closeSession: vi.fn(), + initializeSession: vi.fn(), + updateSessionRuntimeOptions: vi.fn(), +})); + +const sessionMetaMocks = vi.hoisted(() => ({ + readAcpSessionEntry: vi.fn(), +})); + +const resolveMocks = vi.hoisted(() => ({ + resolveConfiguredAcpBindingSpecBySessionKey: vi.fn(), +})); + +vi.mock("./control-plane/manager.js", () => ({ + getAcpSessionManager: () => ({ + closeSession: managerMocks.closeSession, + initializeSession: managerMocks.initializeSession, + updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions, + }), +})); + +vi.mock("./runtime/session-meta.js", () => ({ + readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry, +})); + +vi.mock("./persistent-bindings.resolve.js", () => ({ + resolveConfiguredAcpBindingSpecBySessionKey: + resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey, +})); +type BindingTargetsModule = typeof import("../channels/plugins/binding-targets.js"); +let bindingTargets: BindingTargetsModule; +let bindingTargetsImportScope = 0; + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + list: [{ id: "codex" }, { id: "claude" }], + }, +} satisfies OpenClawConfig; + +beforeEach(async () => { + vi.resetModules(); + bindingTargetsImportScope += 1; + bindingTargets = await importFreshModule( + import.meta.url, + `../channels/plugins/binding-targets.js?scope=${bindingTargetsImportScope}`, + ); + managerMocks.closeSession.mockReset().mockResolvedValue({ + runtimeClosed: true, + metaCleared: false, + }); + managerMocks.initializeSession.mockReset().mockResolvedValue(undefined); + managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined); + sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); + resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockReset().mockReturnValue(null); +}); + +describe("resetConfiguredBindingTargetInPlace", () => { + it("does not resolve configured bindings when ACP metadata already exists", async () => { + const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4"; + sessionMetaMocks.readAcpSessionEntry.mockReturnValue({ + acp: { + agent: "claude", + mode: "persistent", + backend: "acpx", + runtimeOptions: { cwd: "/home/bob/clawd" }, + }, + }); + resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockImplementation(() => { + throw new Error("configured binding resolution should be skipped"); + }); + + const result = await bindingTargets.resetConfiguredBindingTargetInPlace({ + cfg: baseCfg, + sessionKey, + reason: "reset", + }); + + expect(result).toEqual({ ok: true }); + expect(resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey).not.toHaveBeenCalled(); + expect(managerMocks.closeSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + clearMeta: false, + }), + ); + expect(managerMocks.initializeSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + agent: "claude", + backendId: "acpx", + }), + ); + }); +}); diff --git a/src/acp/persistent-bindings.lifecycle.ts b/src/acp/persistent-bindings.lifecycle.ts index 2a2cf6b9c20..9f43b584da3 100644 --- a/src/acp/persistent-bindings.lifecycle.ts +++ b/src/acp/persistent-bindings.lifecycle.ts @@ -8,6 +8,7 @@ import { buildConfiguredAcpSessionKey, normalizeText, type ConfiguredAcpBindingSpec, + type ResolvedConfiguredAcpBinding, } from "./persistent-bindings.types.js"; import { readAcpSessionEntry } from "./runtime/session-meta.js"; @@ -96,7 +97,7 @@ export async function ensureConfiguredAcpBindingSession(params: { } catch (error) { const message = error instanceof Error ? error.message : String(error); logVerbose( - `acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`, + `acp-configured-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`, ); return { ok: false, @@ -106,6 +107,26 @@ export async function ensureConfiguredAcpBindingSession(params: { } } +export async function ensureConfiguredAcpBindingReady(params: { + cfg: OpenClawConfig; + configuredBinding: ResolvedConfiguredAcpBinding | null; +}): Promise<{ ok: true } | { ok: false; error: string }> { + if (!params.configuredBinding) { + return { ok: true }; + } + const ensured = await ensureConfiguredAcpBindingSession({ + cfg: params.cfg, + spec: params.configuredBinding.spec, + }); + if (ensured.ok) { + return { ok: true }; + } + return { + ok: false, + error: ensured.error ?? "unknown error", + }; +} + export async function resetAcpSessionInPlace(params: { cfg: OpenClawConfig; sessionKey: string; @@ -119,14 +140,17 @@ export async function resetAcpSessionInPlace(params: { }; } - const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({ - cfg: params.cfg, - sessionKey, - }); const meta = readAcpSessionEntry({ cfg: params.cfg, sessionKey, })?.acp; + const configuredBinding = + !meta || !normalizeText(meta.agent) + ? resolveConfiguredAcpBindingSpecBySessionKey({ + cfg: params.cfg, + sessionKey, + }) + : null; if (!meta) { if (configuredBinding) { const ensured = await ensureConfiguredAcpBindingSession({ @@ -189,7 +213,7 @@ export async function resetAcpSessionInPlace(params: { return { ok: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`); + logVerbose(`acp-configured-binding: failed reset for ${sessionKey}: ${message}`); return { ok: false, error: message, diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index d0039078378..068b89f8891 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -1,275 +1,17 @@ -import { getChannelPlugin } from "../channels/plugins/index.js"; -import { listAcpBindings } from "../config/bindings.js"; +import { + resolveConfiguredBindingRecord, + resolveConfiguredBindingRecordBySessionKey, + resolveConfiguredBindingRecordForConversation, +} from "../channels/plugins/binding-registry.js"; import type { OpenClawConfig } from "../config/config.js"; -import type { AgentAcpBinding } from "../config/types.js"; -import { pickFirstExistingAgentId } from "../routing/resolve-route.js"; +import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - parseAgentSessionKey, -} from "../routing/session-key.js"; -import { - buildConfiguredAcpSessionKey, - normalizeBindingConfig, - normalizeMode, - normalizeText, - toConfiguredAcpBindingRecord, - type ConfiguredAcpBindingChannel, + resolveConfiguredAcpBindingSpecFromRecord, + toResolvedConfiguredAcpBinding, type ConfiguredAcpBindingSpec, type ResolvedConfiguredAcpBinding, } from "./persistent-bindings.types.js"; -function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null { - const normalized = (value ?? "").trim().toLowerCase(); - if (!normalized) { - return null; - } - const plugin = getChannelPlugin(normalized); - return plugin?.acpBindings ? plugin.id : null; -} - -function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 { - const trimmed = (match ?? "").trim(); - if (!trimmed) { - return actual === DEFAULT_ACCOUNT_ID ? 2 : 0; - } - if (trimmed === "*") { - return 1; - } - return normalizeAccountId(trimmed) === actual ? 2 : 0; -} - -function resolveBindingConversationId(binding: AgentAcpBinding): string | null { - const id = binding.match.peer?.id?.trim(); - return id ? id : null; -} - -function parseConfiguredBindingSessionKey(params: { - sessionKey: string; -}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null { - const parsed = parseAgentSessionKey(params.sessionKey); - const rest = parsed?.rest?.trim().toLowerCase() ?? ""; - if (!rest) { - return null; - } - const tokens = rest.split(":"); - if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") { - return null; - } - const channel = normalizeBindingChannel(tokens[2]); - if (!channel) { - return null; - } - return { - channel, - accountId: normalizeAccountId(tokens[3]), - }; -} - -function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): { - acpAgentId?: string; - mode?: string; - cwd?: string; - backend?: string; -} { - const agent = params.cfg.agents?.list?.find( - (entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(), - ); - if (!agent || agent.runtime?.type !== "acp") { - return {}; - } - return { - acpAgentId: normalizeText(agent.runtime.acp?.agent), - mode: normalizeText(agent.runtime.acp?.mode), - cwd: normalizeText(agent.runtime.acp?.cwd), - backend: normalizeText(agent.runtime.acp?.backend), - }; -} - -function toConfiguredBindingSpec(params: { - cfg: OpenClawConfig; - channel: ConfiguredAcpBindingChannel; - accountId: string; - conversationId: string; - parentConversationId?: string; - binding: AgentAcpBinding; -}): ConfiguredAcpBindingSpec { - const accountId = normalizeAccountId(params.accountId); - const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main"); - const runtimeDefaults = resolveAgentRuntimeAcpDefaults({ - cfg: params.cfg, - ownerAgentId: agentId, - }); - const bindingOverrides = normalizeBindingConfig(params.binding.acp); - const acpAgentId = normalizeText(runtimeDefaults.acpAgentId); - const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode); - return { - channel: params.channel, - accountId, - conversationId: params.conversationId, - parentConversationId: params.parentConversationId, - agentId, - acpAgentId, - mode, - cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd, - backend: bindingOverrides.backend ?? runtimeDefaults.backend, - label: bindingOverrides.label, - }; -} - -function resolveConfiguredBindingRecord(params: { - cfg: OpenClawConfig; - bindings: AgentAcpBinding[]; - channel: ConfiguredAcpBindingChannel; - accountId: string; - selectConversation: (binding: AgentAcpBinding) => { - conversationId: string; - parentConversationId?: string; - matchPriority?: number; - } | null; -}): ResolvedConfiguredAcpBinding | null { - let wildcardMatch: { - binding: AgentAcpBinding; - conversationId: string; - parentConversationId?: string; - matchPriority: number; - } | null = null; - let exactMatch: { - binding: AgentAcpBinding; - conversationId: string; - parentConversationId?: string; - matchPriority: number; - } | null = null; - for (const binding of params.bindings) { - if (normalizeBindingChannel(binding.match.channel) !== params.channel) { - continue; - } - const accountMatchPriority = resolveAccountMatchPriority( - binding.match.accountId, - params.accountId, - ); - if (accountMatchPriority === 0) { - continue; - } - const conversation = params.selectConversation(binding); - if (!conversation) { - continue; - } - const matchPriority = conversation.matchPriority ?? 0; - if (accountMatchPriority === 2) { - if (!exactMatch || matchPriority > exactMatch.matchPriority) { - exactMatch = { - binding, - conversationId: conversation.conversationId, - parentConversationId: conversation.parentConversationId, - matchPriority, - }; - } - continue; - } - if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) { - wildcardMatch = { - binding, - conversationId: conversation.conversationId, - parentConversationId: conversation.parentConversationId, - matchPriority, - }; - } - } - if (exactMatch) { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - conversationId: exactMatch.conversationId, - parentConversationId: exactMatch.parentConversationId, - binding: exactMatch.binding, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), - }; - } - if (!wildcardMatch) { - return null; - } - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - conversationId: wildcardMatch.conversationId, - parentConversationId: wildcardMatch.parentConversationId, - binding: wildcardMatch.binding, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), - }; -} - -export function resolveConfiguredAcpBindingSpecBySessionKey(params: { - cfg: OpenClawConfig; - sessionKey: string; -}): ConfiguredAcpBindingSpec | null { - const sessionKey = params.sessionKey.trim(); - if (!sessionKey) { - return null; - } - const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey }); - if (!parsedSessionKey) { - return null; - } - const plugin = getChannelPlugin(parsedSessionKey.channel); - const acpBindings = plugin?.acpBindings; - if (!acpBindings?.normalizeConfiguredBindingTarget) { - return null; - } - - let wildcardMatch: ConfiguredAcpBindingSpec | null = null; - for (const binding of listAcpBindings(params.cfg)) { - const channel = normalizeBindingChannel(binding.match.channel); - if (!channel || channel !== parsedSessionKey.channel) { - continue; - } - const accountMatchPriority = resolveAccountMatchPriority( - binding.match.accountId, - parsedSessionKey.accountId, - ); - if (accountMatchPriority === 0) { - continue; - } - const targetConversationId = resolveBindingConversationId(binding); - if (!targetConversationId) { - continue; - } - const target = acpBindings.normalizeConfiguredBindingTarget({ - binding, - conversationId: targetConversationId, - }); - if (!target) { - continue; - } - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel, - accountId: parsedSessionKey.accountId, - conversationId: target.conversationId, - parentConversationId: target.parentConversationId, - binding, - }); - if (buildConfiguredAcpSessionKey(spec) !== sessionKey) { - continue; - } - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } - } - return wildcardMatch; -} - export function resolveConfiguredAcpBindingRecord(params: { cfg: OpenClawConfig; channel: string; @@ -277,36 +19,22 @@ export function resolveConfiguredAcpBindingRecord(params: { conversationId: string; parentConversationId?: string; }): ResolvedConfiguredAcpBinding | null { - const channel = normalizeBindingChannel(params.channel); - const accountId = normalizeAccountId(params.accountId); - const conversationId = params.conversationId.trim(); - const parentConversationId = params.parentConversationId?.trim() || undefined; - if (!channel || !conversationId) { - return null; - } - const plugin = getChannelPlugin(channel); - const acpBindings = plugin?.acpBindings; - if (!acpBindings?.matchConfiguredBinding) { - return null; - } - const matchConfiguredBinding = acpBindings.matchConfiguredBinding; - - return resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings: listAcpBindings(params.cfg), - channel, - accountId, - selectConversation: (binding) => { - const bindingConversationId = resolveBindingConversationId(binding); - if (!bindingConversationId) { - return null; - } - return matchConfiguredBinding({ - binding, - bindingConversationId, - conversationId, - parentConversationId, - }); - }, - }); + const resolved = resolveConfiguredBindingRecord(params); + return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null; +} + +export function resolveConfiguredAcpBindingRecordForConversation(params: { + cfg: OpenClawConfig; + conversation: ConversationRef; +}): ResolvedConfiguredAcpBinding | null { + const resolved = resolveConfiguredBindingRecordForConversation(params); + return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null; +} + +export function resolveConfiguredAcpBindingSpecBySessionKey(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): ConfiguredAcpBindingSpec | null { + const resolved = resolveConfiguredBindingRecordBySessionKey(params); + return resolved ? resolveConfiguredAcpBindingSpecFromRecord(resolved.record) : null; } diff --git a/src/acp/persistent-bindings.route.ts b/src/acp/persistent-bindings.route.ts deleted file mode 100644 index d11d46d423d..00000000000 --- a/src/acp/persistent-bindings.route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { ResolvedAgentRoute } from "../routing/resolve-route.js"; -import { deriveLastRoutePolicy } from "../routing/resolve-route.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { - ensureConfiguredAcpBindingSession, - resolveConfiguredAcpBindingRecord, - type ConfiguredAcpBindingChannel, - type ResolvedConfiguredAcpBinding, -} from "./persistent-bindings.js"; - -export function resolveConfiguredAcpRoute(params: { - cfg: OpenClawConfig; - route: ResolvedAgentRoute; - channel: ConfiguredAcpBindingChannel; - accountId: string; - conversationId: string; - parentConversationId?: string; -}): { - configuredBinding: ResolvedConfiguredAcpBinding | null; - route: ResolvedAgentRoute; - boundSessionKey?: string; - boundAgentId?: string; -} { - const configuredBinding = resolveConfiguredAcpBindingRecord({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - conversationId: params.conversationId, - parentConversationId: params.parentConversationId, - }); - if (!configuredBinding) { - return { - configuredBinding: null, - route: params.route, - }; - } - const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? ""; - if (!boundSessionKey) { - return { - configuredBinding, - route: params.route, - }; - } - const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId; - return { - configuredBinding, - boundSessionKey, - boundAgentId, - route: { - ...params.route, - sessionKey: boundSessionKey, - agentId: boundAgentId, - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: params.route.mainSessionKey, - }), - matchedBy: "binding.channel", - }, - }; -} - -export async function ensureConfiguredAcpRouteReady(params: { - cfg: OpenClawConfig; - configuredBinding: ResolvedConfiguredAcpBinding | null; -}): Promise<{ ok: true } | { ok: false; error: string }> { - if (!params.configuredBinding) { - return { ok: true }; - } - const ensured = await ensureConfiguredAcpBindingSession({ - cfg: params.cfg, - spec: params.configuredBinding.spec, - }); - if (ensured.ok) { - return { ok: true }; - } - return { - ok: false, - error: ensured.error ?? "unknown error", - }; -} diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index cb815b9d948..27b0e59733c 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { feishuPlugin } from "../../extensions/feishu/src/channel.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js"; const managerMocks = vi.hoisted(() => ({ resolveSession: vi.fn(), closeSession: vi.fn(), @@ -27,17 +30,24 @@ vi.mock("./runtime/session-meta.js", () => ({ readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry, })); -type PersistentBindingsModule = typeof import("./persistent-bindings.js"); - -let buildConfiguredAcpSessionKey: PersistentBindingsModule["buildConfiguredAcpSessionKey"]; -let ensureConfiguredAcpBindingSession: PersistentBindingsModule["ensureConfiguredAcpBindingSession"]; -let resetAcpSessionInPlace: PersistentBindingsModule["resetAcpSessionInPlace"]; -let resolveConfiguredAcpBindingRecord: PersistentBindingsModule["resolveConfiguredAcpBindingRecord"]; -let resolveConfiguredAcpBindingSpecBySessionKey: PersistentBindingsModule["resolveConfiguredAcpBindingSpecBySessionKey"]; +type PersistentBindingsModule = Pick< + typeof import("./persistent-bindings.resolve.js"), + "resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey" +> & + Pick< + typeof import("./persistent-bindings.lifecycle.js"), + "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" + >; +let persistentBindings: PersistentBindingsModule; +let persistentBindingsImportScope = 0; type ConfiguredBinding = NonNullable[number]; -type BindingRecordInput = Parameters[0]; -type BindingSpec = Parameters[0]["spec"]; +type BindingRecordInput = Parameters< + PersistentBindingsModule["resolveConfiguredAcpBindingRecord"] +>[0]; +type BindingSpec = Parameters< + PersistentBindingsModule["ensureConfiguredAcpBindingSession"] +>[0]["spec"]; const baseCfg = { session: { mainKey: "main", scope: "per-sender" }, @@ -117,7 +127,7 @@ function createFeishuBinding(params: { } function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial = {}) { - return resolveConfiguredAcpBindingRecord({ + return persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "discord", accountId: defaultDiscordAccountId, @@ -131,7 +141,7 @@ function resolveDiscordBindingSpecBySession( conversationId = defaultDiscordConversationId, ) { const resolved = resolveBindingRecord(cfg, { conversationId }); - return resolveConfiguredAcpBindingSpecBySessionKey({ + return persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({ cfg, sessionKey: resolved?.record.targetSessionKey ?? "", }); @@ -148,7 +158,11 @@ function createDiscordPersistentSpec(overrides: Partial = {}): Bind } as BindingSpec; } -function mockReadySession(params: { spec: BindingSpec; cwd: string }) { +function mockReadySession(params: { + spec: BindingSpec; + cwd: string; + state?: "idle" | "running" | "error"; +}) { const sessionKey = buildConfiguredAcpSessionKey(params.spec); managerMocks.resolveSession.mockReturnValue({ kind: "ready", @@ -159,14 +173,33 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) { runtimeSessionName: "existing", mode: params.spec.mode, runtimeOptions: { cwd: params.cwd }, - state: "idle", + state: params.state ?? "idle", lastActivityAt: Date.now(), }, }); return sessionKey; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + persistentBindingsImportScope += 1; + const [resolveModule, lifecycleModule] = await Promise.all([ + importFreshModule( + import.meta.url, + `./persistent-bindings.resolve.js?scope=${persistentBindingsImportScope}`, + ), + importFreshModule( + import.meta.url, + `./persistent-bindings.lifecycle.js?scope=${persistentBindingsImportScope}`, + ), + ]); + persistentBindings = { + resolveConfiguredAcpBindingRecord: resolveModule.resolveConfiguredAcpBindingRecord, + resolveConfiguredAcpBindingSpecBySessionKey: + resolveModule.resolveConfiguredAcpBindingSpecBySessionKey, + ensureConfiguredAcpBindingSession: lifecycleModule.ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace: lifecycleModule.resetAcpSessionInPlace, + }; setActivePluginRegistry( createTestRegistry([ { pluginId: "discord", plugin: discordPlugin, source: "test" }, @@ -184,17 +217,6 @@ beforeEach(() => { sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); }); -beforeEach(async () => { - vi.resetModules(); - ({ - buildConfiguredAcpSessionKey, - ensureConfiguredAcpBindingSession, - resetAcpSessionInPlace, - resolveConfiguredAcpBindingRecord, - resolveConfiguredAcpBindingSpecBySessionKey, - } = await import("./persistent-bindings.js")); -}); - describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { const cfg = createCfgWithBindings([ @@ -263,7 +285,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "work", @@ -318,13 +340,13 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const canonical = resolveConfiguredAcpBindingRecord({ + const canonical = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", conversationId: "-1001234567890:topic:42", }); - const splitIds = resolveConfiguredAcpBindingRecord({ + const splitIds = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", @@ -347,7 +369,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", @@ -364,7 +386,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -384,7 +406,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -405,7 +427,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -427,7 +449,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -449,7 +471,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -468,7 +490,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -514,6 +536,25 @@ describe("resolveConfiguredAcpBindingRecord", () => { expect(resolved?.spec.cwd).toBe("/workspace/repo-a"); expect(resolved?.spec.backend).toBe("acpx"); }); + + it("derives configured binding cwd from an explicit agent workspace", () => { + const cfg = createCfgWithBindings( + [ + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + }), + ], + { + agents: { + list: [{ id: "codex", workspace: "/workspace/openclaw" }, { id: "claude" }], + }, + }, + ); + const resolved = resolveBindingRecord(cfg); + + expect(resolved?.spec.cwd).toBe(resolveAgentWorkspaceDir(cfg, "codex")); + }); }); describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { @@ -534,7 +575,7 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { }); it("returns null for unknown session keys", () => { - const spec = resolveConfiguredAcpBindingSpecBySessionKey({ + const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({ cfg: baseCfg, sessionKey: "agent:main:acp:binding:discord:default:notfound", }); @@ -568,13 +609,13 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { acp: { backend: "acpx" }, }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "user_123", }); - const spec = resolveConfiguredAcpBindingSpecBySessionKey({ + const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({ cfg, sessionKey: resolved?.record.targetSessionKey ?? "", }); @@ -614,7 +655,7 @@ describe("ensureConfiguredAcpBindingSession", () => { cwd: "/workspace/openclaw", }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -633,7 +674,7 @@ describe("ensureConfiguredAcpBindingSession", () => { cwd: "/workspace/other-repo", }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -649,6 +690,26 @@ describe("ensureConfiguredAcpBindingSession", () => { expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1); }); + it("keeps a matching ready session even when the stored ACP session is in error state", async () => { + const spec = createDiscordPersistentSpec({ + cwd: "/home/bob/clawd", + }); + const sessionKey = mockReadySession({ + spec, + cwd: "/home/bob/clawd", + state: "error", + }); + + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ + cfg: baseCfg, + spec, + }); + + expect(ensured).toEqual({ ok: true, sessionKey }); + expect(managerMocks.closeSession).not.toHaveBeenCalled(); + expect(managerMocks.initializeSession).not.toHaveBeenCalled(); + }); + it("initializes ACP session with runtime agent override when provided", async () => { const spec = createDiscordPersistentSpec({ agentId: "coding", @@ -656,7 +717,7 @@ describe("ensureConfiguredAcpBindingSession", () => { }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -692,7 +753,7 @@ describe("resetAcpSessionInPlace", () => { }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "new", @@ -721,7 +782,7 @@ describe("resetAcpSessionInPlace", () => { }); managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable")); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg: baseCfg, sessionKey, reason: "reset", @@ -752,7 +813,7 @@ describe("resetAcpSessionInPlace", () => { }, }); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "reset", @@ -766,4 +827,64 @@ describe("resetAcpSessionInPlace", () => { }), ); }); + + it("preserves configured ACP agent overrides during in-place reset when metadata omits the agent", async () => { + const cfg = createCfgWithBindings( + [ + createDiscordBinding({ + agentId: "coding", + conversationId: "1478844424791396446", + }), + ], + { + agents: { + list: [ + { id: "main" }, + { + id: "coding", + runtime: { + type: "acp", + acp: { + agent: "codex", + backend: "acpx", + mode: "persistent", + }, + }, + }, + { id: "claude" }, + ], + }, + }, + ); + const sessionKey = buildConfiguredAcpSessionKey({ + channel: "discord", + accountId: "default", + conversationId: "1478844424791396446", + agentId: "coding", + acpAgentId: "codex", + mode: "persistent", + backend: "acpx", + }); + sessionMetaMocks.readAcpSessionEntry.mockReturnValue({ + acp: { + mode: "persistent", + backend: "acpx", + }, + }); + + const result = await persistentBindings.resetAcpSessionInPlace({ + cfg, + sessionKey, + reason: "reset", + }); + + expect(result).toEqual({ ok: true }); + expect(managerMocks.initializeSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + agent: "codex", + backendId: "acpx", + }), + ); + }); }); diff --git a/src/acp/persistent-bindings.ts b/src/acp/persistent-bindings.ts deleted file mode 100644 index d5b1f4ce729..00000000000 --- a/src/acp/persistent-bindings.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - buildConfiguredAcpSessionKey, - normalizeBindingConfig, - normalizeMode, - normalizeText, - toConfiguredAcpBindingRecord, - type AcpBindingConfigShape, - type ConfiguredAcpBindingChannel, - type ConfiguredAcpBindingSpec, - type ResolvedConfiguredAcpBinding, -} from "./persistent-bindings.types.js"; -export { - ensureConfiguredAcpBindingSession, - resetAcpSessionInPlace, -} from "./persistent-bindings.lifecycle.js"; -export { - resolveConfiguredAcpBindingRecord, - resolveConfiguredAcpBindingSpecBySessionKey, -} from "./persistent-bindings.resolve.js"; diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index 3583fc4cd9f..3b5a0335a59 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { ChannelId } from "../channels/plugins/types.js"; import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; +import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { sanitizeAgentId } from "../routing/session-key.js"; import type { AcpRuntimeSessionMode } from "./runtime/types.js"; @@ -104,3 +105,72 @@ export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): Se }, }; } + +export function parseConfiguredAcpSessionKey( + sessionKey: string, +): { channel: ConfiguredAcpBindingChannel; accountId: string } | null { + const trimmed = sessionKey.trim(); + if (!trimmed.startsWith("agent:")) { + return null; + } + const rest = trimmed.slice(trimmed.indexOf(":") + 1); + const nextSeparator = rest.indexOf(":"); + if (nextSeparator === -1) { + return null; + } + const tokens = rest.slice(nextSeparator + 1).split(":"); + if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") { + return null; + } + const channel = tokens[2]?.trim().toLowerCase(); + if (!channel) { + return null; + } + return { + channel: channel as ConfiguredAcpBindingChannel, + accountId: normalizeAccountId(tokens[3] ?? "default"), + }; +} + +export function resolveConfiguredAcpBindingSpecFromRecord( + record: SessionBindingRecord, +): ConfiguredAcpBindingSpec | null { + if (record.targetKind !== "session") { + return null; + } + const conversationId = record.conversation.conversationId.trim(); + if (!conversationId) { + return null; + } + const agentId = + normalizeText(record.metadata?.agentId) ?? + resolveAgentIdFromSessionKey(record.targetSessionKey); + if (!agentId) { + return null; + } + return { + channel: record.conversation.channel as ConfiguredAcpBindingChannel, + accountId: normalizeAccountId(record.conversation.accountId), + conversationId, + parentConversationId: normalizeText(record.conversation.parentConversationId), + agentId, + acpAgentId: normalizeText(record.metadata?.acpAgentId), + mode: normalizeMode(record.metadata?.mode), + cwd: normalizeText(record.metadata?.cwd), + backend: normalizeText(record.metadata?.backend), + label: normalizeText(record.metadata?.label), + }; +} + +export function toResolvedConfiguredAcpBinding( + record: SessionBindingRecord, +): ResolvedConfiguredAcpBinding | null { + const spec = resolveConfiguredAcpBindingSpecFromRecord(record); + if (!spec) { + return null; + } + return { + spec, + record, + }; +} diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index ff48d1e1ce6..fc94a1f0c05 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -165,6 +165,7 @@ export async function upsertAcpSessionMeta(params: { }, { activeSessionKey: sessionKey.toLowerCase(), + allowDropAcpMetaSessionKeys: [sessionKey], }, ); } diff --git a/src/auto-reply/reply/acp-reset-target.ts b/src/auto-reply/reply/acp-reset-target.ts index cf8952cdc4a..b77d0f320cc 100644 --- a/src/auto-reply/reply/acp-reset-target.ts +++ b/src/auto-reply/reply/acp-reset-target.ts @@ -1,4 +1,4 @@ -import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js"; +import { resolveConfiguredBindingRecord } from "../../channels/plugins/binding-registry.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js"; @@ -51,7 +51,7 @@ export function resolveEffectiveResetTargetSessionKey(params: { return undefined; } - const configuredBinding = resolveConfiguredAcpBindingRecord({ + const configuredBinding = resolveConfiguredBindingRecord({ cfg: params.cfg, channel, accountId, diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index ed3e61e58bb..c3425161773 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js"; +import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -228,7 +228,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise ({ resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg), })); -const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); -const { resetInboundDedupe } = await import("./inbound-dedupe.js"); -const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"); -const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"); - const noAbortResult = { handled: false, aborted: false } as const; const emptyConfig = {} as OpenClawConfig; -type DispatchReplyArgs = Parameters[0]; +let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig; +let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe; +let acpManagerTesting: typeof import("../../acp/control-plane/manager.js").__testing; +let pluginBindingTesting: typeof import("../../plugins/conversation-binding.js").__testing; +let AcpRuntimeErrorClass: typeof import("../../acp/runtime/errors.js").AcpRuntimeError; +type DispatchReplyArgs = Parameters< + typeof import("./dispatch-from-config.js").dispatchReplyFromConfig +>[0]; function createDispatcher(): ReplyDispatcher { return { @@ -254,9 +257,39 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); + ({ resetInboundDedupe } = await import("./inbound-dedupe.js")); + ({ __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js")); + ({ __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js")); + ({ AcpRuntimeError: AcpRuntimeErrorClass } = await import("../../acp/runtime/errors.js")); + const discordTestPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + capabilities: { + chatTypes: ["direct"], + nativeCommands: true, + }, + }), + execApprovals: { + shouldSuppressLocalPrompt: ({ payload }: { payload: ReplyPayload }) => + Boolean( + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) && + payload.channelData.execApproval, + ), + }, + }; setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: discordTestPlugin, + }, + ]), ); acpManagerTesting.resetAcpSessionManagerForTests(); resetInboundDedupe(); @@ -1733,7 +1766,7 @@ describe("dispatchReplyFromConfig", () => { }, }); acpMocks.requireAcpRuntimeBackend.mockImplementation(() => { - throw new AcpRuntimeError( + throw new AcpRuntimeErrorClass( "ACP_BACKEND_MISSING", "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", ); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 18a7eb7802d..34950c20950 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,4 +1,8 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { + resolveConversationBindingRecord, + touchConversationBindingRecord, +} from "../../bindings/records.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -20,7 +24,6 @@ import { toPluginMessageReceivedEvent, } from "../../hooks/message-hook-mappers.js"; import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; -import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { logMessageProcessed, logMessageQueued, @@ -303,7 +306,7 @@ export async function dispatchReplyFromConfig(params: { const pluginOwnedBindingRecord = inboundClaimContext.conversationId && inboundClaimContext.channelId - ? getSessionBindingService().resolveByConversation({ + ? resolveConversationBindingRecord({ channel: inboundClaimContext.channelId, accountId: inboundClaimContext.accountId ?? "default", conversationId: inboundClaimContext.conversationId, @@ -320,7 +323,7 @@ export async function dispatchReplyFromConfig(params: { | undefined; if (pluginOwnedBinding) { - getSessionBindingService().touch(pluginOwnedBinding.bindingId); + touchConversationBindingRecord(pluginOwnedBinding.bindingId); logVerbose( `plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`, ); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 98fd1144f77..515d71726fb 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -99,6 +99,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => httpRoutes: [], cliRegistrars: [], services: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); @@ -300,7 +301,7 @@ describe("routeReply", () => { }); it("passes thread id to Telegram sends", async () => { - mocks.sendMessageTelegram.mockClear(); + mocks.deliverOutboundPayloads.mockResolvedValue([]); await routeReply({ payload: { text: "hi" }, channel: "telegram", @@ -308,10 +309,12 @@ describe("routeReply", () => { threadId: 42, cfg: {} as never, }); - expect(mocks.sendMessageTelegram).toHaveBeenCalledWith( - "telegram:123", - "hi", - expect.objectContaining({ messageThreadId: 42 }), + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "telegram:123", + threadId: 42, + }), ); }); @@ -346,17 +349,19 @@ describe("routeReply", () => { }); it("passes replyToId to Telegram sends", async () => { - mocks.sendMessageTelegram.mockClear(); + mocks.deliverOutboundPayloads.mockResolvedValue([]); await routeReply({ payload: { text: "hi", replyToId: "123" }, channel: "telegram", to: "telegram:123", cfg: {} as never, }); - expect(mocks.sendMessageTelegram).toHaveBeenCalledWith( - "telegram:123", - "hi", - expect.objectContaining({ replyToMessageId: 123 }), + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "telegram:123", + replyToId: "123", + }), ); }); diff --git a/src/bindings/records.ts b/src/bindings/records.ts new file mode 100644 index 00000000000..d4c1909e023 --- /dev/null +++ b/src/bindings/records.ts @@ -0,0 +1,48 @@ +import { + getSessionBindingService, + type ConversationRef, + type SessionBindingBindInput, + type SessionBindingCapabilities, + type SessionBindingRecord, + type SessionBindingUnbindInput, +} from "../infra/outbound/session-binding-service.js"; + +// Shared binding record helpers used by both configured bindings and +// runtime-created plugin conversation bindings. +export async function createConversationBindingRecord( + input: SessionBindingBindInput, +): Promise { + return await getSessionBindingService().bind(input); +} + +export function getConversationBindingCapabilities(params: { + channel: string; + accountId: string; +}): SessionBindingCapabilities { + return getSessionBindingService().getCapabilities(params); +} + +export function listSessionBindingRecords(targetSessionKey: string): SessionBindingRecord[] { + return getSessionBindingService().listBySession(targetSessionKey); +} + +export function resolveConversationBindingRecord( + conversation: ConversationRef, +): SessionBindingRecord | null { + return getSessionBindingService().resolveByConversation(conversation); +} + +export function touchConversationBindingRecord(bindingId: string, at?: number): void { + const service = getSessionBindingService(); + if (typeof at === "number") { + service.touch(bindingId, at); + return; + } + service.touch(bindingId); +} + +export async function unbindConversationBindingRecord( + input: SessionBindingUnbindInput, +): Promise { + return await getSessionBindingService().unbind(input); +} diff --git a/src/channels/plugins/acp-bindings.test.ts b/src/channels/plugins/acp-bindings.test.ts new file mode 100644 index 00000000000..7d380c665a3 --- /dev/null +++ b/src/channels/plugins/acp-bindings.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.types.js"; + +const resolveAgentConfigMock = vi.hoisted(() => vi.fn()); +const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); +const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn()); +const getChannelPluginMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentConfig: (...args: unknown[]) => resolveAgentConfigMock(...args), + resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args), + resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args), +})); + +vi.mock("./index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), +})); + +vi.mock("../../plugins/runtime.js", () => ({ + getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args), + getActivePluginRegistryVersion: (...args: unknown[]) => + getActivePluginRegistryVersionMock(...args), +})); + +async function importConfiguredBindings() { + const builtins = await import("./configured-binding-builtins.js"); + builtins.ensureConfiguredBindingBuiltinsRegistered(); + return await import("./configured-binding-registry.js"); +} + +function createConfig(options?: { bindingAgentId?: string; accountId?: string }) { + return { + agents: { + list: [{ id: "main" }, { id: "codex" }], + }, + bindings: [ + { + type: "acp", + agentId: options?.bindingAgentId ?? "codex", + match: { + channel: "discord", + accountId: options?.accountId ?? "default", + peer: { + kind: "channel", + id: "1479098716916023408", + }, + }, + acp: { + backend: "acpx", + }, + }, + ], + }; +} + +function createDiscordAcpPlugin(overrides?: { + compileConfiguredBinding?: ReturnType; + matchInboundConversation?: ReturnType; +}) { + const compileConfiguredBinding = + overrides?.compileConfiguredBinding ?? + vi.fn(({ conversationId }: { conversationId: string }) => ({ + conversationId, + })); + const matchInboundConversation = + overrides?.matchInboundConversation ?? + vi.fn( + ({ + compiledBinding, + conversationId, + parentConversationId, + }: { + compiledBinding: { conversationId: string }; + conversationId: string; + parentConversationId?: string; + }) => { + if (compiledBinding.conversationId === conversationId) { + return { conversationId, matchPriority: 2 }; + } + if (parentConversationId && compiledBinding.conversationId === parentConversationId) { + return { conversationId: parentConversationId, matchPriority: 1 }; + } + return null; + }, + ); + return { + id: "discord", + bindings: { + compileConfiguredBinding, + matchInboundConversation, + }, + }; +} + +describe("configured binding registry", () => { + beforeEach(() => { + vi.resetModules(); + resolveAgentConfigMock.mockReset().mockReturnValue(undefined); + resolveDefaultAgentIdMock.mockReset().mockReturnValue("main"); + resolveAgentWorkspaceDirMock.mockReset().mockReturnValue("/tmp/workspace"); + getChannelPluginMock.mockReset(); + getActivePluginRegistryMock.mockReset().mockReturnValue({ channels: [] }); + getActivePluginRegistryVersionMock.mockReset().mockReturnValue(1); + }); + + it("resolves configured ACP bindings from an already loaded channel plugin", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: createConfig() as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(resolved?.record.conversation.channel).toBe("discord"); + expect(resolved?.record.metadata?.backend).toBe("acpx"); + expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1); + }); + + it("resolves configured ACP bindings from canonical conversation refs", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBinding({ + cfg: createConfig() as never, + conversation: { + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }, + }); + + expect(resolved?.conversation).toEqual({ + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + expect(resolved?.record.conversation.channel).toBe("discord"); + expect(resolved?.statefulTarget).toEqual({ + kind: "stateful", + driverId: "acp", + sessionKey: resolved?.record.targetSessionKey, + agentId: "codex", + label: undefined, + }); + }); + + it("primes compiled ACP bindings from the already loaded active registry once", async () => { + const plugin = createDiscordAcpPlugin(); + const cfg = createConfig({ bindingAgentId: "codex" }); + getChannelPluginMock.mockReturnValue(undefined); + getActivePluginRegistryMock.mockReturnValue({ + channels: [{ plugin }], + }); + const bindingRegistry = await importConfiguredBindings(); + + const primed = bindingRegistry.primeConfiguredBindingRegistry({ + cfg: cfg as never, + }); + const resolved = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(primed).toEqual({ bindingCount: 1, channelCount: 1 }); + expect(resolved?.statefulTarget.agentId).toBe("codex"); + expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1); + + const second = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(second?.statefulTarget.agentId).toBe("codex"); + }); + + it("resolves wildcard binding session keys from the compiled registry", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBindingRecordBySessionKey({ + cfg: createConfig({ accountId: "*" }) as never, + sessionKey: buildConfiguredAcpSessionKey({ + channel: "discord", + accountId: "work", + conversationId: "1479098716916023408", + agentId: "codex", + mode: "persistent", + backend: "acpx", + }), + }); + + expect(resolved?.record.conversation.channel).toBe("discord"); + expect(resolved?.record.conversation.accountId).toBe("work"); + expect(resolved?.record.metadata?.backend).toBe("acpx"); + }); + + it("does not perform late plugin discovery when a channel plugin is unavailable", async () => { + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: createConfig() as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(resolved).toBeNull(); + }); + + it("rebuilds the compiled registry when the active plugin registry version changes", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + getActivePluginRegistryVersionMock.mockReturnValue(10); + const cfg = createConfig(); + const bindingRegistry = await importConfiguredBindings(); + + bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + getActivePluginRegistryVersionMock.mockReturnValue(11); + bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/channels/plugins/acp-configured-binding-consumer.ts b/src/channels/plugins/acp-configured-binding-consumer.ts new file mode 100644 index 00000000000..d453726b357 --- /dev/null +++ b/src/channels/plugins/acp-configured-binding-consumer.ts @@ -0,0 +1,155 @@ +import { + buildConfiguredAcpSessionKey, + normalizeBindingConfig, + normalizeMode, + normalizeText, + parseConfiguredAcpSessionKey, + toConfiguredAcpBindingRecord, + type ConfiguredAcpBindingSpec, +} from "../../acp/persistent-bindings.types.js"; +import { + resolveAgentConfig, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { + ConfiguredBindingRuleConfig, + ConfiguredBindingTargetFactory, +} from "./binding-types.js"; +import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js"; +import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js"; + +function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): { + acpAgentId?: string; + mode?: string; + cwd?: string; + backend?: string; +} { + const agent = params.cfg.agents?.list?.find( + (entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(), + ); + if (!agent || agent.runtime?.type !== "acp") { + return {}; + } + return { + acpAgentId: normalizeText(agent.runtime.acp?.agent), + mode: normalizeText(agent.runtime.acp?.mode), + cwd: normalizeText(agent.runtime.acp?.cwd), + backend: normalizeText(agent.runtime.acp?.backend), + }; +} + +function resolveConfiguredBindingWorkspaceCwd(params: { + cfg: OpenClawConfig; + agentId: string; +}): string | undefined { + const explicitAgentWorkspace = normalizeText( + resolveAgentConfig(params.cfg, params.agentId)?.workspace, + ); + if (explicitAgentWorkspace) { + return resolveAgentWorkspaceDir(params.cfg, params.agentId); + } + if (params.agentId === resolveDefaultAgentId(params.cfg)) { + const defaultWorkspace = normalizeText(params.cfg.agents?.defaults?.workspace); + if (defaultWorkspace) { + return resolveAgentWorkspaceDir(params.cfg, params.agentId); + } + } + return undefined; +} + +function buildConfiguredAcpSpec(params: { + channel: string; + accountId: string; + conversation: ChannelConfiguredBindingConversationRef; + agentId: string; + acpAgentId?: string; + mode: "persistent" | "oneshot"; + cwd?: string; + backend?: string; + label?: string; +}): ConfiguredAcpBindingSpec { + return { + channel: params.channel as ConfiguredAcpBindingSpec["channel"], + accountId: params.accountId, + conversationId: params.conversation.conversationId, + parentConversationId: params.conversation.parentConversationId, + agentId: params.agentId, + acpAgentId: params.acpAgentId, + mode: params.mode, + cwd: params.cwd, + backend: params.backend, + label: params.label, + }; +} + +function buildAcpTargetFactory(params: { + cfg: OpenClawConfig; + binding: ConfiguredBindingRuleConfig; + channel: string; + agentId: string; +}): ConfiguredBindingTargetFactory | null { + if (params.binding.type !== "acp") { + return null; + } + const runtimeDefaults = resolveAgentRuntimeAcpDefaults({ + cfg: params.cfg, + ownerAgentId: params.agentId, + }); + const bindingOverrides = normalizeBindingConfig(params.binding.acp); + const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode); + const cwd = + bindingOverrides.cwd ?? + runtimeDefaults.cwd ?? + resolveConfiguredBindingWorkspaceCwd({ + cfg: params.cfg, + agentId: params.agentId, + }); + const backend = bindingOverrides.backend ?? runtimeDefaults.backend; + const label = bindingOverrides.label; + const acpAgentId = normalizeText(runtimeDefaults.acpAgentId); + + return { + driverId: "acp", + materialize: ({ accountId, conversation }) => { + const spec = buildConfiguredAcpSpec({ + channel: params.channel, + accountId, + conversation, + agentId: params.agentId, + acpAgentId, + mode, + cwd, + backend, + label, + }); + const record = toConfiguredAcpBindingRecord(spec); + return { + record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: buildConfiguredAcpSessionKey(spec), + agentId: params.agentId, + ...(label ? { label } : {}), + }, + }; + }, + }; +} + +export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = { + id: "acp", + supports: (binding) => binding.type === "acp", + buildTargetFactory: (params) => + buildAcpTargetFactory({ + cfg: params.cfg, + binding: params.binding, + channel: params.channel, + agentId: params.agentId, + }), + parseSessionKey: ({ sessionKey }) => parseConfiguredAcpSessionKey(sessionKey), + matchesSessionKey: ({ sessionKey, materializedTarget }) => + materializedTarget.record.targetSessionKey === sessionKey, +}; diff --git a/src/channels/plugins/acp-stateful-target-driver.ts b/src/channels/plugins/acp-stateful-target-driver.ts new file mode 100644 index 00000000000..787013fc5b0 --- /dev/null +++ b/src/channels/plugins/acp-stateful-target-driver.ts @@ -0,0 +1,102 @@ +import { + ensureConfiguredAcpBindingReady, + ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace, +} from "../../acp/persistent-bindings.lifecycle.js"; +import { resolveConfiguredAcpBindingSpecBySessionKey } from "../../acp/persistent-bindings.resolve.js"; +import { resolveConfiguredAcpBindingSpecFromRecord } from "../../acp/persistent-bindings.types.js"; +import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { + ConfiguredBindingResolution, + StatefulBindingTargetDescriptor, +} from "./binding-types.js"; +import type { + StatefulBindingTargetDriver, + StatefulBindingTargetResetResult, + StatefulBindingTargetReadyResult, + StatefulBindingTargetSessionResult, +} from "./stateful-target-drivers.js"; + +function toAcpStatefulBindingTargetDescriptor(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): StatefulBindingTargetDescriptor | null { + const meta = readAcpSessionEntry(params)?.acp; + const metaAgentId = meta?.agent?.trim(); + if (metaAgentId) { + return { + kind: "stateful", + driverId: "acp", + sessionKey: params.sessionKey, + agentId: metaAgentId, + }; + } + const spec = resolveConfiguredAcpBindingSpecBySessionKey(params); + if (!spec) { + return null; + } + return { + kind: "stateful", + driverId: "acp", + sessionKey: params.sessionKey, + agentId: spec.agentId, + ...(spec.label ? { label: spec.label } : {}), + }; +} + +async function ensureAcpTargetReady(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; +}): Promise { + const configuredBinding = resolveConfiguredAcpBindingSpecFromRecord( + params.bindingResolution.record, + ); + if (!configuredBinding) { + return { + ok: false, + error: "Configured ACP binding unavailable", + }; + } + return await ensureConfiguredAcpBindingReady({ + cfg: params.cfg, + configuredBinding: { + spec: configuredBinding, + record: params.bindingResolution.record, + }, + }); +} + +async function ensureAcpTargetSession(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; +}): Promise { + const spec = resolveConfiguredAcpBindingSpecFromRecord(params.bindingResolution.record); + if (!spec) { + return { + ok: false, + sessionKey: params.bindingResolution.statefulTarget.sessionKey, + error: "Configured ACP binding unavailable", + }; + } + return await ensureConfiguredAcpBindingSession({ + cfg: params.cfg, + spec, + }); +} + +async function resetAcpTargetInPlace(params: { + cfg: OpenClawConfig; + sessionKey: string; + reason: "new" | "reset"; +}): Promise { + return await resetAcpSessionInPlace(params); +} + +export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = { + id: "acp", + ensureReady: ensureAcpTargetReady, + ensureSession: ensureAcpTargetSession, + resolveTargetBySessionKey: toAcpStatefulBindingTargetDescriptor, + resetInPlace: resetAcpTargetInPlace, +}; diff --git a/src/channels/plugins/binding-provider.ts b/src/channels/plugins/binding-provider.ts new file mode 100644 index 00000000000..27dc5c49951 --- /dev/null +++ b/src/channels/plugins/binding-provider.ts @@ -0,0 +1,14 @@ +import type { ChannelConfiguredBindingProvider } from "./types.adapters.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +export function resolveChannelConfiguredBindingProvider( + plugin: + | Pick + | { + bindings?: ChannelConfiguredBindingProvider; + } + | null + | undefined, +): ChannelConfiguredBindingProvider | undefined { + return plugin?.bindings; +} diff --git a/src/channels/plugins/binding-registry.ts b/src/channels/plugins/binding-registry.ts new file mode 100644 index 00000000000..f4e95c19eba --- /dev/null +++ b/src/channels/plugins/binding-registry.ts @@ -0,0 +1,46 @@ +import { ensureConfiguredBindingBuiltinsRegistered } from "./configured-binding-builtins.js"; +import { + primeConfiguredBindingRegistry as primeConfiguredBindingRegistryRaw, + resolveConfiguredBinding as resolveConfiguredBindingRaw, + resolveConfiguredBindingRecord as resolveConfiguredBindingRecordRaw, + resolveConfiguredBindingRecordBySessionKey as resolveConfiguredBindingRecordBySessionKeyRaw, + resolveConfiguredBindingRecordForConversation as resolveConfiguredBindingRecordForConversationRaw, +} from "./configured-binding-registry.js"; + +// Thin public wrapper around the configured-binding registry. Runtime plugin +// conversation bindings use a separate approval-driven path in src/plugins/. + +export function primeConfiguredBindingRegistry( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return primeConfiguredBindingRegistryRaw(...args); +} + +export function resolveConfiguredBindingRecord( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordRaw(...args); +} + +export function resolveConfiguredBindingRecordForConversation( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordForConversationRaw(...args); +} + +export function resolveConfiguredBinding( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRaw(...args); +} + +export function resolveConfiguredBindingRecordBySessionKey( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordBySessionKeyRaw(...args); +} diff --git a/src/channels/plugins/binding-routing.ts b/src/channels/plugins/binding-routing.ts new file mode 100644 index 00000000000..6fe8b0c400b --- /dev/null +++ b/src/channels/plugins/binding-routing.ts @@ -0,0 +1,91 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ConversationRef } from "../../infra/outbound/session-binding-service.js"; +import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; +import { deriveLastRoutePolicy } from "../../routing/resolve-route.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { resolveConfiguredBinding } from "./binding-registry.js"; +import { ensureConfiguredBindingTargetReady } from "./binding-targets.js"; +import type { ConfiguredBindingResolution } from "./binding-types.js"; + +export type ConfiguredBindingRouteResult = { + bindingResolution: ConfiguredBindingResolution | null; + route: ResolvedAgentRoute; + boundSessionKey?: string; + boundAgentId?: string; +}; + +type ConfiguredBindingRouteConversationInput = + | { + conversation: ConversationRef; + } + | { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + }; + +function resolveConfiguredBindingConversationRef( + params: ConfiguredBindingRouteConversationInput, +): ConversationRef { + if ("conversation" in params) { + return params.conversation; + } + return { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }; +} + +export function resolveConfiguredBindingRoute( + params: { + cfg: OpenClawConfig; + route: ResolvedAgentRoute; + } & ConfiguredBindingRouteConversationInput, +): ConfiguredBindingRouteResult { + const bindingResolution = + resolveConfiguredBinding({ + cfg: params.cfg, + conversation: resolveConfiguredBindingConversationRef(params), + }) ?? null; + if (!bindingResolution) { + return { + bindingResolution: null, + route: params.route, + }; + } + + const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim(); + if (!boundSessionKey) { + return { + bindingResolution, + route: params.route, + }; + } + const boundAgentId = + resolveAgentIdFromSessionKey(boundSessionKey) || bindingResolution.statefulTarget.agentId; + return { + bindingResolution, + boundSessionKey, + boundAgentId, + route: { + ...params.route, + sessionKey: boundSessionKey, + agentId: boundAgentId, + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: boundSessionKey, + mainSessionKey: params.route.mainSessionKey, + }), + matchedBy: "binding.channel", + }, + }; +} + +export async function ensureConfiguredBindingRouteReady(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution | null; +}): Promise<{ ok: true } | { ok: false; error: string }> { + return await ensureConfiguredBindingTargetReady(params); +} diff --git a/src/channels/plugins/binding-targets.test.ts b/src/channels/plugins/binding-targets.test.ts new file mode 100644 index 00000000000..98503052b3f --- /dev/null +++ b/src/channels/plugins/binding-targets.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ensureConfiguredBindingTargetReady, + ensureConfiguredBindingTargetSession, + resetConfiguredBindingTargetInPlace, +} from "./binding-targets.js"; +import type { ConfiguredBindingResolution } from "./binding-types.js"; +import { + registerStatefulBindingTargetDriver, + unregisterStatefulBindingTargetDriver, + type StatefulBindingTargetDriver, +} from "./stateful-target-drivers.js"; + +function createBindingResolution(driverId: string): ConfiguredBindingResolution { + return { + conversation: { + channel: "discord", + accountId: "default", + conversationId: "123", + }, + compiledBinding: { + channel: "discord", + binding: { + type: "acp" as const, + agentId: "codex", + match: { + channel: "discord", + peer: { + kind: "channel" as const, + id: "123", + }, + }, + acp: { + mode: "persistent", + }, + }, + bindingConversationId: "123", + target: { + conversationId: "123", + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ + conversationId: "123", + }), + matchInboundConversation: () => ({ + conversationId: "123", + }), + }, + targetFactory: { + driverId, + materialize: () => ({ + record: { + bindingId: "binding:123", + targetSessionKey: `agent:codex:${driverId}`, + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "123", + }, + status: "active", + boundAt: 0, + }, + statefulTarget: { + kind: "stateful", + driverId, + sessionKey: `agent:codex:${driverId}`, + agentId: "codex", + }, + }), + }, + }, + match: { + conversationId: "123", + }, + record: { + bindingId: "binding:123", + targetSessionKey: `agent:codex:${driverId}`, + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "123", + }, + status: "active", + boundAt: 0, + }, + statefulTarget: { + kind: "stateful", + driverId, + sessionKey: `agent:codex:${driverId}`, + agentId: "codex", + }, + }; +} + +afterEach(() => { + unregisterStatefulBindingTargetDriver("test-driver"); +}); + +describe("binding target drivers", () => { + it("delegates ensureReady and ensureSession to the resolved driver", async () => { + const ensureReady = vi.fn(async () => ({ ok: true as const })); + const ensureSession = vi.fn(async () => ({ + ok: true as const, + sessionKey: "agent:codex:test-driver", + })); + const driver: StatefulBindingTargetDriver = { + id: "test-driver", + ensureReady, + ensureSession, + }; + registerStatefulBindingTargetDriver(driver); + + const bindingResolution = createBindingResolution("test-driver"); + await expect( + ensureConfiguredBindingTargetReady({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ ok: true }); + await expect( + ensureConfiguredBindingTargetSession({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ + ok: true, + sessionKey: "agent:codex:test-driver", + }); + + expect(ensureReady).toHaveBeenCalledTimes(1); + expect(ensureReady).toHaveBeenCalledWith({ + cfg: {} as never, + bindingResolution, + }); + expect(ensureSession).toHaveBeenCalledTimes(1); + expect(ensureSession).toHaveBeenCalledWith({ + cfg: {} as never, + bindingResolution, + }); + }); + + it("resolves resetInPlace through the driver session-key lookup", async () => { + const resetInPlace = vi.fn(async () => ({ ok: true as const })); + const driver: StatefulBindingTargetDriver = { + id: "test-driver", + ensureReady: async () => ({ ok: true }), + ensureSession: async () => ({ + ok: true, + sessionKey: "agent:codex:test-driver", + }), + resolveTargetBySessionKey: ({ sessionKey }) => ({ + kind: "stateful", + driverId: "test-driver", + sessionKey, + agentId: "codex", + }), + resetInPlace, + }; + registerStatefulBindingTargetDriver(driver); + + await expect( + resetConfiguredBindingTargetInPlace({ + cfg: {} as never, + sessionKey: "agent:codex:test-driver", + reason: "reset", + }), + ).resolves.toEqual({ ok: true }); + + expect(resetInPlace).toHaveBeenCalledTimes(1); + expect(resetInPlace).toHaveBeenCalledWith({ + cfg: {} as never, + sessionKey: "agent:codex:test-driver", + reason: "reset", + bindingTarget: { + kind: "stateful", + driverId: "test-driver", + sessionKey: "agent:codex:test-driver", + agentId: "codex", + }, + }); + }); + + it("returns a typed error when no driver is registered", async () => { + const bindingResolution = createBindingResolution("missing-driver"); + + await expect( + ensureConfiguredBindingTargetReady({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ + ok: false, + error: "Configured binding target driver unavailable: missing-driver", + }); + await expect( + ensureConfiguredBindingTargetSession({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ + ok: false, + sessionKey: "agent:codex:missing-driver", + error: "Configured binding target driver unavailable: missing-driver", + }); + }); +}); diff --git a/src/channels/plugins/binding-targets.ts b/src/channels/plugins/binding-targets.ts new file mode 100644 index 00000000000..2ca8fefea22 --- /dev/null +++ b/src/channels/plugins/binding-targets.ts @@ -0,0 +1,69 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ConfiguredBindingResolution } from "./binding-types.js"; +import { ensureStatefulTargetBuiltinsRegistered } from "./stateful-target-builtins.js"; +import { + getStatefulBindingTargetDriver, + resolveStatefulBindingTargetBySessionKey, +} from "./stateful-target-drivers.js"; + +export async function ensureConfiguredBindingTargetReady(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution | null; +}): Promise<{ ok: true } | { ok: false; error: string }> { + ensureStatefulTargetBuiltinsRegistered(); + if (!params.bindingResolution) { + return { ok: true }; + } + const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId); + if (!driver) { + return { + ok: false, + error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`, + }; + } + return await driver.ensureReady({ + cfg: params.cfg, + bindingResolution: params.bindingResolution, + }); +} + +export async function resetConfiguredBindingTargetInPlace(params: { + cfg: OpenClawConfig; + sessionKey: string; + reason: "new" | "reset"; +}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> { + ensureStatefulTargetBuiltinsRegistered(); + const resolved = resolveStatefulBindingTargetBySessionKey({ + cfg: params.cfg, + sessionKey: params.sessionKey, + }); + if (!resolved?.driver.resetInPlace) { + return { + ok: false, + skipped: true, + }; + } + return await resolved.driver.resetInPlace({ + ...params, + bindingTarget: resolved.bindingTarget, + }); +} + +export async function ensureConfiguredBindingTargetSession(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; +}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> { + ensureStatefulTargetBuiltinsRegistered(); + const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId); + if (!driver) { + return { + ok: false, + sessionKey: params.bindingResolution.statefulTarget.sessionKey, + error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`, + }; + } + return await driver.ensureSession({ + cfg: params.cfg, + bindingResolution: params.bindingResolution, + }); +} diff --git a/src/channels/plugins/binding-types.ts b/src/channels/plugins/binding-types.ts new file mode 100644 index 00000000000..81ca368bc2b --- /dev/null +++ b/src/channels/plugins/binding-types.ts @@ -0,0 +1,53 @@ +import type { AgentBinding } from "../../config/types.js"; +import type { + ConversationRef, + SessionBindingRecord, +} from "../../infra/outbound/session-binding-service.js"; +import type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, + ChannelConfiguredBindingProvider, +} from "./types.adapters.js"; +import type { ChannelId } from "./types.js"; + +export type ConfiguredBindingConversation = ConversationRef; +export type ConfiguredBindingChannel = ChannelId; +export type ConfiguredBindingRuleConfig = AgentBinding; + +export type StatefulBindingTargetDescriptor = { + kind: "stateful"; + driverId: string; + sessionKey: string; + agentId: string; + label?: string; +}; + +export type ConfiguredBindingRecordResolution = { + record: SessionBindingRecord; + statefulTarget: StatefulBindingTargetDescriptor; +}; + +export type ConfiguredBindingTargetFactory = { + driverId: string; + materialize: (params: { + accountId: string; + conversation: ChannelConfiguredBindingConversationRef; + }) => ConfiguredBindingRecordResolution; +}; + +export type CompiledConfiguredBinding = { + channel: ConfiguredBindingChannel; + accountPattern?: string; + binding: ConfiguredBindingRuleConfig; + bindingConversationId: string; + target: ChannelConfiguredBindingConversationRef; + agentId: string; + provider: ChannelConfiguredBindingProvider; + targetFactory: ConfiguredBindingTargetFactory; +}; + +export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & { + conversation: ConfiguredBindingConversation; + compiledBinding: CompiledConfiguredBinding; + match: ChannelConfiguredBindingMatch; +}; diff --git a/src/channels/plugins/configured-binding-builtins.ts b/src/channels/plugins/configured-binding-builtins.ts new file mode 100644 index 00000000000..2d27e9b5286 --- /dev/null +++ b/src/channels/plugins/configured-binding-builtins.ts @@ -0,0 +1,13 @@ +import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js"; +import { + registerConfiguredBindingConsumer, + unregisterConfiguredBindingConsumer, +} from "./configured-binding-consumers.js"; + +export function ensureConfiguredBindingBuiltinsRegistered(): void { + registerConfiguredBindingConsumer(acpConfiguredBindingConsumer); +} + +export function resetConfiguredBindingBuiltinsForTesting(): void { + unregisterConfiguredBindingConsumer(acpConfiguredBindingConsumer.id); +} diff --git a/src/channels/plugins/configured-binding-compiler.ts b/src/channels/plugins/configured-binding-compiler.ts new file mode 100644 index 00000000000..ca5a88022d1 --- /dev/null +++ b/src/channels/plugins/configured-binding-compiler.ts @@ -0,0 +1,240 @@ +import { listConfiguredBindings } from "../../config/bindings.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js"; +import { pickFirstExistingAgentId } from "../../routing/resolve-route.js"; +import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js"; +import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js"; +import { resolveConfiguredBindingConsumer } from "./configured-binding-consumers.js"; +import { getChannelPlugin } from "./index.js"; +import type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingProvider, +} from "./types.adapters.js"; + +// Configured bindings are channel-owned rules compiled from config, separate +// from runtime plugin-owned conversation bindings. + +type ChannelPluginLike = NonNullable>; + +export type CompiledConfiguredBindingRegistry = { + rulesByChannel: Map; +}; + +type CachedCompiledConfiguredBindingRegistry = { + registryVersion: number; + registry: CompiledConfiguredBindingRegistry; +}; + +const compiledRegistryCache = new WeakMap< + OpenClawConfig, + CachedCompiledConfiguredBindingRegistry +>(); + +function findChannelPlugin(params: { + registry: + | { + channels?: Array<{ plugin?: ChannelPluginLike | null } | null> | null; + } + | null + | undefined; + channel: string; +}): ChannelPluginLike | undefined { + return ( + params.registry?.channels?.find((entry) => entry?.plugin?.id === params.channel)?.plugin ?? + undefined + ); +} + +function resolveLoadedChannelPlugin(channel: string) { + const normalized = channel.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + const current = getChannelPlugin(normalized as ConfiguredBindingChannel); + if (current) { + return current; + } + + return findChannelPlugin({ + registry: getActivePluginRegistry(), + channel: normalized, + }); +} + +function resolveConfiguredBindingAdapter(channel: string): { + channel: ConfiguredBindingChannel; + provider: ChannelConfiguredBindingProvider; +} | null { + const normalized = channel.trim().toLowerCase(); + if (!normalized) { + return null; + } + const plugin = resolveLoadedChannelPlugin(normalized); + const provider = resolveChannelConfiguredBindingProvider(plugin); + if ( + !plugin || + !provider || + !provider.compileConfiguredBinding || + !provider.matchInboundConversation + ) { + return null; + } + return { + channel: plugin.id, + provider, + }; +} + +function resolveBindingConversationId(binding: { + match?: { peer?: { id?: string } }; +}): string | null { + const id = binding.match?.peer?.id?.trim(); + return id ? id : null; +} + +function compileConfiguredBindingTarget(params: { + provider: ChannelConfiguredBindingProvider; + binding: CompiledConfiguredBinding["binding"]; + conversationId: string; +}): ChannelConfiguredBindingConversationRef | null { + return params.provider.compileConfiguredBinding({ + binding: params.binding, + conversationId: params.conversationId, + }); +} + +function compileConfiguredBindingRule(params: { + cfg: OpenClawConfig; + channel: ConfiguredBindingChannel; + binding: CompiledConfiguredBinding["binding"]; + target: ChannelConfiguredBindingConversationRef; + bindingConversationId: string; + provider: ChannelConfiguredBindingProvider; +}): CompiledConfiguredBinding | null { + const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main"); + const consumer = resolveConfiguredBindingConsumer(params.binding); + if (!consumer) { + return null; + } + const targetFactory = consumer.buildTargetFactory({ + cfg: params.cfg, + binding: params.binding, + channel: params.channel, + agentId, + target: params.target, + bindingConversationId: params.bindingConversationId, + }); + if (!targetFactory) { + return null; + } + return { + channel: params.channel, + accountPattern: params.binding.match.accountId?.trim() || undefined, + binding: params.binding, + bindingConversationId: params.bindingConversationId, + target: params.target, + agentId, + provider: params.provider, + targetFactory, + }; +} + +function pushCompiledRule( + target: Map, + rule: CompiledConfiguredBinding, +) { + const existing = target.get(rule.channel); + if (existing) { + existing.push(rule); + return; + } + target.set(rule.channel, [rule]); +} + +function compileConfiguredBindingRegistry(params: { + cfg: OpenClawConfig; +}): CompiledConfiguredBindingRegistry { + const rulesByChannel = new Map(); + + for (const binding of listConfiguredBindings(params.cfg)) { + const bindingConversationId = resolveBindingConversationId(binding); + if (!bindingConversationId) { + continue; + } + + const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel); + if (!resolvedChannel) { + continue; + } + + const target = compileConfiguredBindingTarget({ + provider: resolvedChannel.provider, + binding, + conversationId: bindingConversationId, + }); + if (!target) { + continue; + } + + const rule = compileConfiguredBindingRule({ + cfg: params.cfg, + channel: resolvedChannel.channel, + binding, + target, + bindingConversationId, + provider: resolvedChannel.provider, + }); + if (!rule) { + continue; + } + pushCompiledRule(rulesByChannel, rule); + } + + return { + rulesByChannel, + }; +} + +export function resolveCompiledBindingRegistry( + cfg: OpenClawConfig, +): CompiledConfiguredBindingRegistry { + const registryVersion = getActivePluginRegistryVersion(); + const cached = compiledRegistryCache.get(cfg); + if (cached?.registryVersion === registryVersion) { + return cached.registry; + } + + const registry = compileConfiguredBindingRegistry({ + cfg, + }); + compiledRegistryCache.set(cfg, { + registryVersion, + registry, + }); + return registry; +} + +export function primeCompiledBindingRegistry( + cfg: OpenClawConfig, +): CompiledConfiguredBindingRegistry { + const registry = compileConfiguredBindingRegistry({ cfg }); + compiledRegistryCache.set(cfg, { + registryVersion: getActivePluginRegistryVersion(), + registry, + }); + return registry; +} + +export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): { + bindingCount: number; + channelCount: number; +} { + return { + bindingCount: [...registry.rulesByChannel.values()].reduce( + (sum, rules) => sum + rules.length, + 0, + ), + channelCount: registry.rulesByChannel.size, + }; +} diff --git a/src/channels/plugins/configured-binding-consumers.ts b/src/channels/plugins/configured-binding-consumers.ts new file mode 100644 index 00000000000..dbe5dc8791c --- /dev/null +++ b/src/channels/plugins/configured-binding-consumers.ts @@ -0,0 +1,69 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { + CompiledConfiguredBinding, + ConfiguredBindingRecordResolution, + ConfiguredBindingRuleConfig, + ConfiguredBindingTargetFactory, +} from "./binding-types.js"; +import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js"; + +export type ParsedConfiguredBindingSessionKey = { + channel: string; + accountId: string; +}; + +export type ConfiguredBindingConsumer = { + id: string; + supports: (binding: ConfiguredBindingRuleConfig) => boolean; + buildTargetFactory: (params: { + cfg: OpenClawConfig; + binding: ConfiguredBindingRuleConfig; + channel: string; + agentId: string; + target: ChannelConfiguredBindingConversationRef; + bindingConversationId: string; + }) => ConfiguredBindingTargetFactory | null; + parseSessionKey?: (params: { sessionKey: string }) => ParsedConfiguredBindingSessionKey | null; + matchesSessionKey?: (params: { + sessionKey: string; + compiledBinding: CompiledConfiguredBinding; + accountId: string; + materializedTarget: ConfiguredBindingRecordResolution; + }) => boolean; +}; + +const registeredConfiguredBindingConsumers = new Map(); + +export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] { + return [...registeredConfiguredBindingConsumers.values()]; +} + +export function resolveConfiguredBindingConsumer( + binding: ConfiguredBindingRuleConfig, +): ConfiguredBindingConsumer | null { + for (const consumer of listConfiguredBindingConsumers()) { + if (consumer.supports(binding)) { + return consumer; + } + } + return null; +} + +export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void { + const id = consumer.id.trim(); + if (!id) { + throw new Error("Configured binding consumer id is required"); + } + const existing = registeredConfiguredBindingConsumers.get(id); + if (existing) { + return; + } + registeredConfiguredBindingConsumers.set(id, { + ...consumer, + id, + }); +} + +export function unregisterConfiguredBindingConsumer(id: string): void { + registeredConfiguredBindingConsumers.delete(id.trim()); +} diff --git a/src/channels/plugins/configured-binding-match.ts b/src/channels/plugins/configured-binding-match.ts new file mode 100644 index 00000000000..7e9ec4f4b09 --- /dev/null +++ b/src/channels/plugins/configured-binding-match.ts @@ -0,0 +1,116 @@ +import type { ConversationRef } from "../../infra/outbound/session-binding-service.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { + CompiledConfiguredBinding, + ConfiguredBindingChannel, + ConfiguredBindingRecordResolution, +} from "./binding-types.js"; +import type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "./types.adapters.js"; + +export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 { + const trimmed = (match ?? "").trim(); + if (!trimmed) { + return actual === DEFAULT_ACCOUNT_ID ? 2 : 0; + } + if (trimmed === "*") { + return 1; + } + return normalizeAccountId(trimmed) === actual ? 2 : 0; +} + +function matchCompiledBindingConversation(params: { + rule: CompiledConfiguredBinding; + conversationId: string; + parentConversationId?: string; +}): ChannelConfiguredBindingMatch | null { + return params.rule.provider.matchInboundConversation({ + binding: params.rule.binding, + compiledBinding: params.rule.target, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); +} + +export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null { + const normalized = raw.trim().toLowerCase(); + return normalized ? (normalized as ConfiguredBindingChannel) : null; +} + +export function toConfiguredBindingConversationRef(conversation: ConversationRef): { + channel: ConfiguredBindingChannel; + accountId: string; + conversationId: string; + parentConversationId?: string; +} | null { + const channel = resolveCompiledBindingChannel(conversation.channel); + const conversationId = conversation.conversationId.trim(); + if (!channel || !conversationId) { + return null; + } + return { + channel, + accountId: normalizeAccountId(conversation.accountId), + conversationId, + parentConversationId: conversation.parentConversationId?.trim() || undefined, + }; +} + +export function materializeConfiguredBindingRecord(params: { + rule: CompiledConfiguredBinding; + accountId: string; + conversation: ChannelConfiguredBindingConversationRef; +}): ConfiguredBindingRecordResolution { + return params.rule.targetFactory.materialize({ + accountId: normalizeAccountId(params.accountId), + conversation: params.conversation, + }); +} + +export function resolveMatchingConfiguredBinding(params: { + rules: CompiledConfiguredBinding[]; + conversation: ReturnType; +}): { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null { + if (!params.conversation) { + return null; + } + + let wildcardMatch: { + rule: CompiledConfiguredBinding; + match: ChannelConfiguredBindingMatch; + } | null = null; + let exactMatch: { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null = + null; + + for (const rule of params.rules) { + const accountMatchPriority = resolveAccountMatchPriority( + rule.accountPattern, + params.conversation.accountId, + ); + if (accountMatchPriority === 0) { + continue; + } + const match = matchCompiledBindingConversation({ + rule, + conversationId: params.conversation.conversationId, + parentConversationId: params.conversation.parentConversationId, + }); + if (!match) { + continue; + } + const matchPriority = match.matchPriority ?? 0; + if (accountMatchPriority === 2) { + if (!exactMatch || matchPriority > (exactMatch.match.matchPriority ?? 0)) { + exactMatch = { rule, match }; + } + continue; + } + if (!wildcardMatch || matchPriority > (wildcardMatch.match.matchPriority ?? 0)) { + wildcardMatch = { rule, match }; + } + } + + return exactMatch ?? wildcardMatch; +} diff --git a/src/channels/plugins/configured-binding-registry.ts b/src/channels/plugins/configured-binding-registry.ts new file mode 100644 index 00000000000..6a7aba3bdfb --- /dev/null +++ b/src/channels/plugins/configured-binding-registry.ts @@ -0,0 +1,116 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ConversationRef } from "../../infra/outbound/session-binding-service.js"; +import type { + ConfiguredBindingRecordResolution, + ConfiguredBindingResolution, +} from "./binding-types.js"; +import { + countCompiledBindingRegistry, + primeCompiledBindingRegistry, + resolveCompiledBindingRegistry, +} from "./configured-binding-compiler.js"; +import { + materializeConfiguredBindingRecord, + resolveMatchingConfiguredBinding, + toConfiguredBindingConversationRef, +} from "./configured-binding-match.js"; +import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js"; + +export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): { + bindingCount: number; + channelCount: number; +} { + return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg)); +} + +export function resolveConfiguredBindingRecord(params: { + cfg: OpenClawConfig; + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; +}): ConfiguredBindingRecordResolution | null { + const conversation = toConfiguredBindingConversationRef({ + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if (!conversation) { + return null; + } + return resolveConfiguredBindingRecordForConversation({ + cfg: params.cfg, + conversation, + }); +} + +export function resolveConfiguredBindingRecordForConversation(params: { + cfg: OpenClawConfig; + conversation: ConversationRef; +}): ConfiguredBindingRecordResolution | null { + const conversation = toConfiguredBindingConversationRef(params.conversation); + if (!conversation) { + return null; + } + const registry = resolveCompiledBindingRegistry(params.cfg); + const rules = registry.rulesByChannel.get(conversation.channel); + if (!rules || rules.length === 0) { + return null; + } + const resolved = resolveMatchingConfiguredBinding({ + rules, + conversation, + }); + if (!resolved) { + return null; + } + return materializeConfiguredBindingRecord({ + rule: resolved.rule, + accountId: conversation.accountId, + conversation: resolved.match, + }); +} + +export function resolveConfiguredBinding(params: { + cfg: OpenClawConfig; + conversation: ConversationRef; +}): ConfiguredBindingResolution | null { + const conversation = toConfiguredBindingConversationRef(params.conversation); + if (!conversation) { + return null; + } + const registry = resolveCompiledBindingRegistry(params.cfg); + const rules = registry.rulesByChannel.get(conversation.channel); + if (!rules || rules.length === 0) { + return null; + } + const resolved = resolveMatchingConfiguredBinding({ + rules, + conversation, + }); + if (!resolved) { + return null; + } + const materializedTarget = materializeConfiguredBindingRecord({ + rule: resolved.rule, + accountId: conversation.accountId, + conversation: resolved.match, + }); + return { + conversation, + compiledBinding: resolved.rule, + match: resolved.match, + ...materializedTarget, + }; +} + +export function resolveConfiguredBindingRecordBySessionKey(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): ConfiguredBindingRecordResolution | null { + return resolveConfiguredBindingRecordBySessionKeyFromRegistry({ + registry: resolveCompiledBindingRegistry(params.cfg), + sessionKey: params.sessionKey, + }); +} diff --git a/src/channels/plugins/configured-binding-session-lookup.ts b/src/channels/plugins/configured-binding-session-lookup.ts new file mode 100644 index 00000000000..e4baa4057d8 --- /dev/null +++ b/src/channels/plugins/configured-binding-session-lookup.ts @@ -0,0 +1,74 @@ +import type { ConfiguredBindingRecordResolution } from "./binding-types.js"; +import type { CompiledConfiguredBindingRegistry } from "./configured-binding-compiler.js"; +import { listConfiguredBindingConsumers } from "./configured-binding-consumers.js"; +import { + materializeConfiguredBindingRecord, + resolveAccountMatchPriority, + resolveCompiledBindingChannel, +} from "./configured-binding-match.js"; + +export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: { + registry: CompiledConfiguredBindingRegistry; + sessionKey: string; +}): ConfiguredBindingRecordResolution | null { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return null; + } + + for (const consumer of listConfiguredBindingConsumers()) { + const parsed = consumer.parseSessionKey?.({ sessionKey }); + if (!parsed) { + continue; + } + const channel = resolveCompiledBindingChannel(parsed.channel); + if (!channel) { + continue; + } + const rules = params.registry.rulesByChannel.get(channel); + if (!rules || rules.length === 0) { + continue; + } + let wildcardMatch: ConfiguredBindingRecordResolution | null = null; + let exactMatch: ConfiguredBindingRecordResolution | null = null; + for (const rule of rules) { + if (rule.targetFactory.driverId !== consumer.id) { + continue; + } + const accountMatchPriority = resolveAccountMatchPriority( + rule.accountPattern, + parsed.accountId, + ); + if (accountMatchPriority === 0) { + continue; + } + const materializedTarget = materializeConfiguredBindingRecord({ + rule, + accountId: parsed.accountId, + conversation: rule.target, + }); + const matchesSessionKey = + consumer.matchesSessionKey?.({ + sessionKey, + compiledBinding: rule, + accountId: parsed.accountId, + materializedTarget, + }) ?? materializedTarget.record.targetSessionKey === sessionKey; + if (matchesSessionKey) { + if (accountMatchPriority === 2) { + exactMatch = materializedTarget; + break; + } + wildcardMatch = materializedTarget; + } + } + if (exactMatch) { + return exactMatch; + } + if (wildcardMatch) { + return wildcardMatch; + } + } + + return null; +} diff --git a/src/channels/plugins/stateful-target-builtins.ts b/src/channels/plugins/stateful-target-builtins.ts new file mode 100644 index 00000000000..0d87ca31d2d --- /dev/null +++ b/src/channels/plugins/stateful-target-builtins.ts @@ -0,0 +1,13 @@ +import { acpStatefulBindingTargetDriver } from "./acp-stateful-target-driver.js"; +import { + registerStatefulBindingTargetDriver, + unregisterStatefulBindingTargetDriver, +} from "./stateful-target-drivers.js"; + +export function ensureStatefulTargetBuiltinsRegistered(): void { + registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver); +} + +export function resetStatefulTargetBuiltinsForTesting(): void { + unregisterStatefulBindingTargetDriver(acpStatefulBindingTargetDriver.id); +} diff --git a/src/channels/plugins/stateful-target-drivers.ts b/src/channels/plugins/stateful-target-drivers.ts new file mode 100644 index 00000000000..ede52472c57 --- /dev/null +++ b/src/channels/plugins/stateful-target-drivers.ts @@ -0,0 +1,89 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { + ConfiguredBindingResolution, + StatefulBindingTargetDescriptor, +} from "./binding-types.js"; + +export type StatefulBindingTargetReadyResult = { ok: true } | { ok: false; error: string }; +export type StatefulBindingTargetSessionResult = + | { ok: true; sessionKey: string } + | { ok: false; sessionKey: string; error: string }; +export type StatefulBindingTargetResetResult = + | { ok: true } + | { ok: false; skipped?: boolean; error?: string }; + +export type StatefulBindingTargetDriver = { + id: string; + ensureReady: (params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; + }) => Promise; + ensureSession: (params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; + }) => Promise; + resolveTargetBySessionKey?: (params: { + cfg: OpenClawConfig; + sessionKey: string; + }) => StatefulBindingTargetDescriptor | null; + resetInPlace?: (params: { + cfg: OpenClawConfig; + sessionKey: string; + bindingTarget: StatefulBindingTargetDescriptor; + reason: "new" | "reset"; + }) => Promise; +}; + +const registeredStatefulBindingTargetDrivers = new Map(); + +function listStatefulBindingTargetDrivers(): StatefulBindingTargetDriver[] { + return [...registeredStatefulBindingTargetDrivers.values()]; +} + +export function registerStatefulBindingTargetDriver(driver: StatefulBindingTargetDriver): void { + const id = driver.id.trim(); + if (!id) { + throw new Error("Stateful binding target driver id is required"); + } + const normalized = { ...driver, id }; + const existing = registeredStatefulBindingTargetDrivers.get(id); + if (existing) { + return; + } + registeredStatefulBindingTargetDrivers.set(id, normalized); +} + +export function unregisterStatefulBindingTargetDriver(id: string): void { + registeredStatefulBindingTargetDrivers.delete(id.trim()); +} + +export function getStatefulBindingTargetDriver(id: string): StatefulBindingTargetDriver | null { + const normalizedId = id.trim(); + if (!normalizedId) { + return null; + } + return registeredStatefulBindingTargetDrivers.get(normalizedId) ?? null; +} + +export function resolveStatefulBindingTargetBySessionKey(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): { driver: StatefulBindingTargetDriver; bindingTarget: StatefulBindingTargetDescriptor } | null { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return null; + } + for (const driver of listStatefulBindingTargetDrivers()) { + const bindingTarget = driver.resolveTargetBySessionKey?.({ + cfg: params.cfg, + sessionKey, + }); + if (bindingTarget) { + return { + driver, + bindingTarget, + }; + } + } + return null; +} diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index eff6878e85e..c31d6057223 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -1,6 +1,6 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { ConfiguredBindingRule } from "../../config/bindings.js"; import type { OpenClawConfig } from "../../config/config.js"; -import type { AgentAcpBinding } from "../../config/types.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js"; import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js"; @@ -541,24 +541,26 @@ export type ChannelAllowlistAdapter = { supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean; }; -export type ChannelAcpBindingAdapter = { - normalizeConfiguredBindingTarget?: (params: { - binding: AgentAcpBinding; +export type ChannelConfiguredBindingConversationRef = { + conversationId: string; + parentConversationId?: string; +}; + +export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingConversationRef & { + matchPriority?: number; +}; + +export type ChannelConfiguredBindingProvider = { + compileConfiguredBinding: (params: { + binding: ConfiguredBindingRule; conversationId: string; - }) => { + }) => ChannelConfiguredBindingConversationRef | null; + matchInboundConversation: (params: { + binding: ConfiguredBindingRule; + compiledBinding: ChannelConfiguredBindingConversationRef; conversationId: string; parentConversationId?: string; - } | null; - matchConfiguredBinding?: (params: { - binding: AgentAcpBinding; - bindingConversationId: string; - conversationId: string; - parentConversationId?: string; - }) => { - conversationId: string; - parentConversationId?: string; - matchPriority?: number; - } | null; + }) => ChannelConfiguredBindingMatch | null; }; export type ChannelSecurityAdapter = { diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 6798545d22f..b4405a063de 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -17,7 +17,7 @@ import type { ChannelSetupAdapter, ChannelStatusAdapter, ChannelAllowlistAdapter, - ChannelAcpBindingAdapter, + ChannelConfiguredBindingProvider, } from "./types.adapters.js"; import type { ChannelAgentTool, @@ -78,7 +78,7 @@ export type ChannelPlugin { expect(plan.workingDirectory).toBe("/Users/me"); expect(plan.environment).toEqual({ OPENCLAW_PORT: "3000" }); expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled(); + expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + env: {}, + port: 3000, + extraPathDirs: ["/custom"], + }), + ); + }); + + it("does not prepend '.' when nodePath is a bare executable name", async () => { + mockNodeGatewayPlanFixture(); + + await buildGatewayInstallPlan({ + env: {}, + port: 3000, + runtime: "node", + nodePath: "node", + }); + + expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + extraPathDirs: undefined, + }), + ); }); it("emits warnings when renderSystemNodeWarning returns one", async () => { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 91248cb86a7..fcd4a6447fb 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -11,6 +11,7 @@ import { buildServiceEnvironment } from "../daemon/service-env.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, + resolveDaemonNodeBinDir, } from "./daemon-install-plan.shared.js"; import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; @@ -87,6 +88,9 @@ export async function buildGatewayInstallPlan(params: { process.platform === "darwin" ? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE) : undefined, + // Keep npm/pnpm available to the service when the selected daemon node comes from + // a version-manager bin directory that isn't covered by static PATH guesses. + extraPathDirs: resolveDaemonNodeBinDir(nodePath), }); // Merge config env vars into the service environment (vars + inline env keys). diff --git a/src/commands/daemon-install-plan.shared.test.ts b/src/commands/daemon-install-plan.shared.test.ts index 399b521a5d5..8d7a3520eaf 100644 --- a/src/commands/daemon-install-plan.shared.test.ts +++ b/src/commands/daemon-install-plan.shared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveDaemonInstallRuntimeInputs, + resolveDaemonNodeBinDir, resolveGatewayDevMode, } from "./daemon-install-plan.shared.js"; @@ -29,3 +30,13 @@ describe("resolveDaemonInstallRuntimeInputs", () => { }); }); }); + +describe("resolveDaemonNodeBinDir", () => { + it("returns the absolute node bin directory", () => { + expect(resolveDaemonNodeBinDir("/custom/node/bin/node")).toEqual(["/custom/node/bin"]); + }); + + it("ignores bare executable names", () => { + expect(resolveDaemonNodeBinDir("node")).toBeUndefined(); + }); +}); diff --git a/src/commands/daemon-install-plan.shared.ts b/src/commands/daemon-install-plan.shared.ts index b3a970d05f4..cb2f701e632 100644 --- a/src/commands/daemon-install-plan.shared.ts +++ b/src/commands/daemon-install-plan.shared.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { emitNodeRuntimeWarning, @@ -42,3 +43,11 @@ export async function emitDaemonInstallRuntimeWarning(params: { title: params.title, }); } + +export function resolveDaemonNodeBinDir(nodePath?: string): string[] | undefined { + const trimmed = nodePath?.trim(); + if (!trimmed || !path.isAbsolute(trimmed)) { + return undefined; + } + return [path.dirname(trimmed)]; +} diff --git a/src/commands/node-daemon-install-helpers.test.ts b/src/commands/node-daemon-install-helpers.test.ts new file mode 100644 index 00000000000..536bea1d014 --- /dev/null +++ b/src/commands/node-daemon-install-helpers.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolvePreferredNodePath: vi.fn(), + resolveNodeProgramArguments: vi.fn(), + resolveSystemNodeInfo: vi.fn(), + renderSystemNodeWarning: vi.fn(), + buildNodeServiceEnvironment: vi.fn(), +})); + +vi.mock("../daemon/runtime-paths.js", () => ({ + resolvePreferredNodePath: mocks.resolvePreferredNodePath, + resolveSystemNodeInfo: mocks.resolveSystemNodeInfo, + renderSystemNodeWarning: mocks.renderSystemNodeWarning, +})); + +vi.mock("../daemon/program-args.js", () => ({ + resolveNodeProgramArguments: mocks.resolveNodeProgramArguments, +})); + +vi.mock("../daemon/service-env.js", () => ({ + buildNodeServiceEnvironment: mocks.buildNodeServiceEnvironment, +})); + +import { buildNodeInstallPlan } from "./node-daemon-install-helpers.js"; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("buildNodeInstallPlan", () => { + it("passes the selected node bin directory into the node service environment", async () => { + mocks.resolveNodeProgramArguments.mockResolvedValue({ + programArguments: ["node", "node-host"], + workingDirectory: "/Users/me", + }); + mocks.resolveSystemNodeInfo.mockResolvedValue({ + path: "/opt/node/bin/node", + version: "22.0.0", + supported: true, + }); + mocks.renderSystemNodeWarning.mockReturnValue(undefined); + mocks.buildNodeServiceEnvironment.mockReturnValue({ + OPENCLAW_SERVICE_VERSION: "2026.3.14", + }); + + const plan = await buildNodeInstallPlan({ + env: {}, + host: "127.0.0.1", + port: 18789, + runtime: "node", + nodePath: "/custom/node/bin/node", + }); + + expect(plan.environment).toEqual({ + OPENCLAW_SERVICE_VERSION: "2026.3.14", + }); + expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled(); + expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({ + env: {}, + extraPathDirs: ["/custom/node/bin"], + }); + }); + + it("does not prepend '.' when nodePath is a bare executable name", async () => { + mocks.resolveNodeProgramArguments.mockResolvedValue({ + programArguments: ["node", "node-host"], + workingDirectory: "/Users/me", + }); + mocks.resolveSystemNodeInfo.mockResolvedValue({ + path: "/usr/bin/node", + version: "22.0.0", + supported: true, + }); + mocks.renderSystemNodeWarning.mockReturnValue(undefined); + mocks.buildNodeServiceEnvironment.mockReturnValue({ + OPENCLAW_SERVICE_VERSION: "2026.3.14", + }); + + await buildNodeInstallPlan({ + env: {}, + host: "127.0.0.1", + port: 18789, + runtime: "node", + nodePath: "node", + }); + + expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({ + env: {}, + extraPathDirs: undefined, + }); + }); +}); diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index 2f86d1c3b5e..321dff5a664 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -4,6 +4,7 @@ import { buildNodeServiceEnvironment } from "../daemon/service-env.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, + resolveDaemonNodeBinDir, } from "./daemon-install-plan.shared.js"; import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js"; import type { NodeDaemonRuntime } from "./node-daemon-runtime.js"; @@ -54,7 +55,12 @@ export async function buildNodeInstallPlan(params: { title: "Node daemon runtime", }); - const environment = buildNodeServiceEnvironment({ env: params.env }); + const environment = buildNodeServiceEnvironment({ + env: params.env, + // Match the gateway install path so supervised node services keep the chosen + // node toolchain on PATH for sibling binaries like npm/pnpm when needed. + extraPathDirs: resolveDaemonNodeBinDir(nodePath), + }); const description = formatNodeServiceDescription({ version: environment.OPENCLAW_SERVICE_VERSION, }); diff --git a/src/config/bindings.ts b/src/config/bindings.ts index b035fa3be15..5cbcd19c552 100644 --- a/src/config/bindings.ts +++ b/src/config/bindings.ts @@ -1,6 +1,8 @@ import type { OpenClawConfig } from "./config.js"; import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js"; +export type ConfiguredBindingRule = AgentBinding; + function normalizeBindingType(binding: AgentBinding): "route" | "acp" { return binding.type === "acp" ? "acp" : "route"; } diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 2773b6d0fe7..eedf63913eb 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -3,7 +3,9 @@ import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertAcpSessionMeta } from "../../acp/runtime/session-meta.js"; import * as jsonFiles from "../../infra/json-files.js"; +import type { OpenClawConfig } from "../config.js"; import { clearSessionStoreCacheForTest, loadSessionStore, @@ -279,6 +281,72 @@ describe("session store lock (Promise chain mutex)", () => { expect(store[key]?.modelProvider).toBeUndefined(); expect(store[key]?.model).toBeUndefined(); }); + + it("preserves ACP metadata when replacing a session entry wholesale", async () => { + const key = "agent:codex:acp:binding:discord:default:feedface"; + const acp = { + backend: "acpx", + agent: "codex", + runtimeSessionName: "codex-discord", + mode: "persistent" as const, + state: "idle" as const, + lastActivityAt: 100, + }; + const { storePath } = await makeTmpStore({ + [key]: { + sessionId: "sess-acp", + updatedAt: 100, + acp, + }, + }); + + await updateSessionStore(storePath, (store) => { + store[key] = { + sessionId: "sess-acp", + updatedAt: 200, + modelProvider: "openai-codex", + model: "gpt-5.4", + }; + }); + + const store = loadSessionStore(storePath); + expect(store[key]?.acp).toEqual(acp); + expect(store[key]?.modelProvider).toBe("openai-codex"); + expect(store[key]?.model).toBe("gpt-5.4"); + }); + + it("allows explicit ACP metadata removal through the ACP session helper", async () => { + const key = "agent:codex:acp:binding:discord:default:deadbeef"; + const { storePath } = await makeTmpStore({ + [key]: { + sessionId: "sess-acp-clear", + updatedAt: 100, + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "codex-discord", + mode: "persistent", + state: "idle", + lastActivityAt: 100, + }, + }, + }); + const cfg = { + session: { + store: storePath, + }, + } as OpenClawConfig; + + const result = await upsertAcpSessionMeta({ + cfg, + sessionKey: key, + mutate: () => null, + }); + + expect(result?.acp).toBeUndefined(); + const store = loadSessionStore(storePath); + expect(store[key]?.acp).toBeUndefined(); + }); }); describe("appendAssistantMessageToSessionTranscript", () => { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index a70285c4c62..3936086beb8 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -309,6 +309,12 @@ type SaveSessionStoreOptions = { skipMaintenance?: boolean; /** Active session key for warn-only maintenance. */ activeSessionKey?: string; + /** + * Session keys that are allowed to drop persisted ACP metadata during this update. + * All other updates preserve existing `entry.acp` blocks when callers replace the + * whole session entry without carrying ACP state forward. + */ + allowDropAcpMetaSessionKeys?: string[]; /** Optional callback for warn-only maintenance. */ onWarn?: (warning: SessionMaintenanceWarning) => void | Promise; /** Optional callback with maintenance stats after a save. */ @@ -337,6 +343,64 @@ function updateSessionStoreWriteCaches(params: { }); } +function resolveMutableSessionStoreKey( + store: Record, + sessionKey: string, +): string | undefined { + const trimmed = sessionKey.trim(); + if (!trimmed) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(store, trimmed)) { + return trimmed; + } + const normalized = normalizeStoreSessionKey(trimmed); + if (Object.prototype.hasOwnProperty.call(store, normalized)) { + return normalized; + } + return Object.keys(store).find((key) => normalizeStoreSessionKey(key) === normalized); +} + +function collectAcpMetadataSnapshot( + store: Record, +): Map> { + const snapshot = new Map>(); + for (const [sessionKey, entry] of Object.entries(store)) { + if (entry?.acp) { + snapshot.set(sessionKey, entry.acp); + } + } + return snapshot; +} + +function preserveExistingAcpMetadata(params: { + previousAcpByKey: Map>; + nextStore: Record; + allowDropSessionKeys?: string[]; +}): void { + const allowDrop = new Set( + (params.allowDropSessionKeys ?? []).map((key) => normalizeStoreSessionKey(key)), + ); + for (const [previousKey, previousAcp] of params.previousAcpByKey.entries()) { + const normalizedKey = normalizeStoreSessionKey(previousKey); + if (allowDrop.has(normalizedKey)) { + continue; + } + const nextKey = resolveMutableSessionStoreKey(params.nextStore, previousKey); + if (!nextKey) { + continue; + } + const nextEntry = params.nextStore[nextKey]; + if (!nextEntry || nextEntry.acp) { + continue; + } + params.nextStore[nextKey] = { + ...nextEntry, + acp: previousAcp, + }; + } +} + async function saveSessionStoreUnlocked( storePath: string, store: Record, @@ -526,7 +590,13 @@ export async function updateSessionStore( return await withSessionStoreLock(storePath, async () => { // Always re-read inside the lock to avoid clobbering concurrent writers. const store = loadSessionStore(storePath, { skipCache: true }); + const previousAcpByKey = collectAcpMetadataSnapshot(store); const result = await mutator(store); + preserveExistingAcpMetadata({ + previousAcpByKey, + nextStore: store, + allowDropSessionKeys: opts?.allowDropAcpMetaSessionKeys, + }); await saveSessionStoreUnlocked(storePath, store, opts); return result; }); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index e5d60fdfc96..f8297a28554 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -257,6 +257,18 @@ describe("buildMinimalServicePath", () => { const unique = [...new Set(parts)]; expect(parts.length).toBe(unique.length); }); + + it("prepends explicit runtime bin directories before guessed user paths", () => { + const result = buildMinimalServicePath({ + platform: "linux", + extraDirs: ["/home/alice/.nvm/versions/node/v22.22.0/bin"], + env: { HOME: "/home/alice" }, + }); + const parts = splitPath(result, "linux"); + + expect(parts[0]).toBe("/home/alice/.nvm/versions/node/v22.22.0/bin"); + expect(parts).toContain("/home/alice/.nvm/current/bin"); + }); }); describe("buildServiceEnvironment", () => { @@ -344,6 +356,19 @@ describe("buildServiceEnvironment", () => { expect(env).not.toHaveProperty("PATH"); expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway"); }); + + it("prepends extra runtime directories to the gateway service PATH", () => { + const env = buildServiceEnvironment({ + env: { HOME: "/home/user" }, + port: 18789, + platform: "linux", + extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"], + }); + + expect(env.PATH?.split(path.posix.delimiter)[0]).toBe( + "/home/user/.nvm/versions/node/v22.22.0/bin", + ); + }); }); describe("buildNodeServiceEnvironment", () => { @@ -416,6 +441,18 @@ describe("buildNodeServiceEnvironment", () => { }); expect(env.TMPDIR).toBe(os.tmpdir()); }); + + it("prepends extra runtime directories to the node service PATH", () => { + const env = buildNodeServiceEnvironment({ + env: { HOME: "/home/user" }, + platform: "linux", + extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"], + }); + + expect(env.PATH?.split(path.posix.delimiter)[0]).toBe( + "/home/user/.nvm/versions/node/v22.22.0/bin", + ); + }); }); describe("shared Node TLS env defaults", () => { diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index fb6fff41839..cb26c210efb 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -247,10 +247,11 @@ export function buildServiceEnvironment(params: { port: number; launchdLabel?: string; platform?: NodeJS.Platform; + extraPathDirs?: string[]; }): Record { - const { env, port, launchdLabel } = params; + const { env, port, launchdLabel, extraPathDirs } = params; const platform = params.platform ?? process.platform; - const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform); + const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs); const profile = env.OPENCLAW_PROFILE; const resolvedLaunchdLabel = launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined); @@ -271,10 +272,11 @@ export function buildServiceEnvironment(params: { export function buildNodeServiceEnvironment(params: { env: Record; platform?: NodeJS.Platform; + extraPathDirs?: string[]; }): Record { - const { env } = params; + const { env, extraPathDirs } = params; const platform = params.platform ?? process.platform; - const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform); + const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs); const gatewayToken = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined; return { @@ -313,6 +315,7 @@ function buildCommonServiceEnvironment( function resolveSharedServiceEnvironmentFields( env: Record, platform: NodeJS.Platform, + extraPathDirs: string[] | undefined, ): SharedServiceEnvironmentFields { const stateDir = env.OPENCLAW_STATE_DIR; const configPath = env.OPENCLAW_CONFIG_PATH; @@ -331,7 +334,10 @@ function resolveSharedServiceEnvironmentFields( tmpDir, // On Windows, Scheduled Tasks should inherit the current task PATH instead of // freezing the install-time snapshot into gateway.cmd/node-host.cmd. - minimalPath: platform === "win32" ? undefined : buildMinimalServicePath({ env, platform }), + minimalPath: + platform === "win32" + ? undefined + : buildMinimalServicePath({ env, platform, extraDirs: extraPathDirs }), proxyEnv, nodeCaCerts, nodeUseSystemCa, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 7887d43f24f..1ad6bf858ef 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -6,6 +6,9 @@ import type { PluginDiagnostic } from "../plugins/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); +const primeConfiguredBindingRegistry = vi.hoisted(() => + vi.fn(() => ({ bindingCount: 0, channelCount: 0 })), +); type HandleGatewayRequestOptions = GatewayRequestOptions & { extraHandlers?: Record; }; @@ -17,6 +20,10 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins, })); +vi.mock("../channels/plugins/binding-registry.js", () => ({ + primeConfiguredBindingRegistry, +})); + vi.mock("./server-methods.js", () => ({ handleGatewayRequest, })); @@ -51,6 +58,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ httpRoutes: [], cliRegistrars: [], services: [], + conversationBindingResolvedHandlers: [], diagnostics, }); @@ -110,6 +118,7 @@ async function createSubagentRuntime( beforeEach(async () => { loadOpenClawPlugins.mockReset(); + primeConfiguredBindingRegistry.mockClear().mockReturnValue({ bindingCount: 0, channelCount: 0 }); handleGatewayRequest.mockReset(); const runtimeModule = await import("../plugins/runtime/index.js"); runtimeModule.clearGatewaySubagentRuntime(); @@ -440,6 +449,29 @@ describe("loadGatewayPlugins", () => { ); }); + test("primes configured bindings during gateway startup", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + const cfg = {}; + loadGatewayPlugins({ + cfg, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + }); + + expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg }); + }); + test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => { const { loadGatewayPlugins } = await importServerPluginsModule(); const diagnostics: PluginDiagnostic[] = [ diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 2ea249b28b4..a997c93cbbc 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; +import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js"; import type { loadConfig } from "../config/config.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; @@ -416,6 +417,7 @@ export function loadGatewayPlugins(params: { }, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, }); + primeConfiguredBindingRegistry({ cfg: params.cfg }); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 36d24537a14..7b8f5cd5f6c 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 77380f6aa9a..66b7e3b938f 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -1,6 +1,43 @@ -// Public pairing/session-binding helpers for plugins that manage conversation ownership. +// Public binding helpers for both runtime plugin-owned bindings and +// config-driven channel bindings. -export * from "../acp/persistent-bindings.route.js"; +export { + createConversationBindingRecord, + getConversationBindingCapabilities, + listSessionBindingRecords, + resolveConversationBindingRecord, + touchConversationBindingRecord, + unbindConversationBindingRecord, +} from "../bindings/records.js"; +export { + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, + type ConfiguredBindingRouteResult, +} from "../channels/plugins/binding-routing.js"; +export { + primeConfiguredBindingRegistry, + resolveConfiguredBinding, + resolveConfiguredBindingRecord, + resolveConfiguredBindingRecordBySessionKey, + resolveConfiguredBindingRecordForConversation, +} from "../channels/plugins/binding-registry.js"; +export { + ensureConfiguredBindingTargetReady, + ensureConfiguredBindingTargetSession, + resetConfiguredBindingTargetInPlace, +} from "../channels/plugins/binding-targets.js"; +export type { + ConfiguredBindingConversation, + ConfiguredBindingResolution, + CompiledConfiguredBinding, + StatefulBindingTargetDescriptor, +} from "../channels/plugins/binding-types.js"; +export type { + StatefulBindingTargetDriver, + StatefulBindingTargetReadyResult, + StatefulBindingTargetResetResult, + StatefulBindingTargetSessionResult, +} from "../channels/plugins/stateful-target-drivers.js"; export { type BindingStatus, type BindingTargetKind, diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index a249fde385d..25b5b71580e 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,4 +1,8 @@ -export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { + ChannelAccountSnapshot, + ChannelGatewayContext, + ChannelMessageActionAdapter, +} from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; @@ -13,6 +17,11 @@ export type { ThreadBindingRecord, ThreadBindingTargetKind, } from "../../extensions/discord/src/monitor/thread-bindings.js"; +export type { + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../channels/plugins/types.adapters.js"; export type { ChannelMessageActionContext, ChannelPlugin, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index ee15823738b..0ca6fe0a38b 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -31,6 +31,11 @@ export type { ChannelMeta, ChannelOutboundAdapter, } from "../channels/plugins/types.js"; +export type { + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../channels/plugins/types.adapters.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export { createReplyPrefixContext } from "../channels/reply-prefix.js"; export { createTypingCallbacks } from "../channels/typing.js"; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 20f685cdbd2..07d4dde6d98 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -1,9 +1,13 @@ +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { + buildPluginSdkEntrySources, buildPluginSdkPackageExports, buildPluginSdkSpecifiers, pluginSdkEntrypoints, @@ -11,6 +15,9 @@ import { import * as sdk from "./index.js"; const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href; describe("plugin-sdk exports", () => { it("does not expose runtime modules", () => { @@ -63,16 +70,33 @@ describe("plugin-sdk exports", () => { }); it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => { + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-")); const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-")); - const repoDistDir = path.join(process.cwd(), "dist"); try { - await expect(fs.access(path.join(repoDistDir, "plugin-sdk"))).resolves.toBeUndefined(); + const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs"); + await fs.writeFile( + buildScriptPath, + `import { build } from ${JSON.stringify(tsdownModuleUrl)}; +await build(${JSON.stringify({ + clean: true, + config: false, + dts: false, + entry: buildPluginSdkEntrySources(), + env: { NODE_ENV: "production" }, + fixedExtension: false, + logLevel: "error", + outDir, + platform: "node", + })}); +`, + ); + await execFileAsync(process.execPath, [buildScriptPath], { + cwd: process.cwd(), + }); for (const entry of pluginSdkEntrypoints) { - const module = await import( - pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href - ); + const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href); expect(module).toBeTypeOf("object"); } @@ -80,8 +104,8 @@ describe("plugin-sdk exports", () => { const consumerDir = path.join(fixtureDir, "consumer"); const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs"); - await fs.mkdir(packageDir, { recursive: true }); - await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir"); + await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); + await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir"); await fs.writeFile( path.join(packageDir, "package.json"), JSON.stringify( @@ -114,6 +138,7 @@ describe("plugin-sdk exports", () => { Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])), ); } finally { + await fs.rm(outDir, { recursive: true, force: true }); await fs.rm(fixtureDir, { recursive: true, force: true }); } }); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 16720cf8961..a683f5437ca 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -14,8 +14,25 @@ export type { ChannelMessageActionName, ChannelStatusIssue, } from "../channels/plugins/types.js"; +export type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, + ChannelConfiguredBindingProvider, +} from "../channels/plugins/types.adapters.js"; export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ChannelSetupAdapter, ChannelSetupInput } from "../channels/plugins/types.js"; +export type { + ConfiguredBindingConversation, + ConfiguredBindingResolution, + CompiledConfiguredBinding, + StatefulBindingTargetDescriptor, +} from "../channels/plugins/binding-types.js"; +export type { + StatefulBindingTargetDriver, + StatefulBindingTargetReadyResult, + StatefulBindingTargetResetResult, + StatefulBindingTargetSessionResult, +} from "../channels/plugins/stateful-target-drivers.js"; export type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 672bde385c5..c7961f91398 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -12,6 +12,11 @@ export type { TelegramActionConfig, TelegramNetworkConfig, } from "../config/types.js"; +export type { + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../channels/plugins/types.adapters.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; @@ -26,7 +31,6 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; export { formatCliCommand } from "../cli/command-format.js"; export { formatDocsLink } from "../terminal/links.js"; - export { PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 20b0df72337..d3b88697a59 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -7,6 +7,8 @@ import type { SessionBindingAdapter, SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { setActivePluginRegistry } from "./runtime.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-")); const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json"); @@ -145,6 +147,7 @@ describe("plugin conversation binding approvals", () => { beforeEach(() => { sessionBindingState.reset(); __testing.reset(); + setActivePluginRegistry(createEmptyPluginRegistry()); fs.rmSync(approvalsPath, { force: true }); unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" }); unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" }); @@ -366,6 +369,118 @@ describe("plugin conversation binding approvals", () => { expect(currentBinding?.detachHint).toBe("/codex_detach"); }); + it("notifies the owning plugin when a bind approval is approved", async () => { + const registry = createEmptyPluginRegistry(); + const onResolved = vi.fn(async () => undefined); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-test", + handler: onResolved, + source: "/plugins/callback-test/index.ts", + rootDir: "/plugins/callback-test", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-test", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:callback-test", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const approved = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + expect(onResolved).toHaveBeenCalledWith({ + status: "approved", + binding: expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/callback-test", + conversationId: "channel:callback-test", + }), + decision: "allow-once", + request: { + summary: "Bind this conversation to Codex thread abc.", + detachHint: undefined, + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:callback-test", + }, + }, + }); + }); + + it("notifies the owning plugin when a bind approval is denied", async () => { + const registry = createEmptyPluginRegistry(); + const onResolved = vi.fn(async () => undefined); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-deny", + handler: onResolved, + source: "/plugins/callback-deny/index.ts", + rootDir: "/plugins/callback-deny", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-deny", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + binding: { summary: "Bind this conversation to Codex thread deny." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const denied = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "deny", + senderId: "user-1", + }); + + expect(denied.status).toBe("denied"); + expect(onResolved).toHaveBeenCalledWith({ + status: "denied", + binding: undefined, + decision: "deny", + request: { + summary: "Bind this conversation to Codex thread deny.", + detachHint: undefined, + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + }, + }); + }); + it("returns and detaches only bindings owned by the requesting plugin root", async () => { const request = await requestPluginConversationBinding({ pluginId: "codex", diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index 4b5cb0671da..283e6c3d71f 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -2,15 +2,20 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { + createConversationBindingRecord, + resolveConversationBindingRecord, + unbindConversationBindingRecord, +} from "../bindings/records.js"; import { expandHomePrefix } from "../infra/home-dir.js"; import { writeJsonAtomic } from "../infra/json-files.js"; -import { - getSessionBindingService, - type ConversationRef, -} from "../infra/outbound/session-binding-service.js"; +import { type ConversationRef } from "../infra/outbound/session-binding-service.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { getActivePluginRegistry } from "./runtime.js"; import type { PluginConversationBinding, + PluginConversationBindingResolvedEvent, + PluginConversationBindingResolutionDecision, PluginConversationBindingRequestParams, PluginConversationBindingRequestResult, } from "./types.js"; @@ -26,7 +31,9 @@ const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [ "openclaw-codex-app-server:thread:", ] as const; -type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny"; +// Runtime plugin conversation bindings are approval-driven and distinct from +// configured channel bindings compiled from config. +type PluginBindingApprovalDecision = PluginConversationBindingResolutionDecision; type PluginBindingApprovalEntry = { pluginRoot: string; @@ -87,7 +94,7 @@ type PluginBindingResolveResult = status: "approved"; binding: PluginConversationBinding; request: PendingPluginBindingRequest; - decision: PluginBindingApprovalDecision; + decision: Exclude; } | { status: "denied"; @@ -423,7 +430,7 @@ async function bindConversationNow(params: { accountId: ref.accountId, conversationId: ref.conversationId, }); - const record = await getSessionBindingService().bind({ + const record = await createConversationBindingRecord({ targetSessionKey, targetKind: "session", conversation: ref, @@ -574,7 +581,7 @@ export async function requestPluginConversationBinding(params: { }): Promise { const conversation = normalizeConversation(params.conversation); const ref = toConversationRef(conversation); - const existing = getSessionBindingService().resolveByConversation(ref); + const existing = resolveConversationBindingRecord(ref); const existingPluginBinding = toPluginConversationBinding(existing); const existingLegacyPluginBinding = isLegacyPluginBindingRecord({ record: existing, @@ -665,9 +672,7 @@ export async function getCurrentPluginConversationBinding(params: { pluginRoot: string; conversation: PluginBindingConversation; }): Promise { - const record = getSessionBindingService().resolveByConversation( - toConversationRef(params.conversation), - ); + const record = resolveConversationBindingRecord(toConversationRef(params.conversation)); const binding = toPluginConversationBinding(record); if (!binding || binding.pluginRoot !== params.pluginRoot) { return null; @@ -684,12 +689,12 @@ export async function detachPluginConversationBinding(params: { conversation: PluginBindingConversation; }): Promise<{ removed: boolean }> { const ref = toConversationRef(params.conversation); - const record = getSessionBindingService().resolveByConversation(ref); + const record = resolveConversationBindingRecord(ref); const binding = toPluginConversationBinding(record); if (!binding || binding.pluginRoot !== params.pluginRoot) { return { removed: false }; } - await getSessionBindingService().unbind({ + await unbindConversationBindingRecord({ bindingId: binding.bindingId, reason: "plugin-detach", }); @@ -717,6 +722,11 @@ export async function resolvePluginConversationBindingApproval(params: { } pendingRequests.delete(params.approvalId); if (params.decision === "deny") { + await notifyPluginConversationBindingResolved({ + status: "denied", + decision: "deny", + request, + }); log.info( `plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, ); @@ -745,6 +755,12 @@ export async function resolvePluginConversationBindingApproval(params: { log.info( `plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, ); + await notifyPluginConversationBindingResolved({ + status: "approved", + binding, + decision: params.decision, + request, + }); return { status: "approved", binding, @@ -753,6 +769,42 @@ export async function resolvePluginConversationBindingApproval(params: { }; } +async function notifyPluginConversationBindingResolved(params: { + status: "approved" | "denied"; + binding?: PluginConversationBinding; + decision: PluginConversationBindingResolutionDecision; + request: PendingPluginBindingRequest; +}): Promise { + const registrations = getActivePluginRegistry()?.conversationBindingResolvedHandlers ?? []; + for (const registration of registrations) { + if (registration.pluginId !== params.request.pluginId) { + continue; + } + const registeredRoot = registration.pluginRoot?.trim(); + if (registeredRoot && registeredRoot !== params.request.pluginRoot) { + continue; + } + try { + const event: PluginConversationBindingResolvedEvent = { + status: params.status, + binding: params.binding, + decision: params.decision, + request: { + summary: params.request.summary, + detachHint: params.request.detachHint, + requestedBySenderId: params.request.requestedBySenderId, + conversation: params.request.conversation, + }, + }; + await registration.handler(event); + } catch (error) { + log.warn( + `plugin binding resolved callback failed plugin=${registration.pluginId} root=${registration.pluginRoot ?? ""}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string { if (params.status === "expired") { return "That plugin bind approval expired. Retry the bind command."; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 4c863c3bdf4..3e89c8462b5 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -28,6 +28,7 @@ import type { OpenClawPluginChannelRegistration, OpenClawPluginCliRegistrar, OpenClawPluginCommandDefinition, + PluginConversationBindingResolvedEvent, OpenClawPluginHttpRouteAuth, OpenClawPluginHttpRouteMatch, OpenClawPluginHttpRouteHandler, @@ -147,6 +148,15 @@ export type PluginCommandRegistration = { rootDir?: string; }; +export type PluginConversationBindingResolvedHandlerRegistration = { + pluginId: string; + pluginName?: string; + pluginRoot?: string; + handler: (event: PluginConversationBindingResolvedEvent) => void | Promise; + source: string; + rootDir?: string; +}; + export type PluginRecord = { id: string; name: string; @@ -199,6 +209,7 @@ export type PluginRegistry = { cliRegistrars: PluginCliRegistration[]; services: PluginServiceRegistration[]; commands: PluginCommandRegistration[]; + conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[]; diagnostics: PluginDiagnostic[]; }; @@ -247,6 +258,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }; } @@ -829,6 +841,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } as TypedPluginHookRegistration); }; + const registerConversationBindingResolvedHandler = ( + record: PluginRecord, + handler: (event: PluginConversationBindingResolvedEvent) => void | Promise, + ) => { + registry.conversationBindingResolvedHandlers.push({ + pluginId: record.id, + pluginName: record.name, + pluginRoot: record.rootDir, + handler, + source: record.source, + rootDir: record.rootDir, + }); + }; + const normalizeLogger = (logger: PluginLogger): PluginLogger => ({ info: logger.info, warn: logger.warn, @@ -942,6 +968,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } } : () => {}, + onConversationBindingResolved: + registrationMode === "full" + ? (handler) => registerConversationBindingResolvedHandler(record, handler) + : () => {}, registerCommand: registrationMode === "full" ? (command) => registerCommand(record, command) : () => {}, registerContextEngine: (id, factory) => { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6deb59669f1..a96913360be 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -940,6 +940,8 @@ export type PluginConversationBindingRequestParams = { detachHint?: string; }; +export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny"; + export type PluginConversationBinding = { bindingId: string; pluginId: string; @@ -970,6 +972,24 @@ export type PluginConversationBindingRequestResult = message: string; }; +export type PluginConversationBindingResolvedEvent = { + status: "approved" | "denied"; + binding?: PluginConversationBinding; + decision: PluginConversationBindingResolutionDecision; + request: { + summary?: string; + detachHint?: string; + requestedBySenderId?: string; + conversation: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; + }; + }; +}; + /** * Result returned by a plugin command handler. */ @@ -1256,6 +1276,9 @@ export type OpenClawPluginApi = { registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void; registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; + onConversationBindingResolved: ( + handler: (event: PluginConversationBindingResolvedEvent) => void | Promise, + ) => void; /** * Register a custom command that bypasses the LLM agent. * Plugin commands are processed before built-in commands and before agent invocation. diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 6ecf718f895..d2ebbc45933 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); diff --git a/test/helpers/extensions/plugin-api.ts b/test/helpers/extensions/plugin-api.ts index bb94c326ee8..ee1e97178a8 100644 --- a/test/helpers/extensions/plugin-api.ts +++ b/test/helpers/extensions/plugin-api.ts @@ -20,6 +20,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerImageGenerationProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, + onConversationBindingResolved() {}, registerCommand() {}, registerContextEngine() {}, resolvePath(input: string) { From a724bbce1a63ab361f5f306f747fb4c3ac08bbc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:35:21 -0700 Subject: [PATCH 107/393] feat: add bundled Chutes extension (#49136) * refactor: generalize bundled provider discovery seams * feat: land chutes extension via plugin-owned auth (#41416) (thanks @Veightor) --- CHANGELOG.md | 1 + extensions/chutes/index.ts | 184 +++++ extensions/chutes/onboard.ts | 67 ++ extensions/chutes/openclaw.plugin.json | 39 ++ extensions/chutes/package.json | 12 + extensions/chutes/provider-catalog.ts | 21 + src/agents/chutes-models.test.ts | 320 +++++++++ src/agents/chutes-models.ts | 639 ++++++++++++++++++ src/agents/model-auth-markers.test.ts | 2 + src/agents/model-auth-markers.ts | 10 + .../models-config.providers.chutes.test.ts | 212 ++++++ src/agents/models-config.providers.ts | 77 +++ src/commands/auth-choice.apply.oauth.ts | 89 +-- src/commands/auth-choice.test.ts | 6 +- src/plugin-sdk/provider-auth.ts | 3 + src/plugin-sdk/provider-models.ts | 8 + src/plugins/config-state.test.ts | 5 + src/plugins/config-state.ts | 6 +- .../contracts/discovery.contract.test.ts | 79 +++ src/plugins/contracts/registry.ts | 144 ++-- src/plugins/loader.test.ts | 38 ++ src/plugins/loader.ts | 1 + src/plugins/manifest-registry.test.ts | 2 + src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 3 + src/plugins/provider-catalog.test.ts | 5 + src/plugins/provider-discovery.test.ts | 6 + src/plugins/provider-discovery.ts | 11 + src/plugins/providers.test.ts | 9 + src/plugins/providers.ts | 14 +- src/plugins/types.ts | 12 + 31 files changed, 1856 insertions(+), 171 deletions(-) create mode 100644 extensions/chutes/index.ts create mode 100644 extensions/chutes/onboard.ts create mode 100644 extensions/chutes/openclaw.plugin.json create mode 100644 extensions/chutes/package.json create mode 100644 extensions/chutes/provider-catalog.ts create mode 100644 src/agents/chutes-models.test.ts create mode 100644 src/agents/chutes-models.ts create mode 100644 src/agents/models-config.providers.chutes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bd8a223eb..d3836e10c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. - Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. +- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. ### Breaking diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts new file mode 100644 index 00000000000..a61cd4ec93f --- /dev/null +++ b/extensions/chutes/index.ts @@ -0,0 +1,184 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { + buildOauthProviderAuthResult, + createProviderApiKeyAuthMethod, + loginChutes, + resolveOAuthApiKeyMarker, + type ProviderAuthContext, + type ProviderAuthResult, +} from "openclaw/plugin-sdk/provider-auth"; +import { + CHUTES_DEFAULT_MODEL_REF, + applyChutesApiKeyConfig, + applyChutesProviderConfig, +} from "./onboard.js"; +import { buildChutesProvider } from "./provider-catalog.js"; + +const PROVIDER_ID = "chutes"; + +async function runChutesOAuth(ctx: ProviderAuthContext): Promise { + const isRemote = ctx.isRemote; + const redirectUri = + process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback"; + const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke"; + const clientId = + process.env.CHUTES_CLIENT_ID?.trim() || + String( + await ctx.prompter.text({ + message: "Enter Chutes OAuth client id", + placeholder: "cid_xxx", + validate: (value: string) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; + + await ctx.prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + "", + `Redirect URI: ${redirectUri}`, + ].join("\n") + : [ + "Browser will open for Chutes authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "", + `Redirect URI: ${redirectUri}`, + ].join("\n"), + "Chutes OAuth", + ); + + const progress = ctx.prompter.progress("Starting Chutes OAuth…"); + try { + const { onAuth, onPrompt } = ctx.oauth.createVpsAwareHandlers({ + isRemote, + prompter: ctx.prompter, + runtime: ctx.runtime, + spin: progress, + openUrl: ctx.openUrl, + localBrowserMessage: "Complete sign-in in browser…", + }); + + const creds = await loginChutes({ + app: { + clientId, + clientSecret, + redirectUri, + scopes: scopes.split(/\s+/).filter(Boolean), + }, + manual: isRemote, + onAuth, + onPrompt, + onProgress: (message) => progress.update(message), + }); + + progress.stop("Chutes OAuth complete"); + + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: CHUTES_DEFAULT_MODEL_REF, + access: creds.access, + refresh: creds.refresh, + expires: creds.expires, + email: typeof creds.email === "string" ? creds.email : undefined, + credentialExtra: { + clientId, + ...("accountId" in creds && typeof creds.accountId === "string" + ? { accountId: creds.accountId } + : {}), + }, + configPatch: applyChutesProviderConfig({}), + notes: [ + "Chutes OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Redirect URI: ${redirectUri}`, + ], + }); + } catch (err) { + progress.stop("Chutes OAuth failed"); + await ctx.prompter.note( + [ + "Trouble with OAuth?", + "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", + `Verify the OAuth app redirect URI includes: ${redirectUri}`, + "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", + ].join("\n"), + "OAuth help", + ); + throw err; + } +} + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "Chutes Provider", + description: "Bundled Chutes.ai provider plugin", + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Chutes", + docsPath: "/providers/chutes", + envVars: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + auth: [ + { + id: "oauth", + label: "Chutes OAuth", + hint: "Browser sign-in", + kind: "oauth", + wizard: { + choiceId: "chutes", + choiceLabel: "Chutes (OAuth)", + choiceHint: "Browser sign-in", + groupId: "chutes", + groupLabel: "Chutes", + groupHint: "OAuth + API key", + }, + run: async (ctx) => await runChutesOAuth(ctx), + }, + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Chutes API key", + hint: "Open-source models including Llama, DeepSeek, and more", + optionKey: "chutesApiKey", + flagName: "--chutes-api-key", + envVar: "CHUTES_API_KEY", + promptMessage: "Enter Chutes API key", + noteTitle: "Chutes", + noteMessage: [ + "Chutes provides access to leading open-source models including Llama, DeepSeek, and more.", + "Get your API key at: https://chutes.ai/settings/api-keys", + ].join("\n"), + defaultModel: CHUTES_DEFAULT_MODEL_REF, + expectedProviders: ["chutes"], + applyConfig: (cfg) => applyChutesApiKeyConfig(cfg), + wizard: { + choiceId: "chutes-api-key", + choiceLabel: "Chutes API key", + groupId: "chutes", + groupLabel: "Chutes", + groupHint: "OAuth + API key", + }, + }), + ], + catalog: { + order: "profile", + run: async (ctx) => { + const { apiKey, discoveryApiKey } = ctx.resolveProviderAuth(PROVIDER_ID, { + oauthMarker: resolveOAuthApiKeyMarker(PROVIDER_ID), + }); + if (!apiKey) { + return null; + } + return { + provider: { + ...(await buildChutesProvider(discoveryApiKey)), + apiKey, + }, + }; + }, + }, + }); + }, +}); diff --git a/extensions/chutes/onboard.ts b/extensions/chutes/onboard.ts new file mode 100644 index 00000000000..f51914c3ca8 --- /dev/null +++ b/extensions/chutes/onboard.ts @@ -0,0 +1,67 @@ +import { + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, + buildChutesModelDefinition, +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; + +export { CHUTES_DEFAULT_MODEL_REF }; + +/** + * Apply Chutes provider configuration without changing the default model. + * Registers all catalog models and sets provider aliases (chutes-fast, etc.). + */ +export function applyChutesProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + for (const m of CHUTES_MODEL_CATALOG) { + models[`chutes/${m.id}`] = { + ...models[`chutes/${m.id}`], + }; + } + + models["chutes-fast"] = { alias: "chutes/zai-org/GLM-4.7-FP8" }; + models["chutes-vision"] = { alias: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506" }; + models["chutes-pro"] = { alias: "chutes/deepseek-ai/DeepSeek-V3.2-TEE" }; + + const chutesModels = CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition); + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "chutes", + api: "openai-completions", + baseUrl: CHUTES_BASE_URL, + catalogModels: chutesModels, + }); +} + +/** + * Apply Chutes provider configuration AND set Chutes as the default model. + */ +export function applyChutesConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyChutesProviderConfig(cfg); + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + primary: CHUTES_DEFAULT_MODEL_REF, + fallbacks: ["chutes/deepseek-ai/DeepSeek-V3.2-TEE", "chutes/Qwen/Qwen3-32B"], + }, + imageModel: { + primary: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506", + fallbacks: ["chutes/chutesai/Mistral-Small-3.1-24B-Instruct-2503"], + }, + }, + }, + }; +} + +export function applyChutesApiKeyConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyChutesProviderConfig(cfg), CHUTES_DEFAULT_MODEL_REF); +} diff --git a/extensions/chutes/openclaw.plugin.json b/extensions/chutes/openclaw.plugin.json new file mode 100644 index 00000000000..26174f31b3a --- /dev/null +++ b/extensions/chutes/openclaw.plugin.json @@ -0,0 +1,39 @@ +{ + "id": "chutes", + "enabledByDefault": true, + "providers": ["chutes"], + "providerAuthEnvVars": { + "chutes": ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"] + }, + "providerAuthChoices": [ + { + "provider": "chutes", + "method": "oauth", + "choiceId": "chutes", + "choiceLabel": "Chutes (OAuth)", + "choiceHint": "Browser sign-in", + "groupId": "chutes", + "groupLabel": "Chutes", + "groupHint": "OAuth + API key" + }, + { + "provider": "chutes", + "method": "api-key", + "choiceId": "chutes-api-key", + "choiceLabel": "Chutes API key", + "choiceHint": "Open-source models including Llama, DeepSeek, and more", + "groupId": "chutes", + "groupLabel": "Chutes", + "groupHint": "OAuth + API key", + "optionKey": "chutesApiKey", + "cliFlag": "--chutes-api-key", + "cliOption": "--chutes-api-key ", + "cliDescription": "Chutes API key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/chutes/package.json b/extensions/chutes/package.json new file mode 100644 index 00000000000..be860172a27 --- /dev/null +++ b/extensions/chutes/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/chutes-provider", + "version": "2026.3.17", + "private": true, + "description": "OpenClaw Chutes.ai provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/chutes/provider-catalog.ts b/extensions/chutes/provider-catalog.ts new file mode 100644 index 00000000000..1467f405dde --- /dev/null +++ b/extensions/chutes/provider-catalog.ts @@ -0,0 +1,21 @@ +import { + CHUTES_BASE_URL, + CHUTES_MODEL_CATALOG, + buildChutesModelDefinition, + discoverChutesModels, + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; + +/** + * Build the Chutes provider with dynamic model discovery. + * Falls back to the static catalog on failure. + * Accepts an optional access token (API key or OAuth access token) for authenticated discovery. + */ +export async function buildChutesProvider(accessToken?: string): Promise { + const models = await discoverChutesModels(accessToken); + return { + baseUrl: CHUTES_BASE_URL, + api: "openai-completions", + models: models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + }; +} diff --git a/src/agents/chutes-models.test.ts b/src/agents/chutes-models.test.ts new file mode 100644 index 00000000000..66bafde50ad --- /dev/null +++ b/src/agents/chutes-models.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + buildChutesModelDefinition, + CHUTES_MODEL_CATALOG, + discoverChutesModels, + clearChutesModelCache, +} from "./chutes-models.js"; + +describe("chutes-models", () => { + beforeEach(() => { + clearChutesModelCache(); + }); + + it("buildChutesModelDefinition returns config with required fields", () => { + const entry = CHUTES_MODEL_CATALOG[0]; + const def = buildChutesModelDefinition(entry); + expect(def.id).toBe(entry.id); + expect(def.name).toBe(entry.name); + expect(def.reasoning).toBe(entry.reasoning); + expect(def.input).toEqual(entry.input); + expect(def.cost).toEqual(entry.cost); + expect(def.contextWindow).toBe(entry.contextWindow); + expect(def.maxTokens).toBe(entry.maxTokens); + expect(def.compat?.supportsUsageInStreaming).toBe(false); + }); + + it("discoverChutesModels returns static catalog when accessToken is empty", async () => { + const models = await discoverChutesModels(""); + expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); + expect(models.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + }); + + it("discoverChutesModels returns static catalog in test env by default", async () => { + const models = await discoverChutesModels("test-token"); + expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); + expect(models[0]?.id).toBe("Qwen/Qwen3-32B"); + }); + + it("discoverChutesModels correctly maps API response when not in test env", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: "zai-org/GLM-4.7-TEE" }, + { + id: "new-provider/new-model-r1", + supported_features: ["reasoning"], + input_modalities: ["text", "image"], + context_length: 200000, + max_output_length: 16384, + pricing: { prompt: 0.1, completion: 0.2 }, + }, + { id: "new-provider/simple-model" }, + ], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const models = await discoverChutesModels("test-token-real-fetch"); + expect(models.length).toBeGreaterThan(0); + if (models.length === 3) { + expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE"); + expect(models[1]?.reasoning).toBe(true); + expect(models[1]?.compat?.supportsUsageInStreaming).toBe(false); + } + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("discoverChutesModels retries without auth on 401", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockImplementation((url, init) => { + if (init?.headers?.Authorization === "Bearer test-token-error") { + // pragma: allowlist secret + return Promise.resolve({ + ok: false, + status: 401, + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [ + { + id: "Qwen/Qwen3-32B", + name: "Qwen/Qwen3-32B", + supported_features: ["reasoning"], + input_modalities: ["text"], + context_length: 40960, + max_output_length: 40960, + pricing: { prompt: 0.08, completion: 0.24 }, + }, + { + id: "unsloth/Mistral-Nemo-Instruct-2407", + name: "unsloth/Mistral-Nemo-Instruct-2407", + input_modalities: ["text"], + context_length: 131072, + max_output_length: 131072, + pricing: { prompt: 0.02, completion: 0.04 }, + }, + { + id: "deepseek-ai/DeepSeek-V3-0324-TEE", + name: "deepseek-ai/DeepSeek-V3-0324-TEE", + supported_features: ["reasoning"], + input_modalities: ["text"], + context_length: 131072, + max_output_length: 65536, + pricing: { prompt: 0.28, completion: 0.42 }, + }, + ], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const models = await discoverChutesModels("test-token-error"); + expect(models.length).toBeGreaterThan(0); + expect(mockFetch).toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("caches fallback static catalog for non-OK responses", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const first = await discoverChutesModels("chutes-fallback-token"); + const second = await discoverChutesModels("chutes-fallback-token"); + expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("scopes discovery cache by access token", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization; + if (auth === "Bearer chutes-token-a") { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "private/model-a" }], + }), + }); + } + if (auth === "Bearer chutes-token-b") { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "private/model-b" }], + }), + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "public/model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const modelsA = await discoverChutesModels("chutes-token-a"); + const modelsB = await discoverChutesModels("chutes-token-b"); + const modelsASecond = await discoverChutesModels("chutes-token-a"); + expect(modelsA[0]?.id).toBe("private/model-a"); + expect(modelsB[0]?.id).toBe("private/model-b"); + expect(modelsASecond[0]?.id).toBe("private/model-a"); + // One request per token, then cache hit for the repeated token-a call. + expect(mockFetch).toHaveBeenCalledTimes(2); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("evicts oldest token entries when cache reaches max size", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization ?? ""; + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: auth ? `${auth}-model` : "public-model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + for (let i = 0; i < 150; i += 1) { + await discoverChutesModels(`cache-token-${i}`); + } + + // The oldest key should have been evicted once we exceed the cap. + await discoverChutesModels("cache-token-0"); + expect(mockFetch).toHaveBeenCalledTimes(151); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("prunes expired token cache entries during subsequent discovery", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization ?? ""; + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: auth ? `${auth}-model` : "public-model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await discoverChutesModels("token-a"); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await discoverChutesModels("token-b"); + await discoverChutesModels("token-a"); + expect(mockFetch).toHaveBeenCalledTimes(3); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("does not cache 401 fallback under the failed token key", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + if (init?.headers?.Authorization === "Bearer failed-token") { + return Promise.resolve({ + ok: false, + status: 401, + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "public/model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await discoverChutesModels("failed-token"); + await discoverChutesModels("failed-token"); + // Two calls each perform: authenticated attempt (401) + public fallback. + expect(mockFetch).toHaveBeenCalledTimes(4); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/agents/chutes-models.ts b/src/agents/chutes-models.ts new file mode 100644 index 00000000000..585723e3adc --- /dev/null +++ b/src/agents/chutes-models.ts @@ -0,0 +1,639 @@ +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("chutes-models"); + +/** Chutes.ai OpenAI-compatible API base URL. */ +export const CHUTES_BASE_URL = "https://llm.chutes.ai/v1"; + +export const CHUTES_DEFAULT_MODEL_ID = "zai-org/GLM-4.7-TEE"; +export const CHUTES_DEFAULT_MODEL_REF = `chutes/${CHUTES_DEFAULT_MODEL_ID}`; + +/** Default cost for Chutes models (actual cost varies by model and compute). */ +export const CHUTES_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +/** Default context window and max tokens for discovered models. */ +const CHUTES_DEFAULT_CONTEXT_WINDOW = 128000; +const CHUTES_DEFAULT_MAX_TOKENS = 4096; + +/** + * Static catalog of popular Chutes models. + * Used as a fallback and for initial onboarding allowlisting. + */ +export const CHUTES_MODEL_CATALOG: ModelDefinitionConfig[] = [ + { + id: "Qwen/Qwen3-32B", + name: "Qwen/Qwen3-32B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.08, output: 0.24, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Mistral-Nemo-Instruct-2407", + name: "unsloth/Mistral-Nemo-Instruct-2407", + reasoning: false, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.02, output: 0.04, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3-0324-TEE", + name: "deepseek-ai/DeepSeek-V3-0324-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.25, output: 1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + name: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.08, output: 0.55, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "openai/gpt-oss-120b-TEE", + name: "openai/gpt-oss-120b-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.05, output: 0.45, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "chutesai/Mistral-Small-3.1-24B-Instruct-2503", + name: "chutesai/Mistral-Small-3.1-24B-Instruct-2503", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.2-TEE", + name: "deepseek-ai/DeepSeek-V3.2-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.28, output: 0.42, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.7-TEE", + name: "zai-org/GLM-4.7-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K2.5-TEE", + name: "moonshotai/Kimi-K2.5-TEE", + reasoning: true, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 65535, + cost: { input: 0.45, output: 2.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-27b-it", + name: "unsloth/gemma-3-27b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 65536, + cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "XiaomiMiMo/MiMo-V2-Flash-TEE", + name: "XiaomiMiMo/MiMo-V2-Flash-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.09, output: 0.29, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "chutesai/Mistral-Small-3.2-24B-Instruct-2506", + name: "chutesai/Mistral-Small-3.2-24B-Instruct-2506", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.06, output: 0.18, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-R1-0528-TEE", + name: "deepseek-ai/DeepSeek-R1-0528-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.45, output: 2.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5-TEE", + name: "zai-org/GLM-5-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.95, output: 3.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.1-TEE", + name: "deepseek-ai/DeepSeek-V3.1-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.2, output: 0.8, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE", + name: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.23, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-4b-it", + name: "unsloth/gemma-3-4b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 96000, + maxTokens: 96000, + cost: { input: 0.01, output: 0.03, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "MiniMaxAI/MiniMax-M2.5-TEE", + name: "MiniMaxAI/MiniMax-M2.5-TEE", + reasoning: true, + input: ["text"], + contextWindow: 196608, + maxTokens: 65536, + cost: { input: 0.3, output: 1.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "tngtech/DeepSeek-TNG-R1T2-Chimera", + name: "tngtech/DeepSeek-TNG-R1T2-Chimera", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 163840, + cost: { input: 0.25, output: 0.85, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-Coder-Next-TEE", + name: "Qwen/Qwen3-Coder-Next-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.12, output: 0.75, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/Hermes-4-405B-FP8-TEE", + name: "NousResearch/Hermes-4-405B-FP8-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3", + name: "deepseek-ai/DeepSeek-V3", + reasoning: false, + input: ["text"], + contextWindow: 163840, + maxTokens: 163840, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "openai/gpt-oss-20b", + name: "openai/gpt-oss-20b", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Llama-3.2-3B-Instruct", + name: "unsloth/Llama-3.2-3B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Mistral-Small-24B-Instruct-2501", + name: "unsloth/Mistral-Small-24B-Instruct-2501", + reasoning: false, + input: ["text", "image"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.07, output: 0.3, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.7-FP8", + name: "zai-org/GLM-4.7-FP8", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6-TEE", + name: "zai-org/GLM-4.6-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65536, + cost: { input: 0.4, output: 1.7, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3.5-397B-A17B-TEE", + name: "Qwen/Qwen3.5-397B-A17B-TEE", + reasoning: true, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.55, output: 3.5, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-72B-Instruct", + name: "Qwen/Qwen2.5-72B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/DeepHermes-3-Mistral-24B-Preview", + name: "NousResearch/DeepHermes-3-Mistral-24B-Preview", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.02, output: 0.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-Next-80B-A3B-Instruct", + name: "Qwen/Qwen3-Next-80B-A3B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.1, output: 0.8, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6-FP8", + name: "zai-org/GLM-4.6-FP8", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-235B-A22B-Thinking-2507", + name: "Qwen/Qwen3-235B-A22B-Thinking-2507", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.11, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + name: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "tngtech/R1T2-Chimera-Speed", + name: "tngtech/R1T2-Chimera-Speed", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.22, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6V", + name: "zai-org/GLM-4.6V", + reasoning: true, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-VL-32B-Instruct", + name: "Qwen/Qwen2.5-VL-32B-Instruct", + reasoning: false, + input: ["text", "image"], + contextWindow: 16384, + maxTokens: 16384, + cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-VL-235B-A22B-Instruct", + name: "Qwen/Qwen3-VL-235B-A22B-Instruct", + reasoning: false, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-14B", + name: "Qwen/Qwen3-14B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-Coder-32B-Instruct", + name: "Qwen/Qwen2.5-Coder-32B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-30B-A3B", + name: "Qwen/Qwen3-30B-A3B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.06, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-12b-it", + name: "unsloth/gemma-3-12b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Llama-3.2-1B-Instruct", + name: "unsloth/Llama-3.2-1B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE", + name: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/Hermes-4-14B", + name: "NousResearch/Hermes-4-14B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.01, output: 0.05, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3Guard-Gen-0.6B", + name: "Qwen/Qwen3Guard-Gen-0.6B", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "rednote-hilab/dots.ocr", + name: "rednote-hilab/dots.ocr", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, +]; + +export function buildChutesModelDefinition( + model: (typeof CHUTES_MODEL_CATALOG)[number], +): ModelDefinitionConfig { + return { + ...model, + // Avoid usage-only streaming chunks that can break OpenAI-compatible parsers. + compat: { + supportsUsageInStreaming: false, + }, + }; +} + +interface ChutesModelEntry { + id: string; + name?: string; + supported_features?: string[]; + input_modalities?: string[]; + context_length?: number; + max_output_length?: number; + pricing?: { + prompt?: number; + completion?: number; + }; + [key: string]: unknown; +} + +interface OpenAIListModelsResponse { + data?: ChutesModelEntry[]; +} + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const CACHE_MAX_ENTRIES = 100; + +interface CacheEntry { + models: ModelDefinitionConfig[]; + time: number; +} + +// Keyed by trimmed access token (empty string = unauthenticated). +// Prevents a public unauthenticated result from suppressing authenticated +// discovery for users with token-scoped private models. +const modelCache = new Map(); + +/** @internal - For testing only */ +export function clearChutesModelCache() { + modelCache.clear(); +} + +function pruneExpiredCacheEntries(now: number = Date.now()): void { + for (const [key, entry] of modelCache.entries()) { + if (now - entry.time >= CACHE_TTL) { + modelCache.delete(key); + } + } +} + +/** Cache the result for the given token key and return it. */ +function cacheAndReturn( + tokenKey: string, + models: ModelDefinitionConfig[], +): ModelDefinitionConfig[] { + const now = Date.now(); + pruneExpiredCacheEntries(now); + + if (!modelCache.has(tokenKey) && modelCache.size >= CACHE_MAX_ENTRIES) { + const oldest = modelCache.keys().next(); + if (!oldest.done) { + modelCache.delete(oldest.value); + } + } + + modelCache.set(tokenKey, { models, time: now }); + return models; +} + +/** + * Discover models from Chutes.ai API with fallback to static catalog. + * Mimics the logic in Chutes init script. + */ +export async function discoverChutesModels(accessToken?: string): Promise { + const trimmedKey = accessToken?.trim() ?? ""; + + // Return cached result for this token if still within TTL + const now = Date.now(); + pruneExpiredCacheEntries(now); + const cached = modelCache.get(trimmedKey); + if (cached) { + return cached.models; + } + + // Skip API discovery in test environment + if (process.env.NODE_ENV === "test" || process.env.VITEST === "true") { + return CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition); + } + + // If auth fails the result comes from the public endpoint — cache it under "" + // so the original token key stays uncached and retries cleanly next TTL window. + let effectiveKey = trimmedKey; + const staticCatalog = () => + cacheAndReturn(effectiveKey, CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition)); + + const headers: Record = {}; + if (trimmedKey) { + headers.Authorization = `Bearer ${trimmedKey}`; + } + + try { + let response = await fetch(`${CHUTES_BASE_URL}/models`, { + signal: AbortSignal.timeout(10_000), + headers, + }); + + if (response.status === 401 && trimmedKey) { + // Auth failed — fall back to the public (unauthenticated) endpoint. + // Cache the result under "" so the bad token stays uncached and can + // be retried with a refreshed credential after the TTL expires. + effectiveKey = ""; + response = await fetch(`${CHUTES_BASE_URL}/models`, { + signal: AbortSignal.timeout(10_000), + }); + } + + if (!response.ok) { + // Only log if it's not a common auth/overload error that we have a fallback for + if (response.status !== 401 && response.status !== 503) { + log.warn(`GET /v1/models failed: HTTP ${response.status}, using static catalog`); + } + return staticCatalog(); + } + + const body = (await response.json()) as OpenAIListModelsResponse; + const data = body?.data; + if (!Array.isArray(data) || data.length === 0) { + log.warn("No models in response, using static catalog"); + return staticCatalog(); + } + + const seen = new Set(); + const models: ModelDefinitionConfig[] = []; + + for (const entry of data) { + const id = typeof entry?.id === "string" ? entry.id.trim() : ""; + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + + const isReasoning = + entry.supported_features?.includes("reasoning") || + id.toLowerCase().includes("r1") || + id.toLowerCase().includes("thinking") || + id.toLowerCase().includes("reason") || + id.toLowerCase().includes("tee"); + + const input: Array<"text" | "image"> = (entry.input_modalities || ["text"]).filter( + (i): i is "text" | "image" => i === "text" || i === "image", + ); + + models.push({ + id, + name: id, // Mirror init.sh: uses id for name + reasoning: isReasoning, + input, + cost: { + input: entry.pricing?.prompt || 0, + output: entry.pricing?.completion || 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: entry.context_length || CHUTES_DEFAULT_CONTEXT_WINDOW, + maxTokens: entry.max_output_length || CHUTES_DEFAULT_MAX_TOKENS, + compat: { + supportsUsageInStreaming: false, + }, + }); + } + + return cacheAndReturn( + effectiveKey, + models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + ); + } catch (error) { + log.warn(`Discovery failed: ${String(error)}, using static catalog`); + return staticCatalog(); + } +} diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index b90f1fd9ffa..960a648675b 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -4,12 +4,14 @@ import { isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER, + resolveOAuthApiKeyMarker, } from "./model-auth-markers.js"; describe("model auth markers", () => { it("recognizes explicit non-secret markers", () => { expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true); expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true); + expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true); expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); }); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 8a890d3a694..37ec67ba2c0 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -2,6 +2,7 @@ import type { SecretRefSource } from "../config/types.secrets.js"; import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; export const MINIMAX_OAUTH_MARKER = "minimax-oauth"; +export const OAUTH_API_KEY_MARKER_PREFIX = "oauth:"; export const QWEN_OAUTH_MARKER = "qwen-oauth"; export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local"; @@ -41,6 +42,14 @@ export function isKnownEnvApiKeyMarker(value: string): boolean { return KNOWN_ENV_API_KEY_MARKERS.has(trimmed) && !isAwsSdkAuthMarker(trimmed); } +export function resolveOAuthApiKeyMarker(providerId: string): string { + return `${OAUTH_API_KEY_MARKER_PREFIX}${providerId.trim()}`; +} + +export function isOAuthApiKeyMarker(value: string): boolean { + return value.trim().startsWith(OAUTH_API_KEY_MARKER_PREFIX); +} + export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string { return NON_ENV_SECRETREF_MARKER; } @@ -71,6 +80,7 @@ export function isNonSecretApiKeyMarker( const isKnownMarker = trimmed === MINIMAX_OAUTH_MARKER || trimmed === QWEN_OAUTH_MARKER || + isOAuthApiKeyMarker(trimmed) || trimmed === OLLAMA_LOCAL_AUTH_MARKER || trimmed === CUSTOM_LOCAL_AUTH_MARKER || trimmed === NON_ENV_SECRETREF_MARKER || diff --git a/src/agents/models-config.providers.chutes.test.ts b/src/agents/models-config.providers.chutes.test.ts new file mode 100644 index 00000000000..a47ee57fcb3 --- /dev/null +++ b/src/agents/models-config.providers.chutes.test.ts @@ -0,0 +1,212 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { CHUTES_BASE_URL } from "./chutes-models.js"; +import { resolveOAuthApiKeyMarker } from "./model-auth-markers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +const CHUTES_OAUTH_MARKER = resolveOAuthApiKeyMarker("chutes"); +const ORIGINAL_VITEST_ENV = process.env.VITEST; +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; + +describe("chutes implicit provider auth mode", () => { + beforeEach(() => { + process.env.VITEST = "true"; + process.env.NODE_ENV = "test"; + }); + + afterAll(() => { + process.env.VITEST = ORIGINAL_VITEST_ENV; + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + }); + + it("auto-loads bundled chutes discovery for env api keys", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const providers = await resolveImplicitProviders({ + agentDir, + env: { + CHUTES_API_KEY: "env-chutes-api-key", + } as NodeJS.ProcessEnv, + }); + + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("CHUTES_API_KEY"); + }); + + it("keeps api_key-backed chutes profiles on the api-key loader path", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("keeps api_key precedence when oauth profile is inserted first", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:oauth": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("keeps api_key precedence when api_key profile is inserted first", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + "chutes:oauth": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("forwards oauth access token to chutes model discovery", async () => { + // Enable real discovery so fetch is actually called. + const originalVitest = process.env.VITEST; + const originalNodeEnv = process.env.NODE_ENV; + const originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "chutes/private-model" }] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "oauth", + provider: "chutes", + access: "my-chutes-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER); + + const chutesCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("chutes.ai")); + expect(chutesCalls.length).toBeGreaterThan(0); + const request = chutesCalls[0]?.[1] as { headers?: Record } | undefined; + expect(request?.headers?.Authorization).toBe("Bearer my-chutes-access-token"); + } finally { + process.env.VITEST = originalVitest; + process.env.NODE_ENV = originalNodeEnv; + globalThis.fetch = originalFetch; + } + }); + + it("uses CHUTES_OAUTH_MARKER only for oauth-backed chutes profiles", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 19ce478b2f4..af9c3d6e34a 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -617,10 +617,22 @@ type ProviderApiKeyResolver = (provider: string) => { discoveryApiKey?: string; }; +type ProviderAuthResolver = ( + provider: string, + options?: { oauthMarker?: string }, +) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; +}; + type ImplicitProviderContext = ImplicitProviderParams & { authStore: ReturnType; env: NodeJS.ProcessEnv; resolveProviderApiKey: ProviderApiKeyResolver; + resolveProviderAuth: ProviderAuthResolver; }; function mergeImplicitProviderSet( @@ -668,6 +680,8 @@ async function resolvePluginImplicitProviders( env: ctx.env, resolveProviderApiKey: (providerId) => ctx.resolveProviderApiKey(providerId?.trim() || provider.id), + resolveProviderAuth: (providerId, options) => + ctx.resolveProviderAuth(providerId?.trim() || provider.id, options), }); mergeImplicitProviderSet( discovered, @@ -704,11 +718,74 @@ export async function resolveImplicitProviders( discoveryApiKey: fromProfiles?.discoveryApiKey, }; }; + const resolveProviderAuth: ProviderAuthResolver = ( + provider: string, + options?: { oauthMarker?: string }, + ) => { + const envVar = resolveEnvApiKeyVarName(provider, env); + if (envVar) { + return { + apiKey: envVar, + discoveryApiKey: toDiscoveryApiKey(env[envVar]), + mode: "api_key", + source: "env", + }; + } + + const ids = listProfilesForProvider(authStore, provider); + let oauthCandidate: + | { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "oauth"; + source: "profile"; + profileId: string; + } + | undefined; + for (const id of ids) { + const cred = authStore.profiles[id]; + if (!cred) { + continue; + } + if (cred.type === "oauth") { + oauthCandidate ??= { + apiKey: options?.oauthMarker, + discoveryApiKey: toDiscoveryApiKey(cred.access), + mode: "oauth", + source: "profile", + profileId: id, + }; + continue; + } + const resolved = resolveApiKeyFromCredential(cred, env); + if (!resolved) { + continue; + } + return { + apiKey: resolved.apiKey, + discoveryApiKey: resolved.discoveryApiKey, + mode: cred.type, + source: "profile", + profileId: id, + }; + } + if (oauthCandidate) { + return oauthCandidate; + } + + return { + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }; + }; const context: ImplicitProviderContext = { ...params, authStore, env, resolveProviderApiKey, + resolveProviderAuth, }; mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "simple")); diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts index a2a3104e447..1966d2bd8d8 100644 --- a/src/commands/auth-choice.apply.oauth.ts +++ b/src/commands/auth-choice.apply.oauth.ts @@ -1,94 +1,7 @@ -import { applyAuthProfileConfig, writeOAuthCredentials } from "../plugins/provider-auth-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { loginChutes } from "./chutes-oauth.js"; -import { isRemoteEnvironment } from "./oauth-env.js"; -import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { openUrl } from "./onboard-helpers.js"; export async function applyAuthChoiceOAuth( - params: ApplyAuthChoiceParams, + _params: ApplyAuthChoiceParams, ): Promise { - if (params.authChoice === "chutes") { - let nextConfig = params.config; - const isRemote = isRemoteEnvironment(); - const redirectUri = - process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback"; - const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke"; - const clientId = - process.env.CHUTES_CLIENT_ID?.trim() || - String( - await params.prompter.text({ - message: "Enter Chutes OAuth client id", - placeholder: "cid_xxx", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; - - await params.prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, paste the redirect URL back here.", - "", - `Redirect URI: ${redirectUri}`, - ].join("\n") - : [ - "Browser will open for Chutes authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "", - `Redirect URI: ${redirectUri}`, - ].join("\n"), - "Chutes OAuth", - ); - - const spin = params.prompter.progress("Starting OAuth flow…"); - try { - const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ - isRemote, - prompter: params.prompter, - runtime: params.runtime, - spin, - openUrl, - localBrowserMessage: "Complete sign-in in browser…", - }); - - const creds = await loginChutes({ - app: { - clientId, - clientSecret, - redirectUri, - scopes: scopes.split(/\s+/).filter(Boolean), - }, - manual: isRemote, - onAuth, - onPrompt, - onProgress: (msg) => spin.update(msg), - }); - - spin.stop("Chutes OAuth complete"); - const profileId = await writeOAuthCredentials("chutes", creds, params.agentDir); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "chutes", - mode: "oauth", - }); - } catch (err) { - spin.stop("Chutes OAuth failed"); - params.runtime.error(String(err)); - await params.prompter.note( - [ - "Trouble with OAuth?", - "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", - `Verify the OAuth app redirect URI includes: ${redirectUri}`, - "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", - ].join("\n"), - "OAuth help", - ); - } - return { config: nextConfig }; - } - return null; } diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 8d6316e9acb..dd270a6d3d2 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import anthropicPlugin from "../../extensions/anthropic/index.js"; +import chutesPlugin from "../../extensions/chutes/index.js"; import cloudflareAiGatewayPlugin from "../../extensions/cloudflare-ai-gateway/index.js"; import googlePlugin from "../../extensions/google/index.js"; import huggingfacePlugin from "../../extensions/huggingface/index.js"; @@ -84,6 +85,7 @@ type StoredAuthProfile = { function createDefaultProviderPlugins() { return registerProviderPlugins( anthropicPlugin, + chutesPlugin, cloudflareAiGatewayPlugin, googlePlugin, huggingfacePlugin, @@ -1345,7 +1347,7 @@ describe("applyAuthChoice", () => { const runtime = createExitThrowingRuntime(); const text: WizardPrompter["text"] = vi.fn(async (params) => { - if (params.message === "Paste the redirect URL") { + if (params.message.startsWith("Paste the redirect URL")) { const runtimeLog = runtime.log as ReturnType; const lastLog = runtimeLog.mock.calls.at(-1)?.[0]; const urlLine = typeof lastLog === "string" ? lastLog : String(lastLog ?? ""); @@ -1370,7 +1372,7 @@ describe("applyAuthChoice", () => { expect(text).toHaveBeenCalledWith( expect.objectContaining({ - message: "Paste the redirect URL", + message: expect.stringContaining("Paste the redirect URL"), }), ); expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({ diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index d30dd81f7d6..84373befb88 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -3,6 +3,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SecretInput } from "../config/types.secrets.js"; export type { ProviderAuthResult } from "../plugins/types.js"; +export type { ProviderAuthContext } from "../plugins/types.js"; export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; @@ -16,6 +17,7 @@ export { } from "../agents/auth-profiles.js"; export { MINIMAX_OAUTH_MARKER, + resolveOAuthApiKeyMarker, resolveNonEnvSecretRefApiKeyMarker, } from "../agents/model-auth-markers.js"; export { @@ -35,6 +37,7 @@ export { } from "../plugins/provider-auth-token.js"; export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { loginChutes } from "../commands/chutes-oauth.js"; export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef } from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index c2a68c7b579..996135c9011 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -81,6 +81,14 @@ export { buildHuggingfaceModelDefinition, } from "../agents/huggingface-models.js"; export { discoverKilocodeModels } from "../agents/kilocode-models.js"; +export { + buildChutesModelDefinition, + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_ID, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, + discoverChutesModels, +} from "../agents/chutes-models.js"; export { resolveOllamaApiBase } from "../agents/ollama-models.js"; export { buildSyntheticModelDefinition, diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 915f647950e..01f2b14cfd7 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -270,4 +270,9 @@ describe("resolveEnableState", () => { const state = resolveEnableState("google", "bundled", normalizePluginsConfig({})); expect(state).toEqual({ enabled: true }); }); + + it("allows bundled plugins to opt into default enablement from manifest metadata", () => { + const state = resolveEnableState("profile-aware", "bundled", normalizePluginsConfig({}), true); + expect(state).toEqual({ enabled: true }); + }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 0dde14a8941..26827e50aa3 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -274,6 +274,7 @@ export function resolveEnableState( id: string, origin: PluginRecord["origin"], config: NormalizedPluginsConfig, + enabledByDefault?: boolean, ): { enabled: boolean; reason?: string } { if (!config.enabled) { return { enabled: false, reason: "plugins disabled" }; @@ -298,7 +299,7 @@ export function resolveEnableState( if (entry?.enabled === true) { return { enabled: true }; } - if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { + if (origin === "bundled" && (enabledByDefault ?? BUNDLED_ENABLED_BY_DEFAULT.has(id))) { return { enabled: true }; } if (origin === "bundled") { @@ -331,8 +332,9 @@ export function resolveEffectiveEnableState(params: { origin: PluginRecord["origin"]; config: NormalizedPluginsConfig; rootConfig?: OpenClawConfig; + enabledByDefault?: boolean; }): { enabled: boolean; reason?: string } { - const base = resolveEnableState(params.id, params.origin, params.config); + const base = resolveEnableState(params.id, params.origin, params.config, params.enabledByDefault); if ( !base.enabled && base.reason === "bundled (disabled by default)" && diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 9035391ac9e..47e098a2baf 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -131,12 +131,30 @@ function runCatalog(params: { provider: Awaited>; env?: NodeJS.ProcessEnv; resolveProviderApiKey?: () => { apiKey: string | undefined }; + resolveProviderAuth?: ( + providerId?: string, + options?: { oauthMarker?: string }, + ) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; + }; }) { return runProviderCatalog({ provider: params.provider, config: {}, env: params.env ?? ({} as NodeJS.ProcessEnv), resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })), + resolveProviderAuth: + params.resolveProviderAuth ?? + ((_, options) => ({ + apiKey: options?.oauthMarker, + discoveryApiKey: undefined, + mode: options?.oauthMarker ? "oauth" : "none", + source: options?.oauthMarker ? "profile" : "none", + })), }); } @@ -249,6 +267,12 @@ describe("provider discovery contract", () => { }, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toMatchObject({ provider: { @@ -274,6 +298,12 @@ describe("provider discovery contract", () => { config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toBeNull(); expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true }); @@ -297,6 +327,12 @@ describe("provider discovery contract", () => { apiKey: "VLLM_API_KEY", discoveryApiKey: "env-vllm-key", }), + resolveProviderAuth: () => ({ + apiKey: "VLLM_API_KEY", + discoveryApiKey: "env-vllm-key", + mode: "api_key", + source: "env", + }), }), ).resolves.toEqual({ provider: { @@ -329,6 +365,12 @@ describe("provider discovery contract", () => { apiKey: "SGLANG_API_KEY", discoveryApiKey: "env-sglang-key", }), + resolveProviderAuth: () => ({ + apiKey: "SGLANG_API_KEY", + discoveryApiKey: "env-sglang-key", + mode: "api_key", + source: "env", + }), }), ).resolves.toEqual({ provider: { @@ -352,6 +394,12 @@ describe("provider discovery contract", () => { MINIMAX_API_KEY: "minimax-key", } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: "minimax-key" }), + resolveProviderAuth: () => ({ + apiKey: "minimax-key", + discoveryApiKey: undefined, + mode: "api_key", + source: "env", + }), }), ).resolves.toMatchObject({ provider: { @@ -391,6 +439,13 @@ describe("provider discovery contract", () => { config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: "minimax-oauth", + discoveryApiKey: "access-token", + mode: "oauth", + source: "profile", + profileId: "minimax-portal:default", + }), }), ).resolves.toMatchObject({ provider: { @@ -420,6 +475,12 @@ describe("provider discovery contract", () => { }, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toMatchObject({ provider: { @@ -447,6 +508,12 @@ describe("provider discovery contract", () => { MODELSTUDIO_API_KEY: "modelstudio-key", } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }), + resolveProviderAuth: () => ({ + apiKey: "modelstudio-key", + discoveryApiKey: undefined, + mode: "api_key", + source: "env", + }), }), ).resolves.toMatchObject({ provider: { @@ -468,6 +535,12 @@ describe("provider discovery contract", () => { config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toBeNull(); }); @@ -504,6 +577,12 @@ describe("provider discovery contract", () => { CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value", } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toEqual({ provider: { diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index f33571b8008..e4b6cf1059a 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,41 +1,18 @@ -import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js"; import anthropicPlugin from "../../../extensions/anthropic/index.js"; import bravePlugin from "../../../extensions/brave/index.js"; -import byteplusPlugin from "../../../extensions/byteplus/index.js"; -import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js"; -import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js"; import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; import firecrawlPlugin from "../../../extensions/firecrawl/index.js"; -import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; import googlePlugin from "../../../extensions/google/index.js"; -import huggingFacePlugin from "../../../extensions/huggingface/index.js"; -import kilocodePlugin from "../../../extensions/kilocode/index.js"; -import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js"; import microsoftPlugin from "../../../extensions/microsoft/index.js"; import minimaxPlugin from "../../../extensions/minimax/index.js"; import mistralPlugin from "../../../extensions/mistral/index.js"; -import modelStudioPlugin from "../../../extensions/modelstudio/index.js"; import moonshotPlugin from "../../../extensions/moonshot/index.js"; -import nvidiaPlugin from "../../../extensions/nvidia/index.js"; -import ollamaPlugin from "../../../extensions/ollama/index.js"; import openAIPlugin from "../../../extensions/openai/index.js"; -import opencodeGoPlugin from "../../../extensions/opencode-go/index.js"; -import opencodePlugin from "../../../extensions/opencode/index.js"; -import openRouterPlugin from "../../../extensions/openrouter/index.js"; import perplexityPlugin from "../../../extensions/perplexity/index.js"; -import qianfanPlugin from "../../../extensions/qianfan/index.js"; -import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js"; -import sglangPlugin from "../../../extensions/sglang/index.js"; -import syntheticPlugin from "../../../extensions/synthetic/index.js"; -import togetherPlugin from "../../../extensions/together/index.js"; -import venicePlugin from "../../../extensions/venice/index.js"; -import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js"; -import vllmPlugin from "../../../extensions/vllm/index.js"; -import volcenginePlugin from "../../../extensions/volcengine/index.js"; import xaiPlugin from "../../../extensions/xai/index.js"; -import xiaomiPlugin from "../../../extensions/xiaomi/index.js"; import zaiPlugin from "../../../extensions/zai/index.js"; import { createCapturedPluginRegistration } from "../captured-registration.js"; +import { resolvePluginProviders } from "../providers.js"; import type { ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, @@ -75,41 +52,6 @@ type PluginRegistrationContractEntry = { toolNames: string[]; }; -const bundledProviderPlugins: RegistrablePlugin[] = [ - amazonBedrockPlugin, - anthropicPlugin, - byteplusPlugin, - cloudflareAiGatewayPlugin, - copilotProxyPlugin, - githubCopilotPlugin, - googlePlugin, - huggingFacePlugin, - kilocodePlugin, - kimiCodingPlugin, - minimaxPlugin, - mistralPlugin, - modelStudioPlugin, - moonshotPlugin, - nvidiaPlugin, - ollamaPlugin, - opencodeGoPlugin, - opencodePlugin, - openAIPlugin, - openRouterPlugin, - qianfanPlugin, - qwenPortalPlugin, - sglangPlugin, - syntheticPlugin, - togetherPlugin, - venicePlugin, - vercelAiGatewayPlugin, - vllmPlugin, - volcenginePlugin, - xaiPlugin, - xiaomiPlugin, - zaiPlugin, -]; - const bundledWebSearchPlugins: Array = [ { ...bravePlugin, credentialValue: "BSA-test" }, { ...firecrawlPlugin, credentialValue: "fc-test" }, @@ -153,10 +95,30 @@ function buildCapabilityContractRegistry(params: { } export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({ - plugins: bundledProviderPlugins, - select: (captured) => captured.providers, + plugins: [], + select: () => [], }); +const loadedBundledProviderRegistry: ProviderContractEntry[] = resolvePluginProviders({ + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + cache: false, + activate: false, +}) + .filter((provider): provider is ProviderPlugin & { pluginId: string } => + Boolean(provider.pluginId), + ) + .map((provider) => ({ + pluginId: provider.pluginId, + provider, + })); + +providerContractRegistry.splice( + 0, + providerContractRegistry.length, + ...loadedBundledProviderRegistry, +); + export const uniqueProviderContractProviders: ProviderPlugin[] = [ ...new Map(providerContractRegistry.map((entry) => [entry.provider.id, entry.provider])).values(), ]; @@ -234,7 +196,6 @@ export const imageGenerationProviderContractRegistry: ImageGenerationProviderCon const bundledPluginRegistrationList = [ ...new Map( [ - ...bundledProviderPlugins, ...bundledSpeechPlugins, ...bundledMediaUnderstandingPlugins, ...bundledImageGenerationPlugins, @@ -243,18 +204,47 @@ const bundledPluginRegistrationList = [ ).values(), ]; -export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = - bundledPluginRegistrationList.map((plugin) => { - const captured = captureRegistrations(plugin); - return { - pluginId: plugin.id, - providerIds: captured.providers.map((provider) => provider.id), - speechProviderIds: captured.speechProviders.map((provider) => provider.id), - mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( - (provider) => provider.id, - ), - imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id), - webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), - toolNames: captured.tools.map((tool) => tool.name), - }; - }); +export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = [ + ...new Map( + providerContractRegistry.map((entry) => [ + entry.pluginId, + { + pluginId: entry.pluginId, + providerIds: providerContractRegistry + .filter((candidate) => candidate.pluginId === entry.pluginId) + .map((candidate) => candidate.provider.id), + speechProviderIds: [] as string[], + mediaUnderstandingProviderIds: [] as string[], + imageGenerationProviderIds: [] as string[], + webSearchProviderIds: [] as string[], + toolNames: [] as string[], + }, + ]), + ).values(), +]; + +for (const plugin of bundledPluginRegistrationList) { + const captured = captureRegistrations(plugin); + const existing = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === plugin.id); + const next = { + pluginId: plugin.id, + providerIds: captured.providers.map((provider) => provider.id), + speechProviderIds: captured.speechProviders.map((provider) => provider.id), + mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( + (provider) => provider.id, + ), + imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id), + webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), + toolNames: captured.tools.map((tool) => tool.name), + }; + if (!existing) { + pluginRegistrationContractRegistry.push(next); + continue; + } + existing.providerIds = next.providerIds.length > 0 ? next.providerIds : existing.providerIds; + existing.speechProviderIds = next.speechProviderIds; + existing.mediaUnderstandingProviderIds = next.mediaUnderstandingProviderIds; + existing.imageGenerationProviderIds = next.imageGenerationProviderIds; + existing.webSearchProviderIds = next.webSearchProviderIds; + existing.toolNames = next.toolNames; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 691cd7e7607..60673ffa67f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2877,6 +2877,44 @@ module.exports = { } }); + it("loads bundled plugins when manifest metadata opts into default enablement", () => { + const bundledDir = makeTempDir(); + const plugin = writePlugin({ + id: "profile-aware", + body: `module.exports = { id: "profile-aware", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "profile-aware", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: bundledDir, + config: { + plugins: { + enabled: true, + }, + }, + }); + + const bundledPlugin = registry.plugins.find((entry) => entry.id === "profile-aware"); + expect(bundledPlugin?.origin).toBe("bundled"); + expect(bundledPlugin?.status).toBe("loaded"); + }); + it("keeps scoped and unscoped plugin ids distinct", () => { useNoBundledPlugins(); const scoped = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 8d064d477c3..251a08beb4e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1035,6 +1035,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi origin: candidate.origin, config: normalized, rootConfig: cfg, + enabledByDefault: manifestRecord.enabledByDefault, }); const entry = normalized.entries[pluginId]; const record = createPluginRecord({ diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index c60e5444443..14a571c9250 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -203,6 +203,7 @@ describe("loadPluginManifestRegistry", () => { const dir = makeTempDir(); writeManifest(dir, { id: "openai", + enabledByDefault: true, providers: ["openai", "openai-codex"], providerAuthEnvVars: { openai: ["OPENAI_API_KEY"], @@ -227,6 +228,7 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ openai: ["OPENAI_API_KEY"], }); + expect(registry.plugins[0]?.enabledByDefault).toBe(true); expect(registry.plugins[0]?.providerAuthChoices).toEqual([ { provider: "openai", diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 7a5c10d67f0..eea801a72ea 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -35,6 +35,7 @@ export type PluginManifestRecord = { name?: string; description?: string; version?: string; + enabledByDefault?: boolean; format?: PluginFormat; bundleFormat?: PluginBundleFormat; bundleCapabilities?: string[]; @@ -154,6 +155,7 @@ function buildRecord(params: { description: normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription, version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion, + enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined, format: params.candidate.format ?? "openclaw", bundleFormat: params.candidate.bundleFormat, kind: params.manifest.kind, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index dd8615d7350..a75a2a9b6ab 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -11,6 +11,7 @@ export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; export type PluginManifest = { id: string; configSchema: Record; + enabledByDefault?: boolean; kind?: PluginKind; channels?: string[]; providers?: string[]; @@ -180,6 +181,7 @@ export function loadPluginManifest( } const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined; + const enabledByDefault = raw.enabledByDefault === true; const name = typeof raw.name === "string" ? raw.name.trim() : undefined; const description = typeof raw.description === "string" ? raw.description.trim() : undefined; const version = typeof raw.version === "string" ? raw.version.trim() : undefined; @@ -199,6 +201,7 @@ export function loadPluginManifest( manifest: { id, configSchema, + ...(enabledByDefault ? { enabledByDefault } : {}), kind, channels, providers, diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index a49e82a98e6..e7dcf201226 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -27,6 +27,11 @@ function createCatalogContext(params: { resolveProviderApiKey: (providerId) => ({ apiKey: providerId ? params.apiKeys?.[providerId] : undefined, }), + resolveProviderAuth: (providerId) => ({ + apiKey: providerId ? params.apiKeys?.[providerId] : undefined, + mode: providerId && params.apiKeys?.[providerId] ? "api_key" : "none", + source: providerId && params.apiKeys?.[providerId] ? "env" : "none", + }), }; } diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 4952961062b..30efba6081b 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -120,6 +120,12 @@ describe("runProviderCatalog", () => { config: {}, env: {}, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }); expect(result).toEqual({ diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index e249bf6e45a..b3816e2faf1 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -81,6 +81,16 @@ export function runProviderCatalog(params: { apiKey: string | undefined; discoveryApiKey?: string; }; + resolveProviderAuth: ( + providerId?: string, + options?: { oauthMarker?: string }, + ) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; + }; }) { return resolveProviderCatalogHook(params.provider)?.run({ config: params.config, @@ -88,5 +98,6 @@ export function runProviderCatalog(params: { workspaceDir: params.workspaceDir, env: params.env, resolveProviderApiKey: params.resolveProviderApiKey, + resolveProviderAuth: params.resolveProviderAuth, }); } diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index b8da58c1921..ff804babb43 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -70,6 +70,11 @@ describe("resolvePluginProviders", () => { config: expect.objectContaining({ plugins: expect.objectContaining({ allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]), + entries: expect.objectContaining({ + google: { enabled: true }, + kilocode: { enabled: true }, + moonshot: { enabled: true }, + }), }), }), cache: false, @@ -89,6 +94,10 @@ describe("resolvePluginProviders", () => { plugins: expect.objectContaining({ enabled: true, allow: expect.arrayContaining(["google", "moonshot"]), + entries: expect.objectContaining({ + google: { enabled: true }, + moonshot: { enabled: true }, + }), }), }), cache: false, diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 45c84986e6c..e966e9d4128 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,6 +1,9 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; +import { + withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat, +} from "./bundled-compat.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -165,13 +168,20 @@ export function resolvePluginProviders(params: { pluginIds: bundledProviderCompatPluginIds, }) : params.config; - const config = params.bundledProviderVitestCompat + const maybeVitestCompat = params.bundledProviderVitestCompat ? withBundledProviderVitestCompat({ config: maybeAllowlistCompat, pluginIds: bundledProviderCompatPluginIds, env: params.env, }) : maybeAllowlistCompat; + const config = + params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat + ? withBundledPluginEnablementCompat({ + config: maybeVitestCompat, + pluginIds: bundledProviderCompatPluginIds, + }) + : maybeVitestCompat; const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index a96913360be..ae5b2d116b4 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -246,6 +246,18 @@ export type ProviderCatalogContext = { apiKey: string | undefined; discoveryApiKey?: string; }; + resolveProviderAuth: ( + providerId?: string, + options?: { + oauthMarker?: string; + }, + ) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; + }; }; export type ProviderCatalogResult = From 4d8106eece6e3b8c9e6bb116d98e287bf8585e16 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:35:02 -0700 Subject: [PATCH 108/393] docs(security): clarify wildcard Control UI origins --- CHANGELOG.md | 1 + docs/gateway/security/index.md | 1 + docs/web/control-ui.md | 3 +++ src/config/schema.help.ts | 2 +- src/security/audit.ts | 4 ++-- 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3836e10c9a..276dc3526c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,7 @@ Docs: https://docs.openclaw.ai - Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. - Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path. +- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing. - Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index c3c1ee2eb1b..8193eb5ca2c 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -355,6 +355,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses. - Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts). - For non-loopback Control UI deployments, `gateway.controlUi.allowedOrigins` is required by default. +- `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy, not a hardened default. Avoid it outside tightly controlled local testing. - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy. - Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 3ad5feb80b4..9e156bb339a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -250,6 +250,9 @@ Notes: - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. - Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` explicitly (full origins). This includes remote dev setups. +- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled + local testing. It means allow any browser origin, not “match whatever host I am + using.” - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode, but it is a dangerous security mode. diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e6b02e2ec3c..82c07b176fb 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -385,7 +385,7 @@ export const FIELD_HELP: Record = { "gateway.controlUi.root": "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "gateway.controlUi.allowedOrigins": - "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.", + 'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.', "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": "DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.", "gateway.controlUi.allowInsecureAuth": diff --git a/src/security/audit.ts b/src/security/audit.ts index ba809a1714c..8eacad4649e 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -512,9 +512,9 @@ function collectGatewayConfigFindings( severity: exposed ? "critical" : "warn", title: "Control UI allowed origins contains wildcard", detail: - 'gateway.controlUi.allowedOrigins includes "*" which effectively disables origin allowlisting for Control UI/WebChat requests.', + 'gateway.controlUi.allowedOrigins includes "*" which means allow any browser origin for Control UI/WebChat requests. This disables origin allowlisting and should be treated as an intentional allow-all policy.', remediation: - "Replace wildcard origins with explicit trusted origins (for example https://control.example.com).", + 'Replace wildcard origins with explicit trusted origins (for example https://control.example.com). Do not use "*" outside tightly controlled local testing.', }); } if (dangerouslyAllowHostHeaderOriginFallback) { From 4b125762f6bbaf5a33fb29ca2bdfaad53d9f1b43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:33:17 -0700 Subject: [PATCH 109/393] refactor: clean extension api boundaries --- extensions/bluebubbles/api.ts | 1 + extensions/bluebubbles/index.ts | 3 + extensions/discord/api.ts | 13 ++ extensions/discord/index.ts | 3 + extensions/discord/runtime-api.ts | 14 ++ extensions/discord/setup-entry.ts | 2 + .../discord/src/monitor/native-command.ts | 2 +- extensions/discord/src/send.components.ts | 2 +- .../discord/src/send.emojis-stickers.ts | 2 +- extensions/discord/src/send.outbound.ts | 2 +- extensions/discord/src/send.shared.ts | 2 +- extensions/discord/src/setup-core.ts | 1 + extensions/discord/src/setup-surface.ts | 1 + extensions/discord/src/shared.ts | 10 +- extensions/feishu/api.ts | 4 + extensions/feishu/index.ts | 2 + extensions/googlechat/api.ts | 2 + extensions/googlechat/index.ts | 3 + extensions/imessage/api.ts | 4 + extensions/imessage/index.ts | 3 + extensions/imessage/runtime-api.ts | 3 + extensions/imessage/setup-entry.ts | 2 + extensions/imessage/src/setup-core.ts | 1 + extensions/imessage/src/setup-surface.ts | 2 +- extensions/imessage/src/shared.ts | 15 +- extensions/irc/api.ts | 2 + extensions/irc/index.ts | 3 + extensions/line/api.ts | 2 + extensions/line/index.ts | 3 + extensions/line/setup-entry.ts | 2 + extensions/matrix/api.ts | 2 + extensions/matrix/index.ts | 3 + extensions/mattermost/api.ts | 1 + extensions/mattermost/index.ts | 3 + extensions/msteams/api.ts | 2 + extensions/msteams/index.ts | 3 + extensions/nextcloud-talk/api.ts | 1 + extensions/nextcloud-talk/index.ts | 3 + extensions/nostr/api.ts | 1 + extensions/nostr/index.ts | 3 + extensions/signal/api.ts | 2 + extensions/signal/index.ts | 3 + extensions/signal/runtime-api.ts | 1 + extensions/signal/setup-entry.ts | 2 + extensions/signal/src/setup-core.ts | 2 +- extensions/signal/src/setup-surface.ts | 2 +- extensions/signal/src/shared.ts | 9 +- extensions/slack/api.ts | 12 ++ extensions/slack/index.ts | 3 + extensions/slack/runtime-api.ts | 4 + extensions/slack/setup-entry.ts | 2 + extensions/slack/src/send.ts | 2 +- extensions/slack/src/setup-core.ts | 2 +- extensions/slack/src/setup-surface.ts | 2 +- extensions/slack/src/shared.ts | 12 +- extensions/synology-chat/api.ts | 1 + extensions/synology-chat/index.ts | 3 + extensions/telegram/api.ts | 18 +++ extensions/telegram/index.ts | 3 + extensions/telegram/runtime-api.ts | 7 + extensions/telegram/setup-entry.ts | 2 + .../bot.create-telegram-bot.test-harness.ts | 2 +- .../telegram/src/bot/delivery.replies.ts | 2 +- extensions/telegram/src/send.test-harness.ts | 2 +- extensions/telegram/src/send.ts | 2 +- extensions/telegram/src/setup-core.ts | 2 +- extensions/telegram/src/shared.ts | 11 +- extensions/tlon/api.ts | 2 + extensions/tlon/index.ts | 3 + extensions/twitch/api.ts | 1 + extensions/whatsapp/api.ts | 2 + extensions/whatsapp/index.ts | 3 + extensions/whatsapp/login-qr-api.ts | 1 + extensions/whatsapp/runtime-api.ts | 9 ++ extensions/whatsapp/setup-entry.ts | 2 + extensions/whatsapp/src/channel.runtime.ts | 23 +-- extensions/whatsapp/src/setup-surface.ts | 2 +- extensions/whatsapp/src/shared.ts | 27 ++-- extensions/zalo/api.ts | 2 + extensions/zalo/index.ts | 3 + extensions/zalouser/api.ts | 2 + extensions/zalouser/index.ts | 3 + package.json | 32 +++++ scripts/lib/plugin-sdk-entrypoints.json | 8 ++ ....triggers.trigger-handling.test-harness.ts | 2 +- .../reply/commands-subagents.test-mocks.ts | 2 +- .../discord/handle-action.guild-admin.ts | 2 +- .../plugins/actions/discord/handle-action.ts | 2 +- src/channels/plugins/bundled.ts | 57 ++++---- src/channels/plugins/contracts/registry.ts | 8 +- src/channels/plugins/target-parsing.ts | 4 +- src/commands/channels.mock-harness.ts | 5 +- src/commands/doctor.e2e-harness.ts | 2 +- src/cron/isolated-agent.test-setup.ts | 2 +- src/gateway/test-helpers.mocks.ts | 2 +- src/infra/heartbeat-runner.test-harness.ts | 9 +- src/infra/heartbeat-runner.test-utils.ts | 3 +- .../message-action-runner.test-helpers.ts | 6 +- src/infra/outbound/targets.shared-test.ts | 4 +- src/plugin-sdk/account-resolution.ts | 14 +- src/plugin-sdk/bluebubbles.ts | 4 +- .../channel-import-guardrails.test.ts | 71 +++++++++- src/plugin-sdk/discord-core.ts | 3 + src/plugin-sdk/discord-send.ts | 2 +- src/plugin-sdk/discord.ts | 41 +++--- src/plugin-sdk/feishu.ts | 6 +- src/plugin-sdk/googlechat.ts | 4 +- src/plugin-sdk/imessage-core.ts | 14 ++ src/plugin-sdk/imessage-targets.ts | 2 +- src/plugin-sdk/imessage.ts | 2 +- src/plugin-sdk/irc.ts | 4 +- src/plugin-sdk/line.ts | 4 +- src/plugin-sdk/matrix.ts | 4 +- src/plugin-sdk/msteams.ts | 4 +- src/plugin-sdk/nostr.ts | 2 +- src/plugin-sdk/setup-tools.ts | 4 + src/plugin-sdk/signal-core.ts | 10 ++ src/plugin-sdk/signal.ts | 13 +- src/plugin-sdk/slack-core.ts | 4 + src/plugin-sdk/slack-message-actions.ts | 3 +- src/plugin-sdk/slack-targets.ts | 2 +- src/plugin-sdk/slack.ts | 27 ++-- src/plugin-sdk/synology-chat.ts | 2 +- src/plugin-sdk/telegram-core.ts | 5 + src/plugin-sdk/telegram.ts | 48 +++---- src/plugin-sdk/tlon.ts | 4 +- src/plugin-sdk/twitch.ts | 5 +- src/plugin-sdk/web-media.ts | 2 +- src/plugin-sdk/whatsapp-core.ts | 18 +++ src/plugin-sdk/whatsapp.ts | 28 ++-- src/plugin-sdk/zalo.ts | 4 +- src/plugin-sdk/zalouser.ts | 4 +- .../runtime/runtime-discord-ops.runtime.ts | 14 +- src/plugins/runtime/runtime-discord.ts | 4 +- src/plugins/runtime/runtime-imessage.ts | 8 +- src/plugins/runtime/runtime-media.ts | 2 +- src/plugins/runtime/runtime-signal.ts | 8 +- .../runtime/runtime-slack-ops.runtime.ts | 12 +- .../runtime/runtime-telegram-ops.runtime.ts | 8 +- src/plugins/runtime/runtime-telegram.ts | 8 +- .../runtime/runtime-whatsapp-login-tool.ts | 2 +- .../runtime/runtime-whatsapp-login.runtime.ts | 2 +- .../runtime-whatsapp-outbound.runtime.ts | 2 +- src/plugins/runtime/runtime-whatsapp.ts | 8 +- src/plugins/runtime/types-channel.ts | 134 +++++++++--------- src/plugins/runtime/types-core.ts | 2 +- src/test-utils/imessage-test-plugin.ts | 2 +- 147 files changed, 699 insertions(+), 364 deletions(-) create mode 100644 extensions/bluebubbles/api.ts create mode 100644 extensions/discord/api.ts create mode 100644 extensions/discord/runtime-api.ts create mode 100644 extensions/feishu/api.ts create mode 100644 extensions/googlechat/api.ts create mode 100644 extensions/imessage/api.ts create mode 100644 extensions/imessage/runtime-api.ts create mode 100644 extensions/irc/api.ts create mode 100644 extensions/line/api.ts create mode 100644 extensions/matrix/api.ts create mode 100644 extensions/mattermost/api.ts create mode 100644 extensions/msteams/api.ts create mode 100644 extensions/nextcloud-talk/api.ts create mode 100644 extensions/nostr/api.ts create mode 100644 extensions/signal/api.ts create mode 100644 extensions/signal/runtime-api.ts create mode 100644 extensions/slack/api.ts create mode 100644 extensions/slack/runtime-api.ts create mode 100644 extensions/synology-chat/api.ts create mode 100644 extensions/telegram/api.ts create mode 100644 extensions/telegram/runtime-api.ts create mode 100644 extensions/tlon/api.ts create mode 100644 extensions/twitch/api.ts create mode 100644 extensions/whatsapp/api.ts create mode 100644 extensions/whatsapp/login-qr-api.ts create mode 100644 extensions/whatsapp/runtime-api.ts create mode 100644 extensions/zalo/api.ts create mode 100644 extensions/zalouser/api.ts create mode 100644 src/plugin-sdk/discord-core.ts create mode 100644 src/plugin-sdk/imessage-core.ts create mode 100644 src/plugin-sdk/setup-tools.ts create mode 100644 src/plugin-sdk/signal-core.ts create mode 100644 src/plugin-sdk/slack-core.ts create mode 100644 src/plugin-sdk/telegram-core.ts create mode 100644 src/plugin-sdk/whatsapp-core.ts diff --git a/extensions/bluebubbles/api.ts b/extensions/bluebubbles/api.ts new file mode 100644 index 00000000000..414efd5531e --- /dev/null +++ b/extensions/bluebubbles/api.ts @@ -0,0 +1 @@ +export { bluebubblesPlugin } from "./src/channel.js"; diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts index 778cbd8ae8f..3e4ab2b4ff8 100644 --- a/extensions/bluebubbles/index.ts +++ b/extensions/bluebubbles/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { bluebubblesPlugin } from "./src/channel.js"; import { setBlueBubblesRuntime } from "./src/runtime.js"; +export { bluebubblesPlugin } from "./src/channel.js"; +export { setBlueBubblesRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "bluebubbles", name: "BlueBubbles", diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts new file mode 100644 index 00000000000..37235190586 --- /dev/null +++ b/extensions/discord/api.ts @@ -0,0 +1,13 @@ +export * from "./runtime-api.js"; +export * from "./src/account-inspect.js"; +export * from "./src/accounts.js"; +export * from "./src/actions/handle-action.guild-admin.js"; +export * from "./src/actions/handle-action.js"; +export * from "./src/components.js"; +export * from "./src/normalize.js"; +export * from "./src/pluralkit.js"; +export * from "./src/session-key-normalization.js"; +export * from "./src/status-issues.js"; +export * from "./src/targets.js"; +export type { DiscordSendComponents, DiscordSendEmbeds } from "./src/send.shared.js"; +export type { DiscordSendResult } from "./src/send.types.js"; diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 7c179623e23..6d3c754edb4 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -3,6 +3,9 @@ import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js"; +export { discordPlugin } from "./src/channel.js"; +export { setDiscordRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "discord", name: "Discord", diff --git a/extensions/discord/runtime-api.ts b/extensions/discord/runtime-api.ts new file mode 100644 index 00000000000..3850143c4ef --- /dev/null +++ b/extensions/discord/runtime-api.ts @@ -0,0 +1,14 @@ +export * from "./src/audit.js"; +export * from "./src/channel-actions.js"; +export * from "./src/directory-live.js"; +export * from "./src/monitor.js"; +export * from "./src/monitor/gateway-plugin.js"; +export * from "./src/monitor/gateway-registry.js"; +export * from "./src/monitor/presence-cache.js"; +export * from "./src/monitor/thread-bindings.js"; +export * from "./src/monitor/thread-bindings.manager.js"; +export * from "./src/monitor/timeouts.js"; +export * from "./src/probe.js"; +export * from "./src/resolve-channels.js"; +export * from "./src/resolve-users.js"; +export * from "./src/send.js"; diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index e59c812ff4b..e2c4689ed39 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { discordSetupPlugin } from "./src/channel.setup.js"; +export { discordSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(discordSetupPlugin); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 1876acbde0a..a292f6d4bfc 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -55,7 +55,7 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems } from "openclaw/plugin-sdk/text-runtime"; import { withTimeout } from "openclaw/plugin-sdk/text-runtime"; -import { loadWebMedia } from "../../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 9c641ba596d..de620fc2250 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -7,7 +7,7 @@ import { import { ChannelType, Routes } from "discord-api-types/v10"; import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; import { diff --git a/extensions/discord/src/send.emojis-stickers.ts b/extensions/discord/src/send.emojis-stickers.ts index 601b8372e74..a1f005c49fb 100644 --- a/extensions/discord/src/send.emojis-stickers.ts +++ b/extensions/discord/src/send.emojis-stickers.ts @@ -1,5 +1,5 @@ import { Routes } from "discord-api-types/v10"; -import { loadWebMediaRaw } from "../../whatsapp/src/media.js"; +import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { normalizeEmojiName, resolveDiscordRest } from "./send.shared.js"; import type { DiscordEmojiUpload, DiscordReactOpts, DiscordStickerUpload } from "./send.types.js"; import { DISCORD_MAX_EMOJI_BYTES, DISCORD_MAX_STICKER_BYTES } from "./send.types.js"; diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index cc71330b192..e0a674d557e 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -14,7 +14,7 @@ import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime"; import type { PollInput } from "openclaw/plugin-sdk/media-runtime"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; -import { loadWebMediaRaw } from "../../whatsapp/src/media.js"; +import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; import { diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index 115356510d2..d3b248a3c6f 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -18,7 +18,7 @@ import { type PollInput, } from "openclaw/plugin-sdk/media-runtime"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { chunkDiscordTextWithMode } from "./chunk.js"; import { createDiscordClient, resolveDiscordRest } from "./client.js"; diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 46afa1fcbbd..3bf1878b1a1 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -17,6 +17,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 7d0ded88dc0..0505681ce0f 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -6,6 +6,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { normalizeDiscordSlug } from "./monitor/allow-list.js"; import { diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 2e611fb08a2..7558b27394a 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -3,7 +3,12 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord-core"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, @@ -40,7 +45,6 @@ export const discordConfigBase = createScopedChannelConfigBase, "configSchema">["configSchema"]; setup: NonNullable["setup"]>; }): Pick< ChannelPlugin, @@ -72,7 +76,7 @@ export function createDiscordPluginBase(params: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.discord"] }, - configSchema: params.configSchema, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), config: { ...discordConfigBase, isConfigured: (account) => Boolean(account.token?.trim()), diff --git a/extensions/feishu/api.ts b/extensions/feishu/api.ts new file mode 100644 index 00000000000..df5c00a43e3 --- /dev/null +++ b/extensions/feishu/api.ts @@ -0,0 +1,4 @@ +export * from "./src/conversation-id.js"; +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; +export * from "./src/thread-bindings.js"; diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 27f90f66479..837ffa28671 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -9,6 +9,8 @@ import { setFeishuRuntime } from "./src/runtime.js"; import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js"; import { registerFeishuWikiTools } from "./src/wiki.js"; +export { feishuPlugin } from "./src/channel.js"; +export { setFeishuRuntime } from "./src/runtime.js"; export { monitorFeishuProvider } from "./src/monitor.js"; export { sendMessageFeishu, diff --git a/extensions/googlechat/api.ts b/extensions/googlechat/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/googlechat/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/googlechat/index.ts b/extensions/googlechat/index.ts index 414bfc9557b..850bd4b6a87 100644 --- a/extensions/googlechat/index.ts +++ b/extensions/googlechat/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { googlechatPlugin } from "./src/channel.js"; import { setGoogleChatRuntime } from "./src/runtime.js"; +export { googlechatPlugin } from "./src/channel.js"; +export { setGoogleChatRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "googlechat", name: "Google Chat", diff --git a/extensions/imessage/api.ts b/extensions/imessage/api.ts new file mode 100644 index 00000000000..ede4a8061ec --- /dev/null +++ b/extensions/imessage/api.ts @@ -0,0 +1,4 @@ +export * from "./runtime-api.js"; +export * from "./src/accounts.js"; +export * from "./src/target-parsing-helpers.js"; +export * from "./src/targets.js"; diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts index aea014f06d4..6ed01ad9da4 100644 --- a/extensions/imessage/index.ts +++ b/extensions/imessage/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { imessagePlugin } from "./src/channel.js"; import { setIMessageRuntime } from "./src/runtime.js"; +export { imessagePlugin } from "./src/channel.js"; +export { setIMessageRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "imessage", name: "iMessage", diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts new file mode 100644 index 00000000000..4f4acfa3328 --- /dev/null +++ b/extensions/imessage/runtime-api.ts @@ -0,0 +1,3 @@ +export * from "./src/monitor.js"; +export * from "./src/probe.js"; +export * from "./src/send.js"; diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts index ed6936ca387..7c4c55967a8 100644 --- a/extensions/imessage/setup-entry.ts +++ b/extensions/imessage/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { imessageSetupPlugin } from "./src/channel.setup.js"; +export { imessageSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(imessageSetupPlugin); diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index f6c71074ca9..57773129ba6 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -14,6 +14,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 94358db1e11..ae6cdb2fcc1 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,5 +1,5 @@ -import { detectBinary } from "openclaw/plugin-sdk/imessage"; import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { detectBinary } from "openclaw/plugin-sdk/setup-tools"; import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js"; import { createIMessageCliPathTextInput, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index e81390dcc8d..301b1848f99 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -1,19 +1,19 @@ -import { - formatTrimmedAllowFromEntries, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, -} from "openclaw/plugin-sdk/channel-config-helpers"; import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { + buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, + formatTrimmedAllowFromEntries, getChatChannelMeta, + IMessageConfigSchema, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, setAccountEnabledInConfigSection, type ChannelPlugin, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/imessage-core"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, @@ -33,7 +33,6 @@ export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ })); export function createIMessagePluginBase(params: { - configSchema: Pick, "configSchema">["configSchema"]; setupWizard?: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -61,7 +60,7 @@ export function createIMessagePluginBase(params: { media: true, }, reload: { configPrefixes: ["channels.imessage"] }, - configSchema: params.configSchema, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), config: { listAccountIds: (cfg) => listIMessageAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), diff --git a/extensions/irc/api.ts b/extensions/irc/api.ts new file mode 100644 index 00000000000..4fae8e966ee --- /dev/null +++ b/extensions/irc/api.ts @@ -0,0 +1,2 @@ +export * from "./src/accounts.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/irc/index.ts b/extensions/irc/index.ts index 5ae8619812d..7a746c551cf 100644 --- a/extensions/irc/index.ts +++ b/extensions/irc/index.ts @@ -3,6 +3,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { ircPlugin } from "./src/channel.js"; import { setIrcRuntime } from "./src/runtime.js"; +export { ircPlugin } from "./src/channel.js"; +export { setIrcRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "irc", name: "IRC", diff --git a/extensions/line/api.ts b/extensions/line/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/line/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/line/index.ts b/extensions/line/index.ts index fabf1c9d5b7..22f2c184e70 100644 --- a/extensions/line/index.ts +++ b/extensions/line/index.ts @@ -3,6 +3,9 @@ import { registerLineCardCommand } from "./src/card-command.js"; import { linePlugin } from "./src/channel.js"; import { setLineRuntime } from "./src/runtime.js"; +export { linePlugin } from "./src/channel.js"; +export { setLineRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "line", name: "LINE", diff --git a/extensions/line/setup-entry.ts b/extensions/line/setup-entry.ts index 97ed5fa30c6..ce23aecd544 100644 --- a/extensions/line/setup-entry.ts +++ b/extensions/line/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { lineSetupPlugin } from "./src/channel.setup.js"; +export { lineSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(lineSetupPlugin); diff --git a/extensions/matrix/api.ts b/extensions/matrix/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/matrix/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 5400a9b94c6..08e9133197c 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { matrixPlugin } from "./src/channel.js"; import { setMatrixRuntime } from "./src/runtime.js"; +export { matrixPlugin } from "./src/channel.js"; +export { setMatrixRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "matrix", name: "Matrix", diff --git a/extensions/mattermost/api.ts b/extensions/mattermost/api.ts new file mode 100644 index 00000000000..4968757a94e --- /dev/null +++ b/extensions/mattermost/api.ts @@ -0,0 +1 @@ +export { mattermostPlugin } from "./src/channel.js"; diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts index f5086aba465..a40971bf850 100644 --- a/extensions/mattermost/index.ts +++ b/extensions/mattermost/index.ts @@ -3,6 +3,9 @@ import { mattermostPlugin } from "./src/channel.js"; import { registerSlashCommandRoute } from "./src/mattermost/slash-state.js"; import { setMattermostRuntime } from "./src/runtime.js"; +export { mattermostPlugin } from "./src/channel.js"; +export { setMattermostRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "mattermost", name: "Mattermost", diff --git a/extensions/msteams/api.ts b/extensions/msteams/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/msteams/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/msteams/index.ts b/extensions/msteams/index.ts index c190ea49224..edffd1452f4 100644 --- a/extensions/msteams/index.ts +++ b/extensions/msteams/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { msteamsPlugin } from "./src/channel.js"; import { setMSTeamsRuntime } from "./src/runtime.js"; +export { msteamsPlugin } from "./src/channel.js"; +export { setMSTeamsRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "msteams", name: "Microsoft Teams", diff --git a/extensions/nextcloud-talk/api.ts b/extensions/nextcloud-talk/api.ts new file mode 100644 index 00000000000..05701614b9e --- /dev/null +++ b/extensions/nextcloud-talk/api.ts @@ -0,0 +1 @@ +export { nextcloudTalkPlugin } from "./src/channel.js"; diff --git a/extensions/nextcloud-talk/index.ts b/extensions/nextcloud-talk/index.ts index 2057bd435e8..56a398d705b 100644 --- a/extensions/nextcloud-talk/index.ts +++ b/extensions/nextcloud-talk/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { nextcloudTalkPlugin } from "./src/channel.js"; import { setNextcloudTalkRuntime } from "./src/runtime.js"; +export { nextcloudTalkPlugin } from "./src/channel.js"; +export { setNextcloudTalkRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "nextcloud-talk", name: "Nextcloud Talk", diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts new file mode 100644 index 00000000000..7c705aec6e5 --- /dev/null +++ b/extensions/nostr/api.ts @@ -0,0 +1 @@ +export * from "./src/setup-surface.js"; diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index cdabf64c322..2b891c4f0f2 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -5,6 +5,9 @@ import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js"; import { getNostrRuntime, setNostrRuntime } from "./src/runtime.js"; import { resolveNostrAccount } from "./src/types.js"; +export { nostrPlugin } from "./src/channel.js"; +export { setNostrRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "nostr", name: "Nostr", diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts new file mode 100644 index 00000000000..f35c45c2b4e --- /dev/null +++ b/extensions/signal/api.ts @@ -0,0 +1,2 @@ +export * from "./runtime-api.js"; +export * from "./src/accounts.js"; diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index 6b20777f842..f18a7041b53 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { signalPlugin } from "./src/channel.js"; import { setSignalRuntime } from "./src/runtime.js"; +export { signalPlugin } from "./src/channel.js"; +export { setSignalRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "signal", name: "Signal", diff --git a/extensions/signal/runtime-api.ts b/extensions/signal/runtime-api.ts new file mode 100644 index 00000000000..e258df15c9c --- /dev/null +++ b/extensions/signal/runtime-api.ts @@ -0,0 +1 @@ +export * from "./src/index.js"; diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts index 63f6d95e8fc..11930cbba37 100644 --- a/extensions/signal/setup-entry.ts +++ b/extensions/signal/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { signalSetupPlugin } from "./src/channel.setup.js"; +export { signalSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(signalSetupPlugin); diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 5714ad1c68c..a89f25dc268 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -14,7 +14,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/signal"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 705c4d2f839..88d4d07a212 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,5 +1,5 @@ import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/signal"; +import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools"; import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; import { createSignalCliPathTextInput, diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index b5fe4bcd646..f03ecd847e2 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -4,13 +4,15 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { + buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, getChatChannelMeta, + normalizeE164, setAccountEnabledInConfigSection, + SignalConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/core"; -import { normalizeE164 } from "openclaw/plugin-sdk/setup"; +} from "openclaw/plugin-sdk/signal-core"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -42,7 +44,6 @@ export const signalConfigAccessors = createScopedAccountConfigAccessors({ }); export function createSignalPluginBase(params: { - configSchema: Pick, "configSchema">["configSchema"]; setupWizard?: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -73,7 +74,7 @@ export function createSignalPluginBase(params: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.signal"] }, - configSchema: params.configSchema, + configSchema: buildChannelConfigSchema(SignalConfigSchema), config: { listAccountIds: (cfg) => listSignalAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts new file mode 100644 index 00000000000..9264ee7c358 --- /dev/null +++ b/extensions/slack/api.ts @@ -0,0 +1,12 @@ +export * from "./runtime-api.js"; +export * from "./src/account-inspect.js"; +export * from "./src/accounts.js"; +export * from "./src/actions.js"; +export * from "./src/blocks-input.js"; +export * from "./src/blocks-render.js"; +export * from "./src/http/index.js"; +export * from "./src/interactive-replies.js"; +export * from "./src/message-actions.js"; +export * from "./src/sent-thread-cache.js"; +export * from "./src/targets.js"; +export * from "./src/threading-tool-context.js"; diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 44abfa36b0d..f59b28f1f94 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { slackPlugin } from "./src/channel.js"; import { setSlackRuntime } from "./src/runtime.js"; +export { slackPlugin } from "./src/channel.js"; +export { setSlackRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "slack", name: "Slack", diff --git a/extensions/slack/runtime-api.ts b/extensions/slack/runtime-api.ts new file mode 100644 index 00000000000..b40f24e4177 --- /dev/null +++ b/extensions/slack/runtime-api.ts @@ -0,0 +1,4 @@ +export * from "./src/directory-live.js"; +export * from "./src/index.js"; +export * from "./src/resolve-channels.js"; +export * from "./src/resolve-users.js"; diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts index 5a80ca2128b..2600e593267 100644 --- a/extensions/slack/setup-entry.ts +++ b/extensions/slack/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { slackSetupPlugin } from "./src/channel.setup.js"; +export { slackSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(slackSetupPlugin); diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index cc352284ca3..65f6203a57e 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -12,7 +12,7 @@ import { } from "openclaw/plugin-sdk/reply-runtime"; import { isSilentReplyText } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index fc856ad0dd2..5a8fe1feab4 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -18,7 +18,7 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "openclaw/plugin-sdk/slack"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 112142df4d6..6731ddff84b 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -11,7 +11,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "openclaw/plugin-sdk/slack"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index ff8be31895e..0d4fd0a3481 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -3,13 +3,18 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core"; import { formatDocsLink, hasConfiguredSecretInput, patchChannelConfigForAccount, - type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + SlackConfigSchema, + type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk/slack-core"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, @@ -156,7 +161,6 @@ export const slackConfigBase = createScopedChannelConfigBase({ }); export function createSlackPluginBase(params: { - configSchema: Pick, "configSchema">["configSchema"]; setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -201,7 +205,7 @@ export function createSlackPluginBase(params: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.slack"] }, - configSchema: params.configSchema, + configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { ...slackConfigBase, isConfigured: (account) => isSlackPluginAccountConfigured(account), diff --git a/extensions/synology-chat/api.ts b/extensions/synology-chat/api.ts new file mode 100644 index 00000000000..7c705aec6e5 --- /dev/null +++ b/extensions/synology-chat/api.ts @@ -0,0 +1 @@ +export * from "./src/setup-surface.js"; diff --git a/extensions/synology-chat/index.ts b/extensions/synology-chat/index.ts index 79e3f49d513..1e51c8f68aa 100644 --- a/extensions/synology-chat/index.ts +++ b/extensions/synology-chat/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { synologyChatPlugin } from "./src/channel.js"; import { setSynologyRuntime } from "./src/runtime.js"; +export { synologyChatPlugin } from "./src/channel.js"; +export { setSynologyRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "synology-chat", name: "Synology Chat", diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts new file mode 100644 index 00000000000..bb8b0907eca --- /dev/null +++ b/extensions/telegram/api.ts @@ -0,0 +1,18 @@ +export * from "./runtime-api.js"; +export * from "./src/account-inspect.js"; +export * from "./src/accounts.js"; +export * from "./src/allow-from.js"; +export * from "./src/api-fetch.js"; +export * from "./src/exec-approvals.js"; +export * from "./src/inline-buttons.js"; +export * from "./src/model-buttons.js"; +export * from "./src/normalize.js"; +export * from "./src/outbound-adapter.js"; +export * from "./src/outbound-params.js"; +export * from "./src/reaction-level.js"; +export * from "./src/sticker-cache.js"; +export * from "./src/status-issues.js"; +export * from "./src/targets.js"; +export * from "./src/update-offset-store.js"; +export type { TelegramButtonStyle, TelegramInlineButtons } from "./src/button-types.js"; +export type { StickerMetadata } from "./src/bot/types.js"; diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index 89413373c5a..ec6290914fe 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -3,6 +3,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { telegramPlugin } from "./src/channel.js"; import { setTelegramRuntime } from "./src/runtime.js"; +export { telegramPlugin } from "./src/channel.js"; +export { setTelegramRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "telegram", name: "Telegram", diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts new file mode 100644 index 00000000000..e704dc007a3 --- /dev/null +++ b/extensions/telegram/runtime-api.ts @@ -0,0 +1,7 @@ +export * from "./src/audit.js"; +export * from "./src/channel-actions.js"; +export * from "./src/monitor.js"; +export * from "./src/probe.js"; +export * from "./src/send.js"; +export * from "./src/thread-bindings.js"; +export * from "./src/token.js"; diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index c44a073e80b..7b2c02399fa 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { telegramSetupPlugin } from "./src/channel.setup.js"; +export { telegramSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(telegramSetupPlugin); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 9d015e770a5..f8573fecadd 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -20,7 +20,7 @@ export function getLoadWebMediaMock(): AnyMock { return loadWebMedia; } -vi.mock("../../whatsapp/src/media.js", () => ({ +vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index d0a2d0fd610..41dec78c70d 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -17,7 +17,7 @@ import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/r import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { loadWebMedia } from "../../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import type { TelegramInlineButtons } from "../button-types.js"; import { splitTelegramCaption } from "../caption.js"; import { diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index 9b82310ef04..28ad1e6bb0a 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -44,7 +44,7 @@ type TelegramSendTestMocks = { maybePersistResolvedTelegramTarget: MockFn; }; -vi.mock("../../whatsapp/src/media.js", () => ({ +vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 0682fda6786..ec824d88ec7 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -19,7 +19,7 @@ import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/media-ru import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { redactSensitiveText } from "openclaw/plugin-sdk/text-runtime"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index d4a95f0d6fb..afc302500bf 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -8,7 +8,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/telegram"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index b70c8b7fa9d..2a6fbf41d0b 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -1,14 +1,16 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { - createScopedChannelConfigBase, createScopedAccountConfigAccessors, + createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; import { + buildChannelConfigSchema, getChatChannelMeta, normalizeAccountId, - type OpenClawConfig, + TelegramConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/core"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/telegram-core"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, @@ -71,7 +73,6 @@ export const telegramConfigBase = createScopedChannelConfigBase, "configSchema">["configSchema"]; setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; }): Pick< @@ -95,7 +96,7 @@ export function createTelegramPluginBase(params: { blockStreaming: true, }, reload: { configPrefixes: ["channels.telegram"] }, - configSchema: params.configSchema, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), config: { ...telegramConfigBase, isConfigured: (account, cfg) => { diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/tlon/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index 9ae569fea03..a59c7bcb9f2 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -6,6 +6,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { tlonPlugin } from "./src/channel.js"; import { setTlonRuntime } from "./src/runtime.js"; +export { tlonPlugin } from "./src/channel.js"; +export { setTlonRuntime } from "./src/runtime.js"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const ALLOWED_TLON_COMMANDS = new Set([ diff --git a/extensions/twitch/api.ts b/extensions/twitch/api.ts new file mode 100644 index 00000000000..7c705aec6e5 --- /dev/null +++ b/extensions/twitch/api.ts @@ -0,0 +1 @@ +export * from "./src/setup-surface.js"; diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts new file mode 100644 index 00000000000..f35c45c2b4e --- /dev/null +++ b/extensions/whatsapp/api.ts @@ -0,0 +1,2 @@ +export * from "./runtime-api.js"; +export * from "./src/accounts.js"; diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index da16917fa43..de3e6c92706 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { whatsappPlugin } from "./src/channel.js"; import { setWhatsAppRuntime } from "./src/runtime.js"; +export { whatsappPlugin } from "./src/channel.js"; +export { setWhatsAppRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "whatsapp", name: "WhatsApp", diff --git a/extensions/whatsapp/login-qr-api.ts b/extensions/whatsapp/login-qr-api.ts new file mode 100644 index 00000000000..a8af0fc64b2 --- /dev/null +++ b/extensions/whatsapp/login-qr-api.ts @@ -0,0 +1 @@ +export * from "./src/login-qr.js"; diff --git a/extensions/whatsapp/runtime-api.ts b/extensions/whatsapp/runtime-api.ts new file mode 100644 index 00000000000..24e269ad62f --- /dev/null +++ b/extensions/whatsapp/runtime-api.ts @@ -0,0 +1,9 @@ +export * from "./src/active-listener.js"; +export * from "./src/agent-tools-login.js"; +export * from "./src/auth-store.js"; +export * from "./src/auto-reply.js"; +export * from "./src/inbound.js"; +export * from "./src/login.js"; +export * from "./src/media.js"; +export * from "./src/send.js"; +export * from "./src/session.js"; diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index a01efecdc36..16471e34e0f 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { whatsappSetupPlugin } from "./src/channel.setup.js"; +export { whatsappSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(whatsappSetupPlugin); diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index de2203db2ad..0d944b3cb17 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -7,10 +7,6 @@ import { readWebSelfId as readWebSelfIdImpl, webAuthExists as webAuthExistsImpl, } from "./auth-store.js"; -import { - startWebLoginWithQr as startWebLoginWithQrImpl, - waitForWebLogin as waitForWebLoginImpl, -} from "./login-qr.js"; import { loginWeb as loginWebImpl } from "./login.js"; import { whatsappSetupWizard as whatsappSetupWizardImpl } from "./setup-surface.js"; @@ -26,6 +22,13 @@ type WaitForWebLogin = typeof import("./login-qr.js").waitForWebLogin; type WhatsAppSetupWizard = typeof import("./setup-surface.js").whatsappSetupWizard; type MonitorWebChannel = typeof import("openclaw/plugin-sdk/whatsapp").monitorWebChannel; +let loginQrPromise: Promise | null = null; + +function loadWhatsAppLoginQr() { + loginQrPromise ??= import("./login-qr.js"); + return loginQrPromise; +} + export function getActiveWebListener( ...args: Parameters ): ReturnType { @@ -56,14 +59,18 @@ export function loginWeb(...args: Parameters): ReturnType { return loginWebImpl(...args); } -export function startWebLoginWithQr( +export async function startWebLoginWithQr( ...args: Parameters ): ReturnType { - return startWebLoginWithQrImpl(...args); + const { startWebLoginWithQr } = await loadWhatsAppLoginQr(); + return await startWebLoginWithQr(...args); } -export function waitForWebLogin(...args: Parameters): ReturnType { - return waitForWebLoginImpl(...args); +export async function waitForWebLogin( + ...args: Parameters +): ReturnType { + const { waitForWebLogin } = await loadWhatsAppLoginQr(); + return await waitForWebLogin(...args); } export const whatsappSetupWizard: WhatsAppSetupWizard = { ...whatsappSetupWizardImpl }; diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 9f2eb7dd311..e836362bca5 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -11,7 +11,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/whatsapp"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 3114db109d0..1777de07736 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -1,19 +1,22 @@ -import { - formatWhatsAppConfigAllowFromEntries, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, -} from "openclaw/plugin-sdk/channel-config-helpers"; import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { + buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, getChatChannelMeta, + normalizeE164, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/core"; -import { normalizeE164 } from "openclaw/plugin-sdk/setup"; +} from "openclaw/plugin-sdk/whatsapp-core"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, @@ -76,8 +79,6 @@ export function createWhatsAppSetupWizardProxy( } export function createWhatsAppPluginBase(params: { - configSchema: Pick, "configSchema">["configSchema"]; - groups: Pick, "groups">["groups"]; setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; isConfigured: NonNullable["config"]>["isConfigured"]; @@ -113,7 +114,7 @@ export function createWhatsAppPluginBase(params: { }, reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: params.configSchema, + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), config: { listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), @@ -212,6 +213,10 @@ export function createWhatsAppPluginBase(params: { }, }, setup: params.setup, - groups: params.groups, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, }; } diff --git a/extensions/zalo/api.ts b/extensions/zalo/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/zalo/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/zalo/index.ts b/extensions/zalo/index.ts index c5091444450..b1391b68c01 100644 --- a/extensions/zalo/index.ts +++ b/extensions/zalo/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { zaloPlugin } from "./src/channel.js"; import { setZaloRuntime } from "./src/runtime.js"; +export { zaloPlugin } from "./src/channel.js"; +export { setZaloRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "zalo", name: "Zalo", diff --git a/extensions/zalouser/api.ts b/extensions/zalouser/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/zalouser/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index 2199567cff8..c5d4cc2ba24 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -4,6 +4,9 @@ import { zalouserPlugin } from "./src/channel.js"; import { setZalouserRuntime } from "./src/runtime.js"; import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js"; +export { zalouserPlugin } from "./src/channel.js"; +export { setZalouserRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "zalouser", name: "Zalo Personal", diff --git a/package.json b/package.json index 27975bdffe2..9ee2b8e82bd 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,10 @@ "types": "./dist/plugin-sdk/setup.d.ts", "default": "./dist/plugin-sdk/setup.js" }, + "./plugin-sdk/setup-tools": { + "types": "./dist/plugin-sdk/setup-tools.d.ts", + "default": "./dist/plugin-sdk/setup-tools.js" + }, "./plugin-sdk/config-runtime": { "types": "./dist/plugin-sdk/config-runtime.d.ts", "default": "./dist/plugin-sdk/config-runtime.js" @@ -158,26 +162,50 @@ "types": "./dist/plugin-sdk/telegram.d.ts", "default": "./dist/plugin-sdk/telegram.js" }, + "./plugin-sdk/telegram-core": { + "types": "./dist/plugin-sdk/telegram-core.d.ts", + "default": "./dist/plugin-sdk/telegram-core.js" + }, "./plugin-sdk/discord": { "types": "./dist/plugin-sdk/discord.d.ts", "default": "./dist/plugin-sdk/discord.js" }, + "./plugin-sdk/discord-core": { + "types": "./dist/plugin-sdk/discord-core.d.ts", + "default": "./dist/plugin-sdk/discord-core.js" + }, "./plugin-sdk/slack": { "types": "./dist/plugin-sdk/slack.d.ts", "default": "./dist/plugin-sdk/slack.js" }, + "./plugin-sdk/slack-core": { + "types": "./dist/plugin-sdk/slack-core.d.ts", + "default": "./dist/plugin-sdk/slack-core.js" + }, "./plugin-sdk/signal": { "types": "./dist/plugin-sdk/signal.d.ts", "default": "./dist/plugin-sdk/signal.js" }, + "./plugin-sdk/signal-core": { + "types": "./dist/plugin-sdk/signal-core.d.ts", + "default": "./dist/plugin-sdk/signal-core.js" + }, "./plugin-sdk/imessage": { "types": "./dist/plugin-sdk/imessage.d.ts", "default": "./dist/plugin-sdk/imessage.js" }, + "./plugin-sdk/imessage-core": { + "types": "./dist/plugin-sdk/imessage-core.d.ts", + "default": "./dist/plugin-sdk/imessage-core.js" + }, "./plugin-sdk/whatsapp": { "types": "./dist/plugin-sdk/whatsapp.d.ts", "default": "./dist/plugin-sdk/whatsapp.js" }, + "./plugin-sdk/whatsapp-core": { + "types": "./dist/plugin-sdk/whatsapp-core.d.ts", + "default": "./dist/plugin-sdk/whatsapp-core.js" + }, "./plugin-sdk/line": { "types": "./dist/plugin-sdk/line.d.ts", "default": "./dist/plugin-sdk/line.js" @@ -426,6 +454,10 @@ "types": "./dist/plugin-sdk/runtime-store.d.ts", "default": "./dist/plugin-sdk/runtime-store.js" }, + "./plugin-sdk/web-media": { + "types": "./dist/plugin-sdk/web-media.d.ts", + "default": "./dist/plugin-sdk/web-media.js" + }, "./plugin-sdk/speech": { "types": "./dist/plugin-sdk/speech.d.ts", "default": "./dist/plugin-sdk/speech.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index cad41b15fca..d67f48733f5 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -10,6 +10,7 @@ "runtime", "runtime-env", "setup", + "setup-tools", "config-runtime", "reply-runtime", "channel-runtime", @@ -29,11 +30,17 @@ "acp-runtime", "zai", "telegram", + "telegram-core", "discord", + "discord-core", "slack", + "slack-core", "signal", + "signal-core", "imessage", + "imessage-core", "whatsapp", + "whatsapp-core", "line", "msteams", "acpx", @@ -96,6 +103,7 @@ "google", "request-url", "runtime-store", + "web-media", "speech", "state-paths", "tool-send" diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 9e0390bc887..2207023319d 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -101,7 +101,7 @@ export function getWebSessionMocks(): AnyMocks { return webSessionMocks; } -vi.mock("../../extensions/whatsapp/src/session.js", () => webSessionMocks); +vi.mock("../../extensions/whatsapp/api.js", () => webSessionMocks); export const MAIN_SESSION_KEY = "agent:main:main"; diff --git a/src/auto-reply/reply/commands-subagents.test-mocks.ts b/src/auto-reply/reply/commands-subagents.test-mocks.ts index 99c34fbf35c..b9934928372 100644 --- a/src/auto-reply/reply/commands-subagents.test-mocks.ts +++ b/src/auto-reply/reply/commands-subagents.test-mocks.ts @@ -10,7 +10,7 @@ export function installSubagentsCommandCoreMocks() { }); // Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. - vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({ + vi.mock("../../../extensions/discord/runtime-api.js", () => ({ createDiscordGatewayPlugin: () => ({}), })); } diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts index 3ba353b1f6e..c7375b6c1a7 100644 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts @@ -1 +1 @@ -export * from "../../../../../extensions/discord/src/actions/handle-action.guild-admin.js"; +export * from "../../../../../extensions/discord/api.js"; diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 4bd957ec624..c7375b6c1a7 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -1 +1 @@ -export * from "../../../../../extensions/discord/src/actions/handle-action.js"; +export * from "../../../../../extensions/discord/api.js"; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index c7cae53de20..5579ddfdf65 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,33 +1,30 @@ -import { bluebubblesPlugin } from "../../../extensions/bluebubbles/src/channel.js"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { discordSetupPlugin } from "../../../extensions/discord/src/channel.setup.js"; -import { setDiscordRuntime } from "../../../extensions/discord/src/runtime.js"; -import { feishuPlugin } from "../../../extensions/feishu/src/channel.js"; -import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; -import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; -import { imessageSetupPlugin } from "../../../extensions/imessage/src/channel.setup.js"; -import { ircPlugin } from "../../../extensions/irc/src/channel.js"; -import { linePlugin } from "../../../extensions/line/src/channel.js"; -import { lineSetupPlugin } from "../../../extensions/line/src/channel.setup.js"; -import { setLineRuntime } from "../../../extensions/line/src/runtime.js"; -import { matrixPlugin } from "../../../extensions/matrix/src/channel.js"; -import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; -import { msteamsPlugin } from "../../../extensions/msteams/src/channel.js"; -import { nextcloudTalkPlugin } from "../../../extensions/nextcloud-talk/src/channel.js"; -import { nostrPlugin } from "../../../extensions/nostr/src/channel.js"; -import { signalPlugin } from "../../../extensions/signal/src/channel.js"; -import { signalSetupPlugin } from "../../../extensions/signal/src/channel.setup.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { slackSetupPlugin } from "../../../extensions/slack/src/channel.setup.js"; -import { synologyChatPlugin } from "../../../extensions/synology-chat/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { telegramSetupPlugin } from "../../../extensions/telegram/src/channel.setup.js"; -import { setTelegramRuntime } from "../../../extensions/telegram/src/runtime.js"; -import { tlonPlugin } from "../../../extensions/tlon/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; -import { whatsappSetupPlugin } from "../../../extensions/whatsapp/src/channel.setup.js"; -import { zaloPlugin } from "../../../extensions/zalo/src/channel.js"; -import { zalouserPlugin } from "../../../extensions/zalouser/src/channel.js"; +import { bluebubblesPlugin } from "../../../extensions/bluebubbles/index.js"; +import { discordPlugin, setDiscordRuntime } from "../../../extensions/discord/index.js"; +import { discordSetupPlugin } from "../../../extensions/discord/setup-entry.js"; +import { feishuPlugin } from "../../../extensions/feishu/index.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/index.js"; +import { imessagePlugin } from "../../../extensions/imessage/index.js"; +import { imessageSetupPlugin } from "../../../extensions/imessage/setup-entry.js"; +import { ircPlugin } from "../../../extensions/irc/index.js"; +import { linePlugin, setLineRuntime } from "../../../extensions/line/index.js"; +import { lineSetupPlugin } from "../../../extensions/line/setup-entry.js"; +import { matrixPlugin } from "../../../extensions/matrix/index.js"; +import { mattermostPlugin } from "../../../extensions/mattermost/index.js"; +import { msteamsPlugin } from "../../../extensions/msteams/index.js"; +import { nextcloudTalkPlugin } from "../../../extensions/nextcloud-talk/index.js"; +import { nostrPlugin } from "../../../extensions/nostr/index.js"; +import { signalPlugin } from "../../../extensions/signal/index.js"; +import { signalSetupPlugin } from "../../../extensions/signal/setup-entry.js"; +import { slackPlugin } from "../../../extensions/slack/index.js"; +import { slackSetupPlugin } from "../../../extensions/slack/setup-entry.js"; +import { synologyChatPlugin } from "../../../extensions/synology-chat/index.js"; +import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js"; +import { telegramSetupPlugin } from "../../../extensions/telegram/setup-entry.js"; +import { tlonPlugin } from "../../../extensions/tlon/index.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/index.js"; +import { whatsappSetupPlugin } from "../../../extensions/whatsapp/setup-entry.js"; +import { zaloPlugin } from "../../../extensions/zalo/index.js"; +import { zalouserPlugin } from "../../../extensions/zalouser/index.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; export const bundledChannelPlugins = [ diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 339651437d3..a343692622a 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -2,10 +2,10 @@ import { expect, vi } from "vitest"; import { __testing as discordThreadBindingTesting, createThreadBindingManager as createDiscordThreadBindingManager, -} from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js"; -import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/src/thread-bindings.js"; -import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js"; -import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/src/thread-bindings.js"; +} from "../../../../extensions/discord/api.js"; +import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; +import { setMatrixRuntime } from "../../../../extensions/matrix/api.js"; +import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { getSessionBindingService, diff --git a/src/channels/plugins/target-parsing.ts b/src/channels/plugins/target-parsing.ts index beea68adca3..7efa740de37 100644 --- a/src/channels/plugins/target-parsing.ts +++ b/src/channels/plugins/target-parsing.ts @@ -1,5 +1,5 @@ -import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; -import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; +import { parseDiscordTarget } from "../../../extensions/discord/api.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/api.js"; import type { ChatType } from "../chat-type.js"; import { normalizeChatChannelId } from "../registry.js"; import { getChannelPlugin, normalizeChannelId } from "./registry.js"; diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index d1f412b0399..6a448a9750e 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -24,9 +24,8 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../../extensions/telegram/src/update-offset-store.js", async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock("../../extensions/telegram/api.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index b75e3bbc5d4..320e8e1258c 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -258,7 +258,7 @@ vi.mock("../pairing/pairing-store.js", () => ({ upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }), })); -vi.mock("../../extensions/telegram/src/token.js", () => ({ +vi.mock("../../extensions/telegram/api.js", () => ({ resolveTelegramToken: vi.fn(() => ({ token: "", source: "none" })), })); diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index c70ea583f68..c677230f3a2 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; +import { parseTelegramTarget } from "../../extensions/telegram/api.js"; import { signalOutbound, telegramOutbound } from "../../test/channel-outbounds.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 7b8f5cd5f6c..46bd08a8186 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -574,7 +574,7 @@ vi.mock("../commands/health.js", () => ({ vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock("../../extensions/whatsapp/src/send.js", () => ({ +vi.mock("../../extensions/whatsapp/api.js", () => ({ sendMessageWhatsApp: (...args: unknown[]) => (hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args), sendPollWhatsApp: (...args: unknown[]) => diff --git a/src/infra/heartbeat-runner.test-harness.ts b/src/infra/heartbeat-runner.test-harness.ts index f884aabfe87..1099fdf50ab 100644 --- a/src/infra/heartbeat-runner.test-harness.ts +++ b/src/infra/heartbeat-runner.test-harness.ts @@ -1,10 +1,7 @@ import { beforeEach } from "vitest"; -import { slackPlugin } from "../../extensions/slack/src/channel.js"; -import { setSlackRuntime } from "../../extensions/slack/src/runtime.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; -import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; -import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; +import { slackPlugin, setSlackRuntime } from "../../extensions/slack/index.js"; +import { telegramPlugin, setTelegramRuntime } from "../../extensions/telegram/index.js"; +import { whatsappPlugin, setWhatsAppRuntime } from "../../extensions/whatsapp/index.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index a5d72b4adad..3ced54d8333 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -2,8 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { vi } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; +import { telegramPlugin, setTelegramRuntime } from "../../extensions/telegram/index.js"; import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; diff --git a/src/infra/outbound/message-action-runner.test-helpers.ts b/src/infra/outbound/message-action-runner.test-helpers.ts index 8ca1ea6a822..78a2585cfc0 100644 --- a/src/infra/outbound/message-action-runner.test-helpers.ts +++ b/src/infra/outbound/message-action-runner.test-helpers.ts @@ -1,7 +1,5 @@ -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { setSlackRuntime } from "../../../extensions/slack/src/runtime.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../../extensions/telegram/src/runtime.js"; +import { slackPlugin, setSlackRuntime } from "../../../extensions/slack/index.js"; +import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createPluginRuntime } from "../../plugins/runtime/index.js"; diff --git a/src/infra/outbound/targets.shared-test.ts b/src/infra/outbound/targets.shared-test.ts index 91c2ca9b84d..dae0ca82dd5 100644 --- a/src/infra/outbound/targets.shared-test.ts +++ b/src/infra/outbound/targets.shared-test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; +import { telegramPlugin } from "../../../extensions/telegram/index.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/index.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { resolveOutboundTarget } from "./targets.js"; diff --git a/src/plugin-sdk/account-resolution.ts b/src/plugin-sdk/account-resolution.ts index 533d88187d0..f5f1229a798 100644 --- a/src/plugin-sdk/account-resolution.ts +++ b/src/plugin-sdk/account-resolution.ts @@ -13,19 +13,13 @@ export { normalizeE164, pathExists, resolveUserPath } from "../utils.js"; export { resolveDiscordAccount, type ResolvedDiscordAccount, -} from "../../extensions/discord/src/accounts.js"; -export { - resolveSlackAccount, - type ResolvedSlackAccount, -} from "../../extensions/slack/src/accounts.js"; +} from "../../extensions/discord/api.js"; +export { resolveSlackAccount, type ResolvedSlackAccount } from "../../extensions/slack/api.js"; export { resolveTelegramAccount, type ResolvedTelegramAccount, -} from "../../extensions/telegram/src/accounts.js"; -export { - resolveSignalAccount, - type ResolvedSignalAccount, -} from "../../extensions/signal/src/accounts.js"; +} from "../../extensions/telegram/api.js"; +export { resolveSignalAccount, type ResolvedSignalAccount } from "../../extensions/signal/api.js"; /** Resolve an account by id, then fall back to the default account when the primary lacks credentials. */ export function resolveAccountWithDefaultFallback(params: { diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 6375bdea76c..88300031290 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -62,13 +62,13 @@ export { export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; -export type { ParsedChatTarget } from "../../extensions/imessage/src/target-parsing-helpers.js"; +export type { ParsedChatTarget } from "../../extensions/imessage/api.js"; export { parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, -} from "../../extensions/imessage/src/target-parsing-helpers.js"; +} from "../../extensions/imessage/api.js"; export { stripMarkdown } from "../line/markdown-to-line.js"; export { parseFiniteNumber } from "../infra/parse-finite-number.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 3f3e0d0033a..b7a252987a5 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -13,27 +13,27 @@ type GuardedSource = { const SAME_CHANNEL_SDK_GUARDS: GuardedSource[] = [ { path: "extensions/discord/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/discord/, /plugin-sdk-internal\/discord/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/discord["']/, /plugin-sdk-internal\/discord/], }, { path: "extensions/slack/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/slack/, /plugin-sdk-internal\/slack/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/slack["']/, /plugin-sdk-internal\/slack/], }, { path: "extensions/telegram/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/telegram/, /plugin-sdk-internal\/telegram/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/telegram["']/, /plugin-sdk-internal\/telegram/], }, { path: "extensions/imessage/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/imessage/, /plugin-sdk-internal\/imessage/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/imessage["']/, /plugin-sdk-internal\/imessage/], }, { path: "extensions/whatsapp/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/whatsapp/, /plugin-sdk-internal\/whatsapp/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/whatsapp["']/, /plugin-sdk-internal\/whatsapp/], }, { path: "extensions/signal/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/signal/, /plugin-sdk-internal\/signal/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/signal["']/, /plugin-sdk-internal\/signal/], }, ]; @@ -135,6 +135,47 @@ function collectExtensionSourceFiles(): string[] { if ( fullPath.includes(".test.") || fullPath.includes(".fixture.") || + fullPath.includes(".snap") || + fullPath.includes("test-support") || + fullPath.endsWith("/api.ts") || + fullPath.endsWith("/runtime-api.ts") + ) { + continue; + } + files.push(fullPath); + } + } + return files; +} + +function collectCoreSourceFiles(): string[] { + const srcDir = resolve(ROOT_DIR, "..", "src"); + const files: string[] = []; + const stack = [srcDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile() || !/\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(entry.name)) { + continue; + } + if (entry.name.endsWith(".d.ts")) { + continue; + } + if ( + fullPath.includes(".test.") || + fullPath.includes(".spec.") || + fullPath.includes(".fixture.") || fullPath.includes(".snap") ) { continue; @@ -177,4 +218,22 @@ describe("channel import guardrails", () => { ); } }); + + it("keeps core production files off extension private src imports", () => { + for (const file of collectCoreSourceFiles()) { + const text = readFileSync(file, "utf8"); + expect(text, `${file} should not import extensions/*/src`).not.toMatch( + /["'][^"']*extensions\/[^/"']+\/src\//, + ); + } + }); + + it("keeps extension production files off other extensions' private src imports", () => { + for (const file of collectExtensionSourceFiles()) { + const text = readFileSync(file, "utf8"); + expect(text, `${file} should not import another extension's src`).not.toMatch( + /["'][^"']*\.\.\/(?:\.\.\/)?(?!src\/)[^/"']+\/src\//, + ); + } + }); }); diff --git a/src/plugin-sdk/discord-core.ts b/src/plugin-sdk/discord-core.ts new file mode 100644 index 00000000000..3e87e17ef42 --- /dev/null +++ b/src/plugin-sdk/discord-core.ts @@ -0,0 +1,3 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index 6cca5f9f803..679b5109a5e 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -1,4 +1,4 @@ -import type { DiscordSendResult } from "../../extensions/discord/src/send.types.js"; +import type { DiscordSendResult } from "../../extensions/discord/api.js"; type DiscordSendOptionInput = { replyToId?: string | null; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 25b5b71580e..d55a8157998 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -5,18 +5,15 @@ export type { } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; -export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; -export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; -export type { - DiscordSendComponents, - DiscordSendEmbeds, -} from "../../extensions/discord/src/send.shared.js"; +export type { DiscordPluralKitConfig } from "../../extensions/discord/api.js"; +export type { InspectedDiscordAccount } from "../../extensions/discord/api.js"; +export type { ResolvedDiscordAccount } from "../../extensions/discord/api.js"; +export type { DiscordSendComponents, DiscordSendEmbeds } from "../../extensions/discord/api.js"; export type { ThreadBindingManager, ThreadBindingRecord, ThreadBindingTargetKind, -} from "../../extensions/discord/src/monitor/thread-bindings.js"; +} from "../../extensions/discord/api.js"; export type { ChannelConfiguredBindingProvider, ChannelConfiguredBindingConversationRef, @@ -71,29 +68,29 @@ export { createDiscordActionGate, listDiscordAccountIds, resolveDefaultDiscordAccountId, -} from "../../extensions/discord/src/accounts.js"; -export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +} from "../../extensions/discord/api.js"; +export { inspectDiscordAccount } from "../../extensions/discord/api.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, -} from "../../extensions/discord/src/normalize.js"; -export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { collectDiscordStatusIssues } from "../../extensions/discord/src/status-issues.js"; +} from "../../extensions/discord/api.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/api.js"; +export { collectDiscordStatusIssues } from "../../extensions/discord/api.js"; export { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../../extensions/discord/src/monitor/timeouts.js"; -export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/src/session-key-normalization.js"; +} from "../../extensions/discord/api.js"; +export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/api.js"; export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../extensions/discord/src/monitor/thread-bindings.js"; -export { getGateway } from "../../extensions/discord/src/monitor/gateway-registry.js"; -export { getPresence } from "../../extensions/discord/src/monitor/presence-cache.js"; -export { readDiscordComponentSpec } from "../../extensions/discord/src/components.js"; -export { resolveDiscordChannelId } from "../../extensions/discord/src/targets.js"; +} from "../../extensions/discord/api.js"; +export { getGateway } from "../../extensions/discord/api.js"; +export { getPresence } from "../../extensions/discord/api.js"; +export { readDiscordComponentSpec } from "../../extensions/discord/api.js"; +export { resolveDiscordChannelId } from "../../extensions/discord/api.js"; export { addRoleDiscord, banMemberDiscord, @@ -137,5 +134,5 @@ export { unpinMessageDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../extensions/discord/src/send.js"; -export { discordMessageActions } from "../../extensions/discord/src/channel-actions.js"; +} from "../../extensions/discord/api.js"; +export { discordMessageActions } from "../../extensions/discord/api.js"; diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 0ca6fe0a38b..3a4fa4779c4 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -67,8 +67,8 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { feishuSetupWizard } from "../../extensions/feishu/src/setup-surface.js"; -export { feishuSetupAdapter } from "../../extensions/feishu/src/setup-core.js"; +export { feishuSetupWizard } from "../../extensions/feishu/api.js"; +export { feishuSetupAdapter } from "../../extensions/feishu/api.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; @@ -84,7 +84,7 @@ export { withTempDownloadPath } from "./temp-path.js"; export { buildFeishuConversationId, parseFeishuConversationId, -} from "../../extensions/feishu/src/conversation-id.js"; +} from "../../extensions/feishu/api.js"; export { createFixedWindowRateLimiter, createWebhookAnomalyTracker, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index ce05a95b47a..ce6d5f44511 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -65,8 +65,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { googlechatSetupAdapter } from "../../extensions/googlechat/src/setup-core.js"; -export { googlechatSetupWizard } from "../../extensions/googlechat/src/setup-surface.js"; +export { googlechatSetupAdapter } from "../../extensions/googlechat/api.js"; +export { googlechatSetupWizard } from "../../extensions/googlechat/api.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; diff --git a/src/plugin-sdk/imessage-core.ts b/src/plugin-sdk/imessage-core.ts new file mode 100644 index 00000000000..ac93a67f307 --- /dev/null +++ b/src/plugin-sdk/imessage-core.ts @@ -0,0 +1,14 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + getChatChannelMeta, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; +export { + formatTrimmedAllowFromEntries, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, +} from "./channel-config-helpers.js"; +export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/imessage-targets.ts b/src/plugin-sdk/imessage-targets.ts index b3353edc3df..4a7f535be48 100644 --- a/src/plugin-sdk/imessage-targets.ts +++ b/src/plugin-sdk/imessage-targets.ts @@ -1 +1 @@ -export { normalizeIMessageHandle } from "../../extensions/imessage/src/targets.js"; +export { normalizeIMessageHandle } from "../../extensions/imessage/api.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index f3dfba82120..ec769552348 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -42,4 +42,4 @@ export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { collectStatusIssuesFromLastError } from "./status-helpers.js"; -export { sendMessageIMessage } from "../../extensions/imessage/src/send.js"; +export { sendMessageIMessage } from "../../extensions/imessage/api.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 4192322d527..47ba490ec42 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -62,7 +62,7 @@ export { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount, -} from "../../extensions/irc/src/accounts.js"; +} from "../../extensions/irc/api.js"; export { readStoreAllowFromForDmPolicy, resolveEffectiveAllowFromLists, @@ -72,7 +72,7 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; -export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/src/setup-surface.js"; +export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/api.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index b6617199472..9592fe7f12e 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -32,8 +32,8 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; -export { lineSetupAdapter } from "../../extensions/line/src/setup-core.js"; -export { lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; +export { lineSetupAdapter } from "../../extensions/line/api.js"; +export { lineSetupWizard } from "../../extensions/line/api.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 164575e04e1..099b53792da 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -108,5 +108,5 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; -export { matrixSetupWizard } from "../../extensions/matrix/src/setup-surface.js"; -export { matrixSetupAdapter } from "../../extensions/matrix/src/setup-core.js"; +export { matrixSetupWizard } from "../../extensions/matrix/api.js"; +export { matrixSetupAdapter } from "../../extensions/matrix/api.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index b30e6c6914a..1185558de79 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -117,5 +117,5 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; -export { msteamsSetupWizard } from "../../extensions/msteams/src/setup-surface.js"; -export { msteamsSetupAdapter } from "../../extensions/msteams/src/setup-core.js"; +export { msteamsSetupWizard } from "../../extensions/msteams/api.js"; +export { msteamsSetupAdapter } from "../../extensions/msteams/api.js"; diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index a2997c5702c..362344810fa 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -19,4 +19,4 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; -export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js"; +export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/api.js"; diff --git a/src/plugin-sdk/setup-tools.ts b/src/plugin-sdk/setup-tools.ts new file mode 100644 index 00000000000..d2a625c608d --- /dev/null +++ b/src/plugin-sdk/setup-tools.ts @@ -0,0 +1,4 @@ +export { formatCliCommand } from "../cli/command-format.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { installSignalCli } from "../plugins/signal-cli-install.js"; +export { formatDocsLink } from "../terminal/links.js"; diff --git a/src/plugin-sdk/signal-core.ts b/src/plugin-sdk/signal-core.ts new file mode 100644 index 00000000000..42b1facd2af --- /dev/null +++ b/src/plugin-sdk/signal-core.ts @@ -0,0 +1,10 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + getChatChannelMeta, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; +export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; +export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index bac479002b4..fda5ec6e7b9 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,7 +1,7 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { SignalAccountConfig } from "../config/types.js"; -export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; +export type { ResolvedSignalAccount } from "../../extensions/signal/api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -51,10 +51,7 @@ export { listEnabledSignalAccounts, listSignalAccountIds, resolveDefaultSignalAccountId, -} from "../../extensions/signal/src/accounts.js"; -export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; -export { - removeReactionSignal, - sendReactionSignal, -} from "../../extensions/signal/src/send-reactions.js"; -export { sendMessageSignal } from "../../extensions/signal/src/send.js"; +} from "../../extensions/signal/api.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/api.js"; +export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/api.js"; +export { sendMessageSignal } from "../../extensions/signal/api.js"; diff --git a/src/plugin-sdk/slack-core.ts b/src/plugin-sdk/slack-core.ts new file mode 100644 index 00000000000..8df7ad669a7 --- /dev/null +++ b/src/plugin-sdk/slack-core.ts @@ -0,0 +1,4 @@ +export type { OpenClawConfig } from "../config/config.js"; +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/slack-message-actions.ts b/src/plugin-sdk/slack-message-actions.ts index ef7a5f12876..64863623503 100644 --- a/src/plugin-sdk/slack-message-actions.ts +++ b/src/plugin-sdk/slack-message-actions.ts @@ -1,6 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; -import { buildSlackInteractiveBlocks } from "../../extensions/slack/src/blocks-render.js"; +import { parseSlackBlocksInput, buildSlackInteractiveBlocks } from "../../extensions/slack/api.js"; import { readNumberParam, readStringParam } from "../agents/tools/common.js"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; import { normalizeInteractiveReply } from "../interactive/payload.js"; diff --git a/src/plugin-sdk/slack-targets.ts b/src/plugin-sdk/slack-targets.ts index be9ded918cf..20ea56e44d1 100644 --- a/src/plugin-sdk/slack-targets.ts +++ b/src/plugin-sdk/slack-targets.ts @@ -3,4 +3,4 @@ export { resolveSlackChannelId, type SlackTarget, type SlackTargetKind, -} from "../../extensions/slack/src/targets.js"; +} from "../../extensions/slack/api.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 113e705ede9..50c08b51a2b 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,7 +1,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SlackAccountConfig } from "../config/types.slack.js"; -export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; -export type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; +export type { InspectedSlackAccount } from "../../extensions/slack/api.js"; +export type { ResolvedSlackAccount } from "../../extensions/slack/api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -52,18 +52,15 @@ export { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackReplyToMode, -} from "../../extensions/slack/src/accounts.js"; -export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; -export { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +} from "../../extensions/slack/api.js"; +export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/api.js"; +export { inspectSlackAccount } from "../../extensions/slack/api.js"; export { parseSlackTarget, resolveSlackChannelId } from "./slack-targets.js"; -export { - extractSlackToolSend, - listSlackMessageActions, -} from "../../extensions/slack/src/message-actions.js"; -export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; -export { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; -export { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.js"; -export { sendMessageSlack } from "../../extensions/slack/src/send.js"; +export { extractSlackToolSend, listSlackMessageActions } from "../../extensions/slack/api.js"; +export { buildSlackThreadingToolContext } from "../../extensions/slack/api.js"; +export { parseSlackBlocksInput } from "../../extensions/slack/api.js"; +export { handleSlackHttpRequest } from "../../extensions/slack/api.js"; +export { sendMessageSlack } from "../../extensions/slack/api.js"; export { deleteSlackMessage, downloadSlackFile, @@ -79,8 +76,8 @@ export { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../extensions/slack/src/actions.js"; -export { recordSlackThreadParticipation } from "../../extensions/slack/src/sent-thread-cache.js"; +} from "../../extensions/slack/api.js"; +export { recordSlackThreadParticipation } from "../../extensions/slack/api.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { createSlackActions } from "../channels/plugins/slack.actions.js"; export type { SlackActionContext } from "../agents/tools/slack-actions.js"; diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index f5fae73fbb2..17b916385bc 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -20,4 +20,4 @@ export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { synologyChatSetupAdapter, synologyChatSetupWizard, -} from "../../extensions/synology-chat/src/setup-surface.js"; +} from "../../extensions/synology-chat/api.js"; diff --git a/src/plugin-sdk/telegram-core.ts b/src/plugin-sdk/telegram-core.ts new file mode 100644 index 00000000000..a020a333fd3 --- /dev/null +++ b/src/plugin-sdk/telegram-core.ts @@ -0,0 +1,5 @@ +export type { OpenClawConfig } from "../config/config.js"; +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export { normalizeAccountId } from "../routing/session-key.js"; +export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index c7961f91398..d4a35210e90 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -17,20 +17,16 @@ export type { ChannelConfiguredBindingConversationRef, ChannelConfiguredBindingMatch, } from "../channels/plugins/types.adapters.js"; -export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; -export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; -export type { - TelegramButtonStyle, - TelegramInlineButtons, -} from "../../extensions/telegram/src/button-types.js"; -export type { StickerMetadata } from "../../extensions/telegram/src/bot/types.js"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; +export type { ResolvedTelegramAccount } from "../../extensions/telegram/api.js"; +export type { TelegramProbe } from "../../extensions/telegram/api.js"; +export type { TelegramButtonStyle, TelegramInlineButtons } from "../../extensions/telegram/api.js"; +export type { StickerMetadata } from "../../extensions/telegram/api.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; -export { formatCliCommand } from "../cli/command-format.js"; -export { formatDocsLink } from "../terminal/links.js"; + export { PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, @@ -71,26 +67,26 @@ export { listTelegramAccountIds, resolveDefaultTelegramAccountId, resolveTelegramPollActionGateState, -} from "../../extensions/telegram/src/accounts.js"; -export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +} from "../../extensions/telegram/api.js"; +export { inspectTelegramAccount } from "../../extensions/telegram/api.js"; export { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget, -} from "../../extensions/telegram/src/normalize.js"; +} from "../../extensions/telegram/api.js"; export { parseTelegramReplyToMessageId, parseTelegramThreadId, -} from "../../extensions/telegram/src/outbound-params.js"; +} from "../../extensions/telegram/api.js"; export { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/src/allow-from.js"; -export { fetchTelegramChatId } from "../../extensions/telegram/src/api-fetch.js"; +} from "../../extensions/telegram/api.js"; +export { fetchTelegramChatId } from "../../extensions/telegram/api.js"; export { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../extensions/telegram/src/inline-buttons.js"; -export { resolveTelegramReactionLevel } from "../../extensions/telegram/src/reaction-level.js"; +} from "../../extensions/telegram/api.js"; +export { resolveTelegramReactionLevel } from "../../extensions/telegram/api.js"; export { createForumTopicTelegram, deleteMessageTelegram, @@ -100,12 +96,12 @@ export { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../extensions/telegram/src/send.js"; -export { getCacheStats, searchStickers } from "../../extensions/telegram/src/sticker-cache.js"; -export { resolveTelegramToken } from "../../extensions/telegram/src/token.js"; -export { telegramMessageActions } from "../../extensions/telegram/src/channel-actions.js"; -export { collectTelegramStatusIssues } from "../../extensions/telegram/src/status-issues.js"; -export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js"; +} from "../../extensions/telegram/api.js"; +export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js"; +export { resolveTelegramToken } from "../../extensions/telegram/api.js"; +export { telegramMessageActions } from "../../extensions/telegram/api.js"; +export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js"; +export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js"; export { buildBrowseProvidersButton, buildModelsKeyboard, @@ -113,8 +109,8 @@ export { calculateTotalPages, getModelsPageSize, type ProviderInfo, -} from "../../extensions/telegram/src/model-buttons.js"; +} from "../../extensions/telegram/api.js"; export { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "../../extensions/telegram/src/exec-approvals.js"; +} from "../../extensions/telegram/api.js"; diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 291834b9648..246c4b7093e 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -27,5 +27,5 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter } from "../../extensions/tlon/src/setup-core.js"; -export { tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; +export { tlonSetupAdapter } from "../../extensions/tlon/api.js"; +export { tlonSetupWizard } from "../../extensions/tlon/api.js"; diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 907cdd171fa..9b200cf03f7 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -33,7 +33,4 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - twitchSetupAdapter, - twitchSetupWizard, -} from "../../extensions/twitch/src/setup-surface.js"; +export { twitchSetupAdapter, twitchSetupWizard } from "../../extensions/twitch/api.js"; diff --git a/src/plugin-sdk/web-media.ts b/src/plugin-sdk/web-media.ts index 1c7432ad2b5..ce734a295bb 100644 --- a/src/plugin-sdk/web-media.ts +++ b/src/plugin-sdk/web-media.ts @@ -3,4 +3,4 @@ export { loadWebMedia, loadWebMediaRaw, type WebMediaResult, -} from "../../extensions/whatsapp/src/media.js"; +} from "../../extensions/whatsapp/runtime-api.js"; diff --git a/src/plugin-sdk/whatsapp-core.ts b/src/plugin-sdk/whatsapp-core.ts new file mode 100644 index 00000000000..036fda6a5a9 --- /dev/null +++ b/src/plugin-sdk/whatsapp-core.ts @@ -0,0 +1,18 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + getChatChannelMeta, +} from "./channel-plugin-common.js"; +export { + formatWhatsAppConfigAllowFromEntries, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, +} from "./channel-config-helpers.js"; +export { + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { resolveWhatsAppGroupIntroHint } from "../channels/plugins/whatsapp-shared.js"; +export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; +export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index ed66e212021..d613872cb33 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,14 +1,8 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -export type { - WebChannelStatus, - WebMonitorTuning, -} from "../../extensions/whatsapp/src/auto-reply.js"; -export type { - WebInboundMessage, - WebListenerCloseReason, -} from "../../extensions/whatsapp/src/inbound.js"; +export type { WebChannelStatus, WebMonitorTuning } from "../../extensions/whatsapp/api.js"; +export type { WebInboundMessage, WebListenerCloseReason } from "../../extensions/whatsapp/api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -72,14 +66,14 @@ export { hasAnyWhatsAppAuth, listEnabledWhatsAppAccounts, resolveWhatsAppAccount, -} from "../../extensions/whatsapp/src/accounts.js"; +} from "../../extensions/whatsapp/api.js"; export { WA_WEB_AUTH_DIR, logWebSelfId, logoutWeb, pickWebChannel, webAuthExists, -} from "../../extensions/whatsapp/src/auth-store.js"; +} from "../../extensions/whatsapp/api.js"; export { DEFAULT_WEB_MEDIA_BYTES, HEARTBEAT_PROMPT, @@ -87,28 +81,28 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "../../extensions/whatsapp/src/auto-reply.js"; +} from "../../extensions/whatsapp/api.js"; export { extractMediaPlaceholder, extractText, monitorWebInbox, -} from "../../extensions/whatsapp/src/inbound.js"; -export { loginWeb } from "../../extensions/whatsapp/src/login.js"; +} from "../../extensions/whatsapp/api.js"; +export { loginWeb } from "../../extensions/whatsapp/api.js"; export { getDefaultLocalRoots, loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg, -} from "../../extensions/whatsapp/src/media.js"; +} from "../../extensions/whatsapp/api.js"; export { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp, -} from "../../extensions/whatsapp/src/send.js"; +} from "../../extensions/whatsapp/api.js"; export { createWaSocket, formatError, getStatusCode, waitForWaConnection, -} from "../../extensions/whatsapp/src/session.js"; -export { createWhatsAppLoginTool } from "../../extensions/whatsapp/src/agent-tools-login.js"; +} from "../../extensions/whatsapp/api.js"; +export { createWhatsAppLoginTool } from "../../extensions/whatsapp/api.js"; diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 37e3b9fde26..2655e26e18f 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -62,8 +62,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; -export { zaloSetupAdapter } from "../../extensions/zalo/src/setup-core.js"; -export { zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; +export { zaloSetupAdapter } from "../../extensions/zalo/api.js"; +export { zaloSetupWizard } from "../../extensions/zalo/api.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index b7b95910132..ed66e31754e 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -53,8 +53,8 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { zalouserSetupAdapter } from "../../extensions/zalouser/src/setup-core.js"; -export { zalouserSetupWizard } from "../../extensions/zalouser/src/setup-surface.js"; +export { zalouserSetupAdapter } from "../../extensions/zalouser/api.js"; +export { zalouserSetupWizard } from "../../extensions/zalouser/api.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts index 182e9c75d41..e1bc99166af 100644 --- a/src/plugins/runtime/runtime-discord-ops.runtime.ts +++ b/src/plugins/runtime/runtime-discord-ops.runtime.ts @@ -1,12 +1,12 @@ -import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "../../../extensions/discord/src/audit.js"; +import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "../../../extensions/discord/runtime-api.js"; import { listDiscordDirectoryGroupsLive as listDiscordDirectoryGroupsLiveImpl, listDiscordDirectoryPeersLive as listDiscordDirectoryPeersLiveImpl, -} from "../../../extensions/discord/src/directory-live.js"; -import { monitorDiscordProvider as monitorDiscordProviderImpl } from "../../../extensions/discord/src/monitor.js"; -import { probeDiscord as probeDiscordImpl } from "../../../extensions/discord/src/probe.js"; -import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "../../../extensions/discord/src/resolve-channels.js"; -import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "../../../extensions/discord/src/resolve-users.js"; +} from "../../../extensions/discord/runtime-api.js"; +import { monitorDiscordProvider as monitorDiscordProviderImpl } from "../../../extensions/discord/runtime-api.js"; +import { probeDiscord as probeDiscordImpl } from "../../../extensions/discord/runtime-api.js"; +import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; +import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; import { createThreadDiscord as createThreadDiscordImpl, deleteMessageDiscord as deleteMessageDiscordImpl, @@ -18,7 +18,7 @@ import { sendPollDiscord as sendPollDiscordImpl, sendTypingDiscord as sendTypingDiscordImpl, unpinMessageDiscord as unpinMessageDiscordImpl, -} from "../../../extensions/discord/src/send.js"; +} from "../../../extensions/discord/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeDiscordOps = Pick< diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 6203fa6c2d8..8264a7f04df 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,4 +1,4 @@ -import { discordMessageActions } from "../../../extensions/discord/src/channel-actions.js"; +import { discordMessageActions } from "../../../extensions/discord/runtime-api.js"; import { getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, @@ -8,7 +8,7 @@ import { setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../../extensions/discord/src/monitor/thread-bindings.js"; +} from "../../../extensions/discord/runtime-api.js"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/runtime-imessage.ts b/src/plugins/runtime/runtime-imessage.ts index 01430cacc3c..56136197626 100644 --- a/src/plugins/runtime/runtime-imessage.ts +++ b/src/plugins/runtime/runtime-imessage.ts @@ -1,6 +1,8 @@ -import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js"; -import { probeIMessage } from "../../../extensions/imessage/src/probe.js"; -import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js"; +import { + monitorIMessageProvider, + probeIMessage, + sendMessageIMessage, +} from "../../../extensions/imessage/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeIMessage(): PluginRuntimeChannel["imessage"] { diff --git a/src/plugins/runtime/runtime-media.ts b/src/plugins/runtime/runtime-media.ts index 90b28eea31e..abf88724981 100644 --- a/src/plugins/runtime/runtime-media.ts +++ b/src/plugins/runtime/runtime-media.ts @@ -1,4 +1,4 @@ -import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; +import { loadWebMedia } from "../../../extensions/whatsapp/runtime-api.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; import { mediaKindFromMime } from "../../media/constants.js"; import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js"; diff --git a/src/plugins/runtime/runtime-signal.ts b/src/plugins/runtime/runtime-signal.ts index 2465ecbdbbc..dc83f3fd1e2 100644 --- a/src/plugins/runtime/runtime-signal.ts +++ b/src/plugins/runtime/runtime-signal.ts @@ -1,6 +1,8 @@ -import { monitorSignalProvider } from "../../../extensions/signal/src/index.js"; -import { probeSignal } from "../../../extensions/signal/src/probe.js"; -import { sendMessageSignal } from "../../../extensions/signal/src/send.js"; +import { + monitorSignalProvider, + probeSignal, + sendMessageSignal, +} from "../../../extensions/signal/runtime-api.js"; import { signalMessageActions } from "../../channels/plugins/actions/signal.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 4f4dc1aeda7..65b7ed9e884 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -1,12 +1,12 @@ import { listSlackDirectoryGroupsLive as listSlackDirectoryGroupsLiveImpl, listSlackDirectoryPeersLive as listSlackDirectoryPeersLiveImpl, -} from "../../../extensions/slack/src/directory-live.js"; -import { monitorSlackProvider as monitorSlackProviderImpl } from "../../../extensions/slack/src/index.js"; -import { probeSlack as probeSlackImpl } from "../../../extensions/slack/src/probe.js"; -import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/src/resolve-channels.js"; -import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/src/resolve-users.js"; -import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/src/send.js"; +} from "../../../extensions/slack/runtime-api.js"; +import { monitorSlackProvider as monitorSlackProviderImpl } from "../../../extensions/slack/runtime-api.js"; +import { probeSlack as probeSlackImpl } from "../../../extensions/slack/runtime-api.js"; +import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; +import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; +import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; import { handleSlackAction as handleSlackActionImpl } from "../../agents/tools/slack-actions.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts index b8b915e6065..dcd3fa05dec 100644 --- a/src/plugins/runtime/runtime-telegram-ops.runtime.ts +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -1,6 +1,6 @@ -import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "../../../extensions/telegram/src/audit.js"; -import { monitorTelegramProvider as monitorTelegramProviderImpl } from "../../../extensions/telegram/src/monitor.js"; -import { probeTelegram as probeTelegramImpl } from "../../../extensions/telegram/src/probe.js"; +import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "../../../extensions/telegram/runtime-api.js"; +import { monitorTelegramProvider as monitorTelegramProviderImpl } from "../../../extensions/telegram/runtime-api.js"; +import { probeTelegram as probeTelegramImpl } from "../../../extensions/telegram/runtime-api.js"; import { deleteMessageTelegram as deleteMessageTelegramImpl, editMessageReplyMarkupTelegram as editMessageReplyMarkupTelegramImpl, @@ -11,7 +11,7 @@ import { sendPollTelegram as sendPollTelegramImpl, sendTypingTelegram as sendTypingTelegramImpl, unpinMessageTelegram as unpinMessageTelegramImpl, -} from "../../../extensions/telegram/src/send.js"; +} from "../../../extensions/telegram/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeTelegramOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index f8d71de11e0..42ce8d995a6 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,10 +1,10 @@ -import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/src/audit.js"; -import { telegramMessageActions } from "../../../extensions/telegram/src/channel-actions.js"; +import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/api.js"; +import { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, -} from "../../../extensions/telegram/src/thread-bindings.js"; -import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; +} from "../../../extensions/telegram/runtime-api.js"; +import { resolveTelegramToken } from "../../../extensions/telegram/runtime-api.js"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/runtime-whatsapp-login-tool.ts b/src/plugins/runtime/runtime-whatsapp-login-tool.ts index 811619b9099..094e47c9a1d 100644 --- a/src/plugins/runtime/runtime-whatsapp-login-tool.ts +++ b/src/plugins/runtime/runtime-whatsapp-login-tool.ts @@ -1 +1 @@ -export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "../../../extensions/whatsapp/src/agent-tools-login.js"; +export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "../../../extensions/whatsapp/runtime-api.js"; diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts index 2760db7311d..baef795d478 100644 --- a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -1,4 +1,4 @@ -import { loginWeb as loginWebImpl } from "../../../extensions/whatsapp/src/login.js"; +import { loginWeb as loginWebImpl } from "../../../extensions/whatsapp/runtime-api.js"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppLogin = Pick; diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts index 71aa83ce9ac..91fcba6fd39 100644 --- a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -1,7 +1,7 @@ import { sendMessageWhatsApp as sendMessageWhatsAppImpl, sendPollWhatsApp as sendPollWhatsAppImpl, -} from "../../../extensions/whatsapp/src/send.js"; +} from "../../../extensions/whatsapp/runtime-api.js"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppOutbound = Pick< diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index e3b38710ce1..5ca70688471 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -1,11 +1,11 @@ -import { getActiveWebListener } from "../../../extensions/whatsapp/src/active-listener.js"; +import { getActiveWebListener } from "../../../extensions/whatsapp/runtime-api.js"; import { getWebAuthAgeMs, logoutWeb, logWebSelfId, readWebSelfId, webAuthExists, -} from "../../../extensions/whatsapp/src/auth-store.js"; +} from "../../../extensions/whatsapp/runtime-api.js"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, @@ -64,7 +64,7 @@ const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhat }; let webLoginQrPromise: Promise< - typeof import("../../../extensions/whatsapp/src/login-qr.js") + typeof import("../../../extensions/whatsapp/login-qr-api.js") > | null = null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< @@ -72,7 +72,7 @@ let whatsappActionsPromise: Promise< > | null = null; function loadWebLoginQr() { - webLoginQrPromise ??= import("../../../extensions/whatsapp/src/login-qr.js"); + webLoginQrPromise ??= import("../../../extensions/whatsapp/login-qr-api.js"); return webLoginQrPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 1b0c21044a8..d115a3a91e7 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -87,29 +87,29 @@ export type PluginRuntimeChannel = { shouldHandleTextCommands: typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands; }; discord: { - messageActions: typeof import("../../../extensions/discord/src/channel-actions.js").discordMessageActions; - auditChannelPermissions: typeof import("../../../extensions/discord/src/audit.js").auditDiscordChannelPermissions; - listDirectoryGroupsLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryPeersLive; - probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord; - resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist; - sendComponentMessage: typeof import("../../../extensions/discord/src/send.js").sendDiscordComponentMessage; - sendMessageDiscord: typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord; - sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord; - monitorDiscordProvider: typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider; + messageActions: typeof import("../../../extensions/discord/runtime-api.js").discordMessageActions; + auditChannelPermissions: typeof import("../../../extensions/discord/runtime-api.js").auditDiscordChannelPermissions; + listDirectoryGroupsLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryPeersLive; + probeDiscord: typeof import("../../../extensions/discord/runtime-api.js").probeDiscord; + resolveChannelAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordChannelAllowlist; + resolveUserAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordUserAllowlist; + sendComponentMessage: typeof import("../../../extensions/discord/runtime-api.js").sendDiscordComponentMessage; + sendMessageDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendMessageDiscord; + sendPollDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendPollDiscord; + monitorDiscordProvider: typeof import("../../../extensions/discord/runtime-api.js").monitorDiscordProvider; threadBindings: { - getManager: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").getThreadBindingManager; - resolveIdleTimeoutMs: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingIdleTimeoutMs; - resolveInactivityExpiresAt: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingInactivityExpiresAt; - resolveMaxAgeMs: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingMaxAgeMs; - resolveMaxAgeExpiresAt: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingMaxAgeExpiresAt; - setIdleTimeoutBySessionKey: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").setThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").setThreadBindingMaxAgeBySessionKey; - unbindBySessionKey: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").unbindThreadBindingsBySessionKey; + getManager: typeof import("../../../extensions/discord/runtime-api.js").getThreadBindingManager; + resolveIdleTimeoutMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingIdleTimeoutMs; + resolveInactivityExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingInactivityExpiresAt; + resolveMaxAgeMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeMs; + resolveMaxAgeExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeExpiresAt; + setIdleTimeoutBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingMaxAgeBySessionKey; + unbindBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").unbindThreadBindingsBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/discord/src/send.js").sendTypingDiscord; + pulse: typeof import("../../../extensions/discord/runtime-api.js").sendTypingDiscord; start: (params: { channelId: string; accountId?: string; @@ -121,39 +121,39 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/discord/src/send.js").editMessageDiscord; - deleteMessage: typeof import("../../../extensions/discord/src/send.js").deleteMessageDiscord; - pinMessage: typeof import("../../../extensions/discord/src/send.js").pinMessageDiscord; - unpinMessage: typeof import("../../../extensions/discord/src/send.js").unpinMessageDiscord; - createThread: typeof import("../../../extensions/discord/src/send.js").createThreadDiscord; - editChannel: typeof import("../../../extensions/discord/src/send.js").editChannelDiscord; + editMessage: typeof import("../../../extensions/discord/runtime-api.js").editMessageDiscord; + deleteMessage: typeof import("../../../extensions/discord/runtime-api.js").deleteMessageDiscord; + pinMessage: typeof import("../../../extensions/discord/runtime-api.js").pinMessageDiscord; + unpinMessage: typeof import("../../../extensions/discord/runtime-api.js").unpinMessageDiscord; + createThread: typeof import("../../../extensions/discord/runtime-api.js").createThreadDiscord; + editChannel: typeof import("../../../extensions/discord/runtime-api.js").editChannelDiscord; }; }; slack: { - listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryPeersLive; - probeSlack: typeof import("../../../extensions/slack/src/probe.js").probeSlack; - resolveChannelAllowlist: typeof import("../../../extensions/slack/src/resolve-channels.js").resolveSlackChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/slack/src/resolve-users.js").resolveSlackUserAllowlist; - sendMessageSlack: typeof import("../../../extensions/slack/src/send.js").sendMessageSlack; - monitorSlackProvider: typeof import("../../../extensions/slack/src/index.js").monitorSlackProvider; + listDirectoryGroupsLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryPeersLive; + probeSlack: typeof import("../../../extensions/slack/runtime-api.js").probeSlack; + resolveChannelAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackChannelAllowlist; + resolveUserAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackUserAllowlist; + sendMessageSlack: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; + monitorSlackProvider: typeof import("../../../extensions/slack/runtime-api.js").monitorSlackProvider; handleSlackAction: typeof import("../../agents/tools/slack-actions.js").handleSlackAction; }; telegram: { - auditGroupMembership: typeof import("../../../extensions/telegram/src/audit.js").auditTelegramGroupMembership; - collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/src/audit.js").collectTelegramUnmentionedGroupIds; - probeTelegram: typeof import("../../../extensions/telegram/src/probe.js").probeTelegram; - resolveTelegramToken: typeof import("../../../extensions/telegram/src/token.js").resolveTelegramToken; - sendMessageTelegram: typeof import("../../../extensions/telegram/src/send.js").sendMessageTelegram; - sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram; - monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider; - messageActions: typeof import("../../../extensions/telegram/src/channel-actions.js").telegramMessageActions; + auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; + collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/api.js").collectTelegramUnmentionedGroupIds; + probeTelegram: typeof import("../../../extensions/telegram/runtime-api.js").probeTelegram; + resolveTelegramToken: typeof import("../../../extensions/telegram/runtime-api.js").resolveTelegramToken; + sendMessageTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram; + sendPollTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendPollTelegram; + monitorTelegramProvider: typeof import("../../../extensions/telegram/runtime-api.js").monitorTelegramProvider; + messageActions: typeof import("../../../extensions/telegram/runtime-api.js").telegramMessageActions; threadBindings: { - setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/src/thread-bindings.js").setTelegramThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/src/thread-bindings.js").setTelegramThreadBindingMaxAgeBySessionKey; + setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingMaxAgeBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/telegram/src/send.js").sendTypingTelegram; + pulse: typeof import("../../../extensions/telegram/runtime-api.js").sendTypingTelegram; start: (params: { to: string; accountId?: string; @@ -166,8 +166,8 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/telegram/src/send.js").editMessageTelegram; - editReplyMarkup: typeof import("../../../extensions/telegram/src/send.js").editMessageReplyMarkupTelegram; + editMessage: typeof import("../../../extensions/telegram/runtime-api.js").editMessageTelegram; + editReplyMarkup: typeof import("../../../extensions/telegram/runtime-api.js").editMessageReplyMarkupTelegram; clearReplyMarkup: ( chatIdInput: string | number, messageIdInput: string | number, @@ -180,35 +180,35 @@ export type PluginRuntimeChannel = { cfg?: ReturnType; }, ) => Promise<{ ok: true; messageId: string; chatId: string }>; - deleteMessage: typeof import("../../../extensions/telegram/src/send.js").deleteMessageTelegram; - renameTopic: typeof import("../../../extensions/telegram/src/send.js").renameForumTopicTelegram; - pinMessage: typeof import("../../../extensions/telegram/src/send.js").pinMessageTelegram; - unpinMessage: typeof import("../../../extensions/telegram/src/send.js").unpinMessageTelegram; + deleteMessage: typeof import("../../../extensions/telegram/runtime-api.js").deleteMessageTelegram; + renameTopic: typeof import("../../../extensions/telegram/runtime-api.js").renameForumTopicTelegram; + pinMessage: typeof import("../../../extensions/telegram/runtime-api.js").pinMessageTelegram; + unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram; }; }; signal: { - probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal; - sendMessageSignal: typeof import("../../../extensions/signal/src/send.js").sendMessageSignal; - monitorSignalProvider: typeof import("../../../extensions/signal/src/index.js").monitorSignalProvider; + probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; + sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; + monitorSignalProvider: typeof import("../../../extensions/signal/runtime-api.js").monitorSignalProvider; messageActions: typeof import("../../channels/plugins/actions/signal.js").signalMessageActions; }; imessage: { - monitorIMessageProvider: typeof import("../../../extensions/imessage/src/monitor.js").monitorIMessageProvider; - probeIMessage: typeof import("../../../extensions/imessage/src/probe.js").probeIMessage; - sendMessageIMessage: typeof import("../../../extensions/imessage/src/send.js").sendMessageIMessage; + monitorIMessageProvider: typeof import("../../../extensions/imessage/runtime-api.js").monitorIMessageProvider; + probeIMessage: typeof import("../../../extensions/imessage/runtime-api.js").probeIMessage; + sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage; }; whatsapp: { - getActiveWebListener: typeof import("../../../extensions/whatsapp/src/active-listener.js").getActiveWebListener; - getWebAuthAgeMs: typeof import("../../../extensions/whatsapp/src/auth-store.js").getWebAuthAgeMs; - logoutWeb: typeof import("../../../extensions/whatsapp/src/auth-store.js").logoutWeb; - logWebSelfId: typeof import("../../../extensions/whatsapp/src/auth-store.js").logWebSelfId; - readWebSelfId: typeof import("../../../extensions/whatsapp/src/auth-store.js").readWebSelfId; - webAuthExists: typeof import("../../../extensions/whatsapp/src/auth-store.js").webAuthExists; - sendMessageWhatsApp: typeof import("../../../extensions/whatsapp/src/send.js").sendMessageWhatsApp; - sendPollWhatsApp: typeof import("../../../extensions/whatsapp/src/send.js").sendPollWhatsApp; - loginWeb: typeof import("../../../extensions/whatsapp/src/login.js").loginWeb; - startWebLoginWithQr: typeof import("../../../extensions/whatsapp/src/login-qr.js").startWebLoginWithQr; - waitForWebLogin: typeof import("../../../extensions/whatsapp/src/login-qr.js").waitForWebLogin; + getActiveWebListener: typeof import("../../../extensions/whatsapp/runtime-api.js").getActiveWebListener; + getWebAuthAgeMs: typeof import("../../../extensions/whatsapp/runtime-api.js").getWebAuthAgeMs; + logoutWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").logoutWeb; + logWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").logWebSelfId; + readWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").readWebSelfId; + webAuthExists: typeof import("../../../extensions/whatsapp/runtime-api.js").webAuthExists; + sendMessageWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendMessageWhatsApp; + sendPollWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendPollWhatsApp; + loginWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").loginWeb; + startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; + waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; handleWhatsAppAction: typeof import("../../agents/tools/whatsapp-actions.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index e5951a1ce57..2ca6f6c035a 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -39,7 +39,7 @@ export type PluginRuntimeCore = { formatNativeDependencyHint: typeof import("./native-deps.js").formatNativeDependencyHint; }; media: { - loadWebMedia: typeof import("../../../extensions/whatsapp/src/media.js").loadWebMedia; + loadWebMedia: typeof import("../../../extensions/whatsapp/runtime-api.js").loadWebMedia; detectMime: typeof import("../../media/mime.js").detectMime; mediaKindFromMime: typeof import("../../media/constants.js").mediaKindFromMime; isVoiceCompatibleAudio: typeof import("../../media/audio.js").isVoiceCompatibleAudio; diff --git a/src/test-utils/imessage-test-plugin.ts b/src/test-utils/imessage-test-plugin.ts index 201ad3f9897..62362fe5712 100644 --- a/src/test-utils/imessage-test-plugin.ts +++ b/src/test-utils/imessage-test-plugin.ts @@ -1,4 +1,4 @@ -import { normalizeIMessageHandle } from "../../extensions/imessage/src/targets.js"; +import { normalizeIMessageHandle } from "../../extensions/imessage/api.js"; import { imessageOutbound } from "../../test/channel-outbounds.js"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js"; import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; From 829ea70519c9f9abed646f8decf8bbb0402c09dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:38:05 -0700 Subject: [PATCH 110/393] fix: remove duplicate setup helper imports --- extensions/discord/src/setup-core.ts | 1 - extensions/discord/src/setup-surface.ts | 1 - extensions/imessage/src/setup-core.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 3bf1878b1a1..a05a9af65b1 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,5 +1,4 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; -import { formatDocsLink } from "openclaw/plugin-sdk/discord"; import { DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 0505681ce0f..d27c7862c99 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,4 +1,3 @@ -import { formatDocsLink } from "openclaw/plugin-sdk/discord"; import { type OpenClawConfig, promptLegacyChannelAllowFrom, diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 57773129ba6..6ea7382106a 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,4 +1,3 @@ -import { formatDocsLink } from "openclaw/plugin-sdk/imessage"; import { createPatchedAccountSetupAdapter, parseSetupEntriesAllowingWildcard, From 7b61b025ff903857cf12b4df062fab118f418655 Mon Sep 17 00:00:00 2001 From: Menglin Li Date: Wed, 18 Mar 2026 00:44:31 +0800 Subject: [PATCH 111/393] fix(compaction): break safeguard cancel loop for sessions with no summarizable messages (#41981) (#42215) Merged via squash. Prepared head SHA: 7ce6bd834e8653561f5389b8756bfab7664ab9f3 Co-authored-by: lml2468 <39320777+lml2468@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + .../monitor/message-handler.preflight.test.ts | 1 - .../compaction-safeguard.test.ts | 130 +++++++++++++++++- .../pi-extensions/compaction-safeguard.ts | 29 +++- 4 files changed, 155 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 276dc3526c5..a5dfdbd71e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ Docs: https://docs.openclaw.ai - Gateway/exec approvals: surface requested env override keys in gateway-host approval prompts so operators can review surviving env context without inheriting noisy base host env. - Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr. - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. +- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. ### Fixes diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 0067de03c4e..bd55cd2ead2 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -389,7 +389,6 @@ describe("preflightDiscordMessage", () => { id: "m-webhook-hydrated-1", channelId: threadId, content: "", - webhookId: undefined, author: { id: "relay-bot-1", bot: true, diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 882099f3569..509bbdd25b2 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -138,10 +138,31 @@ async function runCompactionScenario(params: { }); const result = (await compactionHandler(params.event, mockContext)) as { cancel?: boolean; + compaction?: { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + }; }; return { result, getApiKeyMock }; } +function expectCompactionResult(result: { + cancel?: boolean; + compaction?: { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + }; +}) { + expect(result.cancel).not.toBe(true); + expect(result.compaction).toBeDefined(); + if (!result.compaction) { + throw new Error("Expected compaction result"); + } + return result.compaction; +} + describe("compaction-safeguard tool failures", () => { it("formats tool failures with meta and summary", () => { const messages: AgentMessage[] = [ @@ -1524,10 +1545,117 @@ describe("compaction-safeguard double-compaction guard", () => { event: mockEvent, apiKey: "sk-test", // pragma: allowlist secret }); - expect(result).toEqual({ cancel: true }); + const compaction = expectCompactionResult(result); + // After fix for #41981: returns a compaction result (not cancel) to write + // a boundary entry and break the re-trigger loop. + // buildStructuredFallbackSummary(undefined) produces a minimal structured summary + expect(compaction.summary).toContain("## Decisions"); + expect(compaction.summary).toContain("No prior history."); + expect(compaction.summary).toContain("## Open TODOs"); + expect(compaction.firstKeptEntryId).toBe("entry-1"); + expect(compaction.tokensBefore).toBe(1500); expect(getApiKeyMock).not.toHaveBeenCalled(); }); + it("returns compaction result with structured fallback summary sections", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-2", + tokensBefore: 2000, + previousSummary: "## Decisions\nUsed approach A.", + fileOps: { read: [], edited: [], written: [] }, + settings: { reserveTokens: 16384 }, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + const { result } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "sk-test", // pragma: allowlist secret + }); + const compaction = expectCompactionResult(result); + // Fallback preserves previous summary when it has required sections + expect(compaction.summary).toContain("## Decisions"); + expect(compaction.summary).toContain("## Open TODOs"); + expect(compaction.firstKeptEntryId).toBe("entry-2"); + }); + + it("writes boundary again on repeated empty preparation (no cancel loop after new assistant message)", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-3", + tokensBefore: 1000, + fileOps: { read: [], edited: [], written: [] }, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + + // First call — writes boundary + const { result: result1 } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "sk-test", // pragma: allowlist secret + }); + const compaction1 = expectCompactionResult(result1); + expect(compaction1.summary).toContain("## Decisions"); + + // Simulate: after the boundary, a new assistant message arrives, SDK + // triggers compaction again with another empty preparation. The safeguard + // must write another boundary (not cancel) to avoid re-entering the + // cancel loop described in the maintainer review. + const { result: result2 } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "sk-test", // pragma: allowlist secret + }); + const compaction2 = expectCompactionResult(result2); + expect(compaction2.summary).toContain("## Decisions"); + expect(compaction2.firstKeptEntryId).toBe("entry-3"); + }); + + it("does not write boundary when turnPrefixMessages has real content (split-turn)", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [ + { role: "user" as const, content: "real turn prefix content" }, + ] as AgentMessage[], + firstKeptEntryId: "entry-4", + tokensBefore: 2000, + fileOps: { read: [], edited: [], written: [] }, + isSplitTurn: true, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + const { result } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: null, + }); + // Should NOT take the boundary fast-path — falls through to normal compaction + // (which cancels due to no API key, but that's the expected normal path) + expect(result).toEqual({ cancel: true }); + }); + it("continues when messages include real conversation content", async () => { const sessionManager = stubSessionManager(); const model = createAnthropicModelFixture(); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 4461b97d3e0..92332140656 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -702,11 +702,32 @@ async function readWorkspaceContextForSummary(): Promise { export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions: eventInstructions, signal } = event; - if (!preparation.messagesToSummarize.some(isRealConversationMessage)) { - log.warn( - "Compaction safeguard: cancelling compaction with no real conversation messages to summarize.", + const hasRealSummarizable = preparation.messagesToSummarize.some(isRealConversationMessage); + const hasRealTurnPrefix = preparation.turnPrefixMessages.some(isRealConversationMessage); + if (!hasRealSummarizable && !hasRealTurnPrefix) { + // When there are no summarizable messages AND no real turn-prefix content, + // cancelling compaction leaves context unchanged but the SDK re-triggers + // _checkCompaction after every assistant response — creating a cancel loop + // that blocks cron lanes (#41981). + // + // Strategy: always return a minimal compaction result so the SDK writes a + // boundary entry. The SDK's prepareCompaction() returns undefined when the + // last entry is a compaction, which blocks immediate re-triggering within + // the same turn. After a new assistant message arrives, if the SDK triggers + // compaction again with an empty preparation, we write another boundary — + // this is bounded to at most one boundary per LLM round-trip, not a tight + // loop. + log.info( + "Compaction safeguard: no real conversation messages to summarize; writing compaction boundary to suppress re-trigger loop.", ); - return { cancel: true }; + const fallbackSummary = buildStructuredFallbackSummary(preparation.previousSummary); + return { + compaction: { + summary: fallbackSummary, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + }, + }; } const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps); const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles); From 2145eb5908c0f625988f2d284db1762ee3af024e Mon Sep 17 00:00:00 2001 From: Jonathan Jing Date: Tue, 17 Mar 2026 09:46:56 -0700 Subject: [PATCH 112/393] feat(mattermost): add retry logic and timeout handling for DM channel creation (#42398) Merged via squash. Prepared head SHA: 3db47be907decd78116603c6ab4a48ff91eb2c25 Co-authored-by: JonathanJing <17068507+JonathanJing@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm --- CHANGELOG.md | 1 + docs/channels/mattermost.md | 29 ++ extensions/mattermost/src/config-schema.ts | 28 ++ .../src/mattermost/client.retry.test.ts | 466 ++++++++++++++++++ .../mattermost/src/mattermost/client.ts | 257 ++++++++++ .../mattermost/src/mattermost/send.test.ts | 159 +++++- extensions/mattermost/src/mattermost/send.ts | 67 ++- extensions/mattermost/src/types.ts | 11 + 8 files changed, 1005 insertions(+), 13 deletions(-) create mode 100644 extensions/mattermost/src/mattermost/client.retry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a5dfdbd71e6..f24d843e508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai - macOS/launch at login: stop emitting `KeepAlive` for the desktop app launch agent so OpenClaw no longer relaunches immediately after a manual quit while launch at login remains enabled. (#40213) Thanks @stablegenius49. - ACP/gateway startup: use direct Telegram and Discord startup/status helpers instead of routing probes through the plugin runtime, and prepend the selected daemon Node bin dir to service PATH so plugin-local installs can still find `npm` and `pnpm`. - ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime. +- Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing. ## 2026.3.13 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 2ceb6c17626..41f6ffa19a0 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -191,6 +191,35 @@ OpenClaw resolves them **user-first**: If you need deterministic behavior, always use the explicit prefixes (`user:` / `channel:`). +## DM channel retry + +When OpenClaw sends to a Mattermost DM target and needs to resolve the direct channel first, it +retries transient direct-channel creation failures by default. + +Use `channels.mattermost.dmChannelRetry` to tune that behavior globally for the Mattermost plugin, +or `channels.mattermost.accounts..dmChannelRetry` for one account. + +```json5 +{ + channels: { + mattermost: { + dmChannelRetry: { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 10000, + timeoutMs: 30000, + }, + }, + }, +} +``` + +Notes: + +- This applies only to DM channel creation (`/api/v4/channels/direct`), not every Mattermost API call. +- Retries apply to transient failures such as rate limits, 5xx responses, and network or timeout errors. +- 4xx client errors other than `429` are treated as permanent and are not retried. + ## Reactions (message tool) - Use `message action=react` with `channel=mattermost`. diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 16ee615454c..d578de86e9a 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -9,6 +9,32 @@ import { z } from "zod"; import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; +const DmChannelRetrySchema = z + .object({ + /** Maximum number of retry attempts for DM channel creation (default: 3) */ + maxRetries: z.number().int().min(0).max(10).optional(), + /** Initial delay in milliseconds before first retry (default: 1000) */ + initialDelayMs: z.number().int().min(100).max(60000).optional(), + /** Maximum delay in milliseconds between retries (default: 10000) */ + maxDelayMs: z.number().int().min(1000).max(60000).optional(), + /** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */ + timeoutMs: z.number().int().min(5000).max(120000).optional(), + }) + .strict() + .refine( + (data) => { + if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) { + return data.initialDelayMs <= data.maxDelayMs; + } + return true; + }, + { + message: "initialDelayMs must be less than or equal to maxDelayMs", + path: ["initialDelayMs"], + }, + ) + .optional(); + const MattermostSlashCommandsSchema = z .object({ /** Enable native slash commands. "auto" resolves to false (opt-in). */ @@ -58,6 +84,8 @@ const MattermostAccountSchemaBase = z allowedSourceIps: z.array(z.string()).optional(), }) .optional(), + /** Retry configuration for DM channel creation */ + dmChannelRetry: DmChannelRetrySchema, }) .strict(); diff --git a/extensions/mattermost/src/mattermost/client.retry.test.ts b/extensions/mattermost/src/mattermost/client.retry.test.ts new file mode 100644 index 00000000000..c5f62357fe4 --- /dev/null +++ b/extensions/mattermost/src/mattermost/client.retry.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { createMattermostClient, createMattermostDirectChannelWithRetry } from "./client.js"; + +describe("createMattermostDirectChannelWithRetry", () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + function createMockClient() { + return createMattermostClient({ + baseUrl: "https://mattermost.example.com", + botToken: "test-token", + fetchImpl: mockFetch as unknown as typeof fetch, + }); + } + + function createFetchFailedError(params: { message: string; code?: string }): TypeError { + const cause = Object.assign(new Error(params.message), { + code: params.code, + }); + return Object.assign(new TypeError("fetch failed"), { cause }); + } + + it("succeeds on first attempt without retries", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-123" }), + } as Response); + + const client = createMockClient(); + const onRetry = vi.fn(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + onRetry, + }); + + expect(result.id).toBe("dm-channel-123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("retries on 429 rate limit error and succeeds", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 429, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Too many requests" }), + text: async () => "Too many requests", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-456" }), + } as Response); + + const client = createMockClient(); + const onRetry = vi.fn(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + onRetry, + }); + + expect(result.id).toBe("dm-channel-456"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith( + 1, + expect.any(Number), + expect.objectContaining({ message: expect.stringContaining("429") }), + ); + }); + + it("retries on port 443 connection errors (not misclassified as 4xx)", async () => { + // This tests that port numbers like :443 don't trigger false 4xx classification + mockFetch + .mockRejectedValueOnce(new Error("connect ECONNRESET 104.18.32.10:443")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-port" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + // Should retry and succeed on second attempt (port 443 should NOT be treated as 4xx) + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.id).toBe("dm-channel-port"); + }); + + it("does not retry on 400 even if error message contains '429' text", async () => { + // This tests that "429" in error detail doesn't trigger false rate-limit retry + // e.g., "Invalid user ID: 4294967295" should NOT be retried + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Invalid user ID: 4294967295" }), + text: async () => "Invalid user ID: 4294967295", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow(); + + // Should not retry - only called once (400 is a client error, even though message contains "429") + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("retries on 5xx server errors", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 503, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Service unavailable" }), + text: async () => "Service unavailable", + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 502, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Bad gateway" }), + text: async () => "Bad gateway", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-789" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + expect(result.id).toBe("dm-channel-789"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("retries on network errors", async () => { + mockFetch + .mockRejectedValueOnce(new Error("Network error: connection refused")) + .mockRejectedValueOnce(new Error("ECONNRESET")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-abc" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + expect(result.id).toBe("dm-channel-abc"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("retries on fetch failed errors when the cause carries a transient code", async () => { + mockFetch + .mockRejectedValueOnce( + createFetchFailedError({ + message: "connect ECONNREFUSED 127.0.0.1:81", + code: "ECONNREFUSED", + }), + ) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-fetch-failed" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + expect(result.id).toBe("dm-channel-fetch-failed"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not retry on 4xx client errors (except 429)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Bad request" }), + text: async () => "Bad request", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("400"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not retry on 404 not found", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "User not found" }), + text: async () => "User not found", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("404"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws after exhausting all retries", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Service unavailable" }), + text: async () => "Service unavailable", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 2, + initialDelayMs: 10, + }), + ).rejects.toThrow(); + + expect(mockFetch).toHaveBeenCalledTimes(3); // initial + 2 retries + }); + + it("respects custom timeout option and aborts fetch", async () => { + let abortSignal: AbortSignal | undefined; + let abortListenerCalled = false; + + mockFetch.mockImplementationOnce((url, init) => { + abortSignal = init?.signal; + if (abortSignal) { + abortSignal.addEventListener("abort", () => { + abortListenerCalled = true; + }); + } + // Return a promise that rejects when aborted, otherwise never resolves + return new Promise((_, reject) => { + if (abortSignal) { + const checkAbort = () => { + if (abortSignal?.aborted) { + reject(new Error("AbortError")); + } else { + setTimeout(checkAbort, 10); + } + }; + setTimeout(checkAbort, 10); + } + }); + }); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + timeoutMs: 50, + maxRetries: 0, + initialDelayMs: 10, + }), + ).rejects.toThrow(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(abortSignal).toBeDefined(); + expect(abortListenerCalled).toBe(true); + }); + + it("uses exponential backoff with jitter between retries", async () => { + const delays: number[] = []; + mockFetch + .mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable")) + .mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-delay" }), + } as Response); + + const client = createMockClient(); + + await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + onRetry: (attempt, delayMs) => { + delays.push(delayMs); + }, + }); + + expect(delays).toHaveLength(2); + // First retry: exponentialDelay = 100ms, jitter = 0-100ms, total = 100-200ms + expect(delays[0]).toBeGreaterThanOrEqual(100); + expect(delays[0]).toBeLessThanOrEqual(200); + // Second retry: exponentialDelay = 200ms, jitter = 0-200ms, total = 200-400ms + expect(delays[1]).toBeGreaterThanOrEqual(200); + expect(delays[1]).toBeLessThanOrEqual(400); + }); + + it("respects maxDelayMs cap", async () => { + const delays: number[] = []; + mockFetch + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-max" }), + } as Response); + + const client = createMockClient(); + + await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 4, + initialDelayMs: 1000, + maxDelayMs: 2500, + onRetry: (attempt, delayMs) => { + delays.push(delayMs); + }, + }); + + expect(delays).toHaveLength(4); + // All delays should be capped at maxDelayMs + delays.forEach((delay) => { + expect(delay).toBeLessThanOrEqual(2500); + }); + }); + + it("does not retry on 4xx errors even if message contains retryable keywords", async () => { + // This tests the fix for false positives where a 400 error with "timeout" in the message + // would incorrectly be retried + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Request timeout: connection timed out" }), + text: async () => "Request timeout: connection timed out", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("400"); + + // Should not retry - only called once + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not retry on 403 Forbidden even with 'abort' in message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Request aborted: forbidden" }), + text: async () => "Request aborted: forbidden", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("403"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("passes AbortSignal to fetch for timeout support", async () => { + let capturedSignal: AbortSignal | undefined; + mockFetch.mockImplementationOnce((url, init) => { + capturedSignal = init?.signal; + return Promise.resolve({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-signal" }), + } as Response); + }); + + const client = createMockClient(); + await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + timeoutMs: 5000, + }); + + expect(capturedSignal).toBeDefined(); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + }); + + it("retries on 5xx even if error message contains 4xx substring", async () => { + // This tests the fix for the ordering bug: 503 with "upstream 404" should be retried + mockFetch + .mockRejectedValueOnce(new Error("Mattermost API 503: upstream returned 404 Not Found")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-5xx-with-404" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + // Should retry and succeed on second attempt + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.id).toBe("dm-channel-5xx-with-404"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index 1a8219340b9..c514160590f 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -168,13 +168,270 @@ export async function sendMattermostTyping( export async function createMattermostDirectChannel( client: MattermostClient, userIds: string[], + signal?: AbortSignal, ): Promise { return await client.request("/channels/direct", { method: "POST", body: JSON.stringify(userIds), + signal, }); } +export type CreateDmChannelRetryOptions = { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Initial delay in milliseconds (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds (default: 10000) */ + maxDelayMs?: number; + /** Timeout for each individual request in milliseconds (default: 30000) */ + timeoutMs?: number; + /** Optional logger for retry events */ + onRetry?: (attempt: number, delayMs: number, error: Error) => void; +}; + +const RETRYABLE_NETWORK_ERROR_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ECONNABORTED", + "ENOTFOUND", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", + "EPIPE", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_DNS_RESOLVE_FAILED", + "UND_ERR_CONNECT", + "UND_ERR_SOCKET", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", +]); + +const RETRYABLE_NETWORK_ERROR_NAMES = new Set([ + "AbortError", + "TimeoutError", + "ConnectTimeoutError", + "HeadersTimeoutError", + "BodyTimeoutError", +]); + +const RETRYABLE_NETWORK_MESSAGE_SNIPPETS = [ + "network error", + "timeout", + "timed out", + "abort", + "connection refused", + "econnreset", + "econnrefused", + "etimedout", + "enotfound", + "socket hang up", + "getaddrinfo", +]; + +/** + * Creates a Mattermost DM channel with exponential backoff retry logic. + * Retries on transient errors (429, 5xx, network errors) but not on + * client errors (4xx except 429) or permanent failures. + */ +export async function createMattermostDirectChannelWithRetry( + client: MattermostClient, + userIds: string[], + options: CreateDmChannelRetryOptions = {}, +): Promise { + const { + maxRetries = 3, + initialDelayMs = 1000, + maxDelayMs = 10000, + timeoutMs = 30000, + onRetry, + } = options; + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + // Use AbortController for per-request timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const result = await createMattermostDirectChannel(client, userIds, controller.signal); + return result; + } finally { + clearTimeout(timeoutId); + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + // Don't retry on the last attempt + if (attempt >= maxRetries) { + break; + } + + // Check if error is retryable + if (!isRetryableError(lastError)) { + throw lastError; + } + + // Calculate exponential backoff delay with full-jitter + // Jitter is proportional to the exponential delay, not a fixed 1000ms + // This ensures backoff behaves correctly for small delay configurations + const exponentialDelay = initialDelayMs * Math.pow(2, attempt); + const jitter = Math.random() * exponentialDelay; + const delayMs = Math.min(exponentialDelay + jitter, maxDelayMs); + + if (onRetry) { + onRetry(attempt + 1, delayMs, lastError); + } + + // Wait before retrying + await sleep(delayMs); + } + } + + throw lastError ?? new Error("Failed to create DM channel after retries"); +} + +function isRetryableError(error: Error): boolean { + const candidates = collectErrorCandidates(error); + const messages = candidates + .map((candidate) => readErrorMessage(candidate)?.toLowerCase()) + .filter((message): message is string => Boolean(message)); + + // Retry on 5xx server errors FIRST (before checking 4xx) + // Use "mattermost api" prefix to avoid matching port numbers (e.g., :443) or IP octets + // This prevents misclassification when a 5xx error detail contains a 4xx substring + // e.g., "Mattermost API 503: upstream returned 404" + if (messages.some((message) => /mattermost api 5\d{2}\b/.test(message))) { + return true; + } + + // Check for explicit 429 rate limiting FIRST (before generic "429" text match) + // This avoids retrying when error detail contains "429" but it's not the status code + if ( + messages.some( + (message) => /mattermost api 429\b/.test(message) || message.includes("too many requests"), + ) + ) { + return true; + } + + // Check for explicit 4xx status codes - these are client errors and should NOT be retried + // (except 429 which is handled above) + // Use "mattermost api" prefix to avoid matching port numbers like :443 + for (const message of messages) { + const clientErrorMatch = message.match(/mattermost api (4\d{2})\b/); + if (!clientErrorMatch) { + continue; + } + const statusCode = parseInt(clientErrorMatch[1], 10); + if (statusCode >= 400 && statusCode < 500) { + return false; + } + } + + // Retry on network/transient errors only if no explicit Mattermost API status code is present + // This avoids false positives like: + // - "400 Bad Request: connection timed out" (has status code) + // - "connect ECONNRESET 104.18.32.10:443" (has port number, not status) + const hasMattermostApiStatusCode = messages.some((message) => + /mattermost api \d{3}\b/.test(message), + ); + if (hasMattermostApiStatusCode) { + return false; + } + + const codes = candidates + .map((candidate) => readErrorCode(candidate)) + .filter((code): code is string => Boolean(code)); + if (codes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) { + return true; + } + + const names = candidates + .map((candidate) => readErrorName(candidate)) + .filter((name): name is string => Boolean(name)); + if (names.some((name) => RETRYABLE_NETWORK_ERROR_NAMES.has(name))) { + return true; + } + + return messages.some((message) => + RETRYABLE_NETWORK_MESSAGE_SNIPPETS.some((pattern) => message.includes(pattern)), + ); +} + +function collectErrorCandidates(error: unknown): unknown[] { + const queue: unknown[] = [error]; + const seen = new Set(); + const candidates: unknown[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + candidates.push(current); + + if (typeof current !== "object") { + continue; + } + + const nested = current as { + cause?: unknown; + reason?: unknown; + errors?: unknown; + }; + queue.push(nested.cause, nested.reason); + if (Array.isArray(nested.errors)) { + queue.push(...nested.errors); + } + } + + return candidates; +} + +function readErrorMessage(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const message = (error as { message?: unknown }).message; + return typeof message === "string" && message.trim() ? message : undefined; +} + +function readErrorName(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const name = (error as { name?: unknown }).name; + return typeof name === "string" && name.trim() ? name : undefined; +} + +function readErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const { code, errno } = error as { + code?: unknown; + errno?: unknown; + }; + const raw = typeof code === "string" && code.trim() ? code : errno; + if (typeof raw === "string" && raw.trim()) { + return raw.trim().toUpperCase(); + } + if (typeof raw === "number" && Number.isFinite(raw)) { + return String(raw); + } + return undefined; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export async function createMattermostPost( client: MattermostClient, params: { diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 15cf05eb541..784b27677e6 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -13,9 +13,11 @@ const mockState = vi.hoisted(() => ({ accountId: "default", botToken: "bot-token", baseUrl: "https://mattermost.example.com", + config: {}, })), createMattermostClient: vi.fn(), createMattermostDirectChannel: vi.fn(), + createMattermostDirectChannelWithRetry: vi.fn(), createMattermostPost: vi.fn(), fetchMattermostChannelByName: vi.fn(), fetchMattermostMe: vi.fn(), @@ -37,6 +39,7 @@ vi.mock("./accounts.js", () => ({ vi.mock("./client.js", () => ({ createMattermostClient: mockState.createMattermostClient, createMattermostDirectChannel: mockState.createMattermostDirectChannel, + createMattermostDirectChannelWithRetry: mockState.createMattermostDirectChannelWithRetry, createMattermostPost: mockState.createMattermostPost, fetchMattermostChannelByName: mockState.fetchMattermostChannelByName, fetchMattermostMe: mockState.fetchMattermostMe, @@ -77,10 +80,12 @@ describe("sendMessageMattermost", () => { accountId: "default", botToken: "bot-token", baseUrl: "https://mattermost.example.com", + config: {}, }); mockState.loadOutboundMediaFromUrl.mockReset(); mockState.createMattermostClient.mockReset(); mockState.createMattermostDirectChannel.mockReset(); + mockState.createMattermostDirectChannelWithRetry.mockReset(); mockState.createMattermostPost.mockReset(); mockState.fetchMattermostChannelByName.mockReset(); mockState.fetchMattermostMe.mockReset(); @@ -91,6 +96,7 @@ describe("sendMessageMattermost", () => { resetMattermostOpaqueTargetCacheForTests(); mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-1" }); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-1" }); mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" }); mockState.fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]); mockState.fetchMattermostChannelByName.mockResolvedValue({ id: "town-square" }); @@ -105,6 +111,12 @@ describe("sendMessageMattermost", () => { }, }, }; + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "work", + botToken: "provided-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); await sendMessageMattermost("channel:town-square", "hello", { cfg: providedCfg as any, @@ -128,6 +140,12 @@ describe("sendMessageMattermost", () => { }, }; mockState.loadConfig.mockReturnValueOnce(runtimeCfg); + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "runtime-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); await sendMessageMattermost("channel:town-square", "hello"); @@ -146,6 +164,12 @@ describe("sendMessageMattermost", () => { contentType: "image/png", kind: "image", }); + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); await sendMessageMattermost("channel:town-square", "hello", { mediaUrl: "file:///tmp/agent-workspace/photo.png", @@ -169,6 +193,13 @@ describe("sendMessageMattermost", () => { }); it("builds interactive button props when buttons are provided", async () => { + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); + await sendMessageMattermost("channel:town-square", "Pick a model", { buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]], }); @@ -196,8 +227,13 @@ describe("sendMessageMattermost", () => { it("resolves a bare Mattermost user id as a DM target before upload", async () => { const userId = "dthcxgoxhifn3pwh65cut3ud3w"; + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); - mockState.createMattermostDirectChannel.mockResolvedValueOnce({ id: "dm-channel-1" }); mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({ buffer: Buffer.from("media-bytes"), fileName: "photo.png", @@ -211,7 +247,11 @@ describe("sendMessageMattermost", () => { }); expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId); - expect(mockState.createMattermostDirectChannel).toHaveBeenCalledWith({}, ["bot-user", userId]); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-user", userId], + expect.any(Object), + ); expect(mockState.uploadMattermostFile).toHaveBeenCalledWith( {}, expect.objectContaining({ @@ -223,6 +263,12 @@ describe("sendMessageMattermost", () => { it("falls back to a channel target when bare Mattermost id is not a user", async () => { const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa"; + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); mockState.fetchMattermostUser.mockRejectedValueOnce( new Error("Mattermost API 404 Not Found: user not found"), ); @@ -239,7 +285,7 @@ describe("sendMessageMattermost", () => { }); expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId); - expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); expect(mockState.uploadMattermostFile).toHaveBeenCalledWith( {}, expect.objectContaining({ @@ -337,11 +383,12 @@ describe("parseMattermostTarget", () => { // userIdResolutionCache and dmChannelCache are module singletons that survive across tests. // Using unique cache keys per test ensures full isolation without needing a cache reset API. describe("sendMessageMattermost user-first resolution", () => { - function makeAccount(token: string) { + function makeAccount(token: string, config = {}) { return { accountId: "default", botToken: token, baseUrl: "https://mattermost.example.com", + config, }; } @@ -350,6 +397,7 @@ describe("sendMessageMattermost user-first resolution", () => { mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-id" }); mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" }); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" }); }); @@ -362,7 +410,7 @@ describe("sendMessageMattermost user-first resolution", () => { const res = await sendMessageMattermost(userId, "hello"); expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); - expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); const params = mockState.createMattermostPost.mock.calls[0]?.[1]; expect(params.channelId).toBe("dm-channel-id"); expect(res.channelId).toBe("dm-channel-id"); @@ -379,7 +427,7 @@ describe("sendMessageMattermost user-first resolution", () => { const res = await sendMessageMattermost(channelId, "hello"); expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); - expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); const params = mockState.createMattermostPost.mock.calls[0]?.[1]; expect(params.channelId).toBe(channelId); expect(res.channelId).toBe(channelId); @@ -403,7 +451,7 @@ describe("sendMessageMattermost user-first resolution", () => { vi.clearAllMocks(); mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" }); - mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" }); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" }); mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB)); mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); @@ -417,11 +465,12 @@ describe("sendMessageMattermost user-first resolution", () => { // Unique token + id — explicit user: prefix bypasses probe, goes straight to DM const userId = "dddddd4444444444dddddd4444"; // 26 chars mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4")); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); const res = await sendMessageMattermost(`user:${userId}`, "hello"); expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); - expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); expect(res.channelId).toBe("dm-channel-id"); }); @@ -433,9 +482,101 @@ describe("sendMessageMattermost user-first resolution", () => { const res = await sendMessageMattermost(`channel:${chanId}`, "hello"); expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); - expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); const params = mockState.createMattermostPost.mock.calls[0]?.[1]; expect(params.channelId).toBe(chanId); expect(res.channelId).toBe(chanId); }); + + it("passes dmRetryOptions from opts to createMattermostDirectChannelWithRetry", async () => { + const userId = "ffffff6666666666ffffff6666"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-retry-opts-t6")); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + const retryOptions = { + maxRetries: 5, + initialDelayMs: 500, + maxDelayMs: 5000, + timeoutMs: 10000, + }; + + await sendMessageMattermost(`user:${userId}`, "hello", { + dmRetryOptions: retryOptions, + }); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining(retryOptions), + ); + }); + + it("uses dmChannelRetry from account config when opts.dmRetryOptions not provided", async () => { + const userId = "gggggg7777777777gggggg7777"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "token-retry-config-t7", + baseUrl: "https://mattermost.example.com", + config: { + dmChannelRetry: { + maxRetries: 4, + initialDelayMs: 2000, + maxDelayMs: 8000, + timeoutMs: 15000, + }, + }, + }); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + await sendMessageMattermost(`user:${userId}`, "hello"); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining({ + maxRetries: 4, + initialDelayMs: 2000, + maxDelayMs: 8000, + timeoutMs: 15000, + }), + ); + }); + + it("opts.dmRetryOptions overrides provided fields and preserves account defaults", async () => { + const userId = "hhhhhh8888888888hhhhhh8888"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "token-retry-override-t8", + baseUrl: "https://mattermost.example.com", + config: { + dmChannelRetry: { + maxRetries: 2, + initialDelayMs: 1000, + }, + }, + }); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + const overrideOptions = { + maxRetries: 7, + timeoutMs: 20000, + }; + + await sendMessageMattermost(`user:${userId}`, "hello", { + dmRetryOptions: overrideOptions, + }); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining(overrideOptions), + ); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining({ + initialDelayMs: 1000, + }), + ); + }); }); diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 4655dab2f7d..c589c8829a0 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -3,7 +3,7 @@ import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, - createMattermostDirectChannel, + createMattermostDirectChannelWithRetry, createMattermostPost, fetchMattermostChannelByName, fetchMattermostMe, @@ -12,6 +12,7 @@ import { normalizeMattermostBaseUrl, uploadMattermostFile, type MattermostUser, + type CreateDmChannelRetryOptions, } from "./client.js"; import { buildButtonProps, @@ -32,6 +33,8 @@ export type MattermostSendOpts = { props?: Record; buttons?: Array; attachmentText?: string; + /** Retry options for DM channel creation */ + dmRetryOptions?: CreateDmChannelRetryOptions; }; export type MattermostSendResult = { @@ -182,11 +185,40 @@ async function resolveChannelIdByName(params: { throw new Error(`Mattermost channel "#${name}" not found in any team the bot belongs to`); } -async function resolveTargetChannelId(params: { +type ResolveTargetChannelIdParams = { target: MattermostTarget; baseUrl: string; token: string; -}): Promise { + dmRetryOptions?: CreateDmChannelRetryOptions; + logger?: { debug?: (msg: string) => void; warn?: (msg: string) => void }; +}; + +function mergeDmRetryOptions( + base?: CreateDmChannelRetryOptions, + override?: CreateDmChannelRetryOptions, +): CreateDmChannelRetryOptions | undefined { + const merged: CreateDmChannelRetryOptions = { + maxRetries: override?.maxRetries ?? base?.maxRetries, + initialDelayMs: override?.initialDelayMs ?? base?.initialDelayMs, + maxDelayMs: override?.maxDelayMs ?? base?.maxDelayMs, + timeoutMs: override?.timeoutMs ?? base?.timeoutMs, + onRetry: override?.onRetry, + }; + + if ( + merged.maxRetries === undefined && + merged.initialDelayMs === undefined && + merged.maxDelayMs === undefined && + merged.timeoutMs === undefined && + merged.onRetry === undefined + ) { + return undefined; + } + + return merged; +} + +async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Promise { if (params.target.kind === "channel") { return params.target.id; } @@ -214,7 +246,20 @@ async function resolveTargetChannelId(params: { baseUrl: params.baseUrl, botToken: params.token, }); - const channel = await createMattermostDirectChannel(client, [botUser.id, userId]); + + const channel = await createMattermostDirectChannelWithRetry(client, [botUser.id, userId], { + ...params.dmRetryOptions, + onRetry: (attempt, delayMs, error) => { + // Call user's onRetry if provided + params.dmRetryOptions?.onRetry?.(attempt, delayMs, error); + // Log if verbose mode is enabled + if (params.logger) { + params.logger.warn?.( + `DM channel creation retry ${attempt} after ${delayMs}ms: ${error.message}`, + ); + } + }, + }); dmChannelCache.set(dmKey, channel.id); return channel.id; } @@ -232,6 +277,7 @@ async function resolveMattermostSendContext( opts: MattermostSendOpts = {}, ): Promise { const core = getCore(); + const logger = core.logging.getChildLogger({ module: "mattermost" }); const cfg = opts.cfg ?? core.config.loadConfig(); const account = resolveMattermostAccount({ cfg, @@ -262,10 +308,23 @@ async function resolveMattermostSendContext( : opaqueTarget?.kind === "channel" ? { kind: "channel" as const, id: opaqueTarget.id } : parseMattermostTarget(trimmedTo); + // Build retry options from account config, allowing opts to override + const accountRetryConfig: CreateDmChannelRetryOptions | undefined = account.config.dmChannelRetry + ? { + maxRetries: account.config.dmChannelRetry.maxRetries, + initialDelayMs: account.config.dmChannelRetry.initialDelayMs, + maxDelayMs: account.config.dmChannelRetry.maxDelayMs, + timeoutMs: account.config.dmChannelRetry.timeoutMs, + } + : undefined; + const dmRetryOptions = mergeDmRetryOptions(accountRetryConfig, opts.dmRetryOptions); + const channelId = await resolveTargetChannelId({ target, baseUrl, token, + dmRetryOptions, + logger: core.logging.shouldLogVerbose() ? logger : undefined, }); return { diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index f4038ac6920..e6fcc19098c 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -90,6 +90,17 @@ export type MattermostAccountConfig = { */ allowedSourceIps?: string[]; }; + /** Retry configuration for DM channel creation */ + dmChannelRetry?: { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Initial delay in milliseconds before first retry (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds between retries (default: 10000) */ + maxDelayMs?: number; + /** Timeout for each individual request in milliseconds (default: 30000) */ + timeoutMs?: number; + }; }; export type MattermostConfig = { From 6636ca87f47fb1822f491d315380c8ed9988fa9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:54:19 -0700 Subject: [PATCH 113/393] docs(hooks): clarify trust model and audit guidance --- docs/automation/webhook.md | 2 ++ docs/cli/security.md | 2 +- docs/gateway/security/index.md | 3 +++ src/config/schema.help.ts | 4 ++-- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index b35ee9d4469..38676a8fdbe 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -38,6 +38,7 @@ Every request must include the hook token. Prefer headers: - `Authorization: Bearer ` (recommended) - `x-openclaw-token: ` - Query-string tokens are rejected (`?token=...` returns `400`). +- Treat `hooks.token` holders as full-trust callers for the hook ingress surface on that gateway. Hook payload content is still untrusted, but this is not a separate non-owner auth boundary. ## Endpoints @@ -205,6 +206,7 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \ - Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. - Use a dedicated hook token; do not reuse gateway auth tokens. +- Prefer a dedicated hook agent with strict `tools.profile` and sandboxing so hook ingress has a narrower blast radius. - Repeated auth failures are rate-limited per client address to slow brute-force attempts. - If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection. - Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions. diff --git a/docs/cli/security.md b/docs/cli/security.md index 76a7ae75976..28b65f3629b 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -30,7 +30,7 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. -For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. +For webhook ingress, it warns when `hooks.token` reuses the Gateway token, when `hooks.defaultSessionKey` is unset, when `hooks.allowedAgentIds` is unrestricted, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records). It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 8193eb5ca2c..5fbd26a826e 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -243,7 +243,10 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | | `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | | `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | +| `hooks.token_reuse_gateway_token` | critical | Hook ingress token also unlocks Gateway auth | `hooks.token`, `gateway.auth.token` | no | | `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | +| `hooks.default_session_key_unset` | warn | Hook agent runs fan out into generated per-request sessions | `hooks.defaultSessionKey` | no | +| `hooks.allowed_agent_ids_unrestricted` | warn/critical | Authenticated hook callers may route to any configured agent | `hooks.allowedAgentIds` | no | | `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | | `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | | `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 82c07b176fb..4518d393ed2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1245,7 +1245,7 @@ export const FIELD_HELP: Record = { "hooks.path": "HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.", "hooks.token": - "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", + "Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", "hooks.defaultSessionKey": "Fallback session key used for hook deliveries when a request does not provide one through allowed channels. Use a stable but scoped key to avoid mixing unrelated automation conversations.", "hooks.allowRequestSessionKey": @@ -1253,7 +1253,7 @@ export const FIELD_HELP: Record = { "hooks.allowedSessionKeyPrefixes": "Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.", "hooks.allowedAgentIds": - "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.", + "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.", "hooks.maxBodyBytes": "Maximum accepted webhook payload size in bytes before the request is rejected. Keep this bounded to reduce abuse risk and protect memory usage under bursty integrations.", "hooks.presets": From bdf2c265a7b1b8c004d0f71e1c849d49cded5507 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 16:55:12 +0000 Subject: [PATCH 114/393] test: stabilize memory async search close --- src/memory/manager.async-search.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/memory/manager.async-search.test.ts b/src/memory/manager.async-search.test.ts index dca8cc52892..7250314cd55 100644 --- a/src/memory/manager.async-search.test.ts +++ b/src/memory/manager.async-search.test.ts @@ -87,6 +87,7 @@ describe("memory search async sync", () => { }); manager = await createMemoryManagerOrThrow(cfg); + (manager as unknown as { dirty: boolean }).dirty = true; await manager.search("hello"); await vi.waitFor(() => { expect((manager as unknown as { syncing: Promise | null }).syncing).toBeTruthy(); From 6d9bf6de9383d9f91741b17767fc8d30f9d0e0fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:58:12 -0700 Subject: [PATCH 115/393] refactor: narrow extension public seams --- docs/tools/plugin.md | 10 +++ extensions/discord/api.ts | 1 - extensions/imessage/api.ts | 1 - extensions/signal/api.ts | 1 - extensions/slack/api.ts | 1 - extensions/telegram/api.ts | 1 - extensions/whatsapp/api.ts | 1 - ....triggers.trigger-handling.test-harness.ts | 2 +- src/channels/plugins/contracts/registry.ts | 4 +- src/gateway/test-helpers.mocks.ts | 2 +- .../channel-import-guardrails.test.ts | 63 +++++++++++++++++++ src/plugin-sdk/discord.ts | 16 ++--- src/plugin-sdk/imessage.ts | 2 +- src/plugin-sdk/signal.ts | 6 +- src/plugin-sdk/slack.ts | 2 +- src/plugin-sdk/telegram.ts | 8 +-- src/plugin-sdk/whatsapp.ts | 23 ++++--- src/plugins/runtime/runtime-telegram.ts | 2 +- src/plugins/runtime/types-channel.ts | 2 +- 19 files changed, 109 insertions(+), 39 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 6d6a61b5e2c..a723978bdc7 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -957,6 +957,16 @@ authoring plugins: - `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older external plugins. Bundled plugins should not use it, and non-test imports emit a one-time deprecation warning outside test environments. +- Bundled extension internals remain private. External plugins should use only + `openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo + public seams under `extensions//index.js`, `api.js`, `runtime-api.js`, + `setup-entry.js`, and narrowly scoped files such as `login-qr-api.js`. Never + import `extensions//src/*` from core or from another extension. +- Repo seam split: + `extensions//api.js` is the helper/types barrel, + `extensions//runtime-api.js` is the runtime-only barrel, + `extensions//index.js` is the bundled plugin entry, + and `extensions//setup-entry.js` is the setup plugin entry. - `openclaw/plugin-sdk/telegram` for Telegram channel plugin types and shared channel-facing helpers. Built-in Telegram implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/discord` for Discord channel plugin types and shared channel-facing helpers. Built-in Discord implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/slack` for Slack channel plugin types and shared channel-facing helpers. Built-in Slack implementation internals stay private to the bundled extension. diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index 37235190586..858255c0495 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/account-inspect.js"; export * from "./src/accounts.js"; export * from "./src/actions/handle-action.guild-admin.js"; diff --git a/extensions/imessage/api.ts b/extensions/imessage/api.ts index ede4a8061ec..a311d13fec5 100644 --- a/extensions/imessage/api.ts +++ b/extensions/imessage/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/accounts.js"; export * from "./src/target-parsing-helpers.js"; export * from "./src/targets.js"; diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts index f35c45c2b4e..feaaa1c5835 100644 --- a/extensions/signal/api.ts +++ b/extensions/signal/api.ts @@ -1,2 +1 @@ -export * from "./runtime-api.js"; export * from "./src/accounts.js"; diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts index 9264ee7c358..37aaf02b027 100644 --- a/extensions/slack/api.ts +++ b/extensions/slack/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/account-inspect.js"; export * from "./src/accounts.js"; export * from "./src/actions.js"; diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index bb8b0907eca..d5960350c39 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/account-inspect.js"; export * from "./src/accounts.js"; export * from "./src/allow-from.js"; diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts index f35c45c2b4e..feaaa1c5835 100644 --- a/extensions/whatsapp/api.ts +++ b/extensions/whatsapp/api.ts @@ -1,2 +1 @@ -export * from "./runtime-api.js"; export * from "./src/accounts.js"; diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 2207023319d..9a831dde795 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -101,7 +101,7 @@ export function getWebSessionMocks(): AnyMocks { return webSessionMocks; } -vi.mock("../../extensions/whatsapp/api.js", () => webSessionMocks); +vi.mock("../../extensions/whatsapp/runtime-api.js", () => webSessionMocks); export const MAIN_SESSION_KEY = "agent:main:main"; diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index a343692622a..d651b6ef012 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -2,10 +2,10 @@ import { expect, vi } from "vitest"; import { __testing as discordThreadBindingTesting, createThreadBindingManager as createDiscordThreadBindingManager, -} from "../../../../extensions/discord/api.js"; +} from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; import { setMatrixRuntime } from "../../../../extensions/matrix/api.js"; -import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/api.js"; +import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { getSessionBindingService, diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 46bd08a8186..bfd2603bc0a 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -574,7 +574,7 @@ vi.mock("../commands/health.js", () => ({ vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock("../../extensions/whatsapp/api.js", () => ({ +vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ sendMessageWhatsApp: (...args: unknown[]) => (hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args), sendPollWhatsApp: (...args: unknown[]) => diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index b7a252987a5..7321adb1264 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -4,6 +4,36 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const ALLOWED_EXTENSION_PUBLIC_SEAMS = new Set([ + "api.js", + "index.js", + "login-qr-api.js", + "runtime-api.js", + "setup-entry.js", +]); +const GUARDED_CHANNEL_EXTENSIONS = new Set([ + "bluebubbles", + "discord", + "feishu", + "googlechat", + "imessage", + "irc", + "line", + "matrix", + "mattermost", + "msteams", + "nextcloud-talk", + "nostr", + "signal", + "slack", + "synology-chat", + "telegram", + "tlon", + "twitch", + "whatsapp", + "zalo", + "zalouser", +]); type GuardedSource = { path: string; @@ -186,6 +216,27 @@ function collectCoreSourceFiles(): string[] { return files; } +function collectExtensionImports(text: string): string[] { + return [...text.matchAll(/["']([^"']*extensions\/[^"']+\.(?:[cm]?[jt]sx?))["']/g)].map( + (match) => match[1] ?? "", + ); +} + +function expectOnlyApprovedExtensionSeams(file: string, imports: string[]): void { + for (const specifier of imports) { + const normalized = specifier.replaceAll("\\", "/"); + const extensionId = normalized.match(/extensions\/([^/]+)\//)?.[1] ?? null; + if (!extensionId || !GUARDED_CHANNEL_EXTENSIONS.has(extensionId)) { + continue; + } + const basename = normalized.split("/").at(-1) ?? ""; + expect( + ALLOWED_EXTENSION_PUBLIC_SEAMS.has(basename), + `${file} should only import approved extension seams, got ${specifier}`, + ).toBe(true); + } +} + describe("channel import guardrails", () => { it("keeps channel helper modules off their own SDK barrels", () => { for (const source of SAME_CHANNEL_SDK_GUARDS) { @@ -236,4 +287,16 @@ describe("channel import guardrails", () => { ); } }); + + it("keeps core extension imports limited to approved public seams", () => { + for (const file of collectCoreSourceFiles()) { + expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); + } + }); + + it("keeps extension-to-extension imports limited to approved public seams", () => { + for (const file of collectExtensionSourceFiles()) { + expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); + } + }); }); diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index d55a8157998..91bde97a5aa 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -13,7 +13,7 @@ export type { ThreadBindingManager, ThreadBindingRecord, ThreadBindingTargetKind, -} from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; export type { ChannelConfiguredBindingProvider, ChannelConfiguredBindingConversationRef, @@ -75,20 +75,20 @@ export { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "../../extensions/discord/api.js"; -export { collectDiscordAuditChannelIds } from "../../extensions/discord/api.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/runtime-api.js"; export { collectDiscordStatusIssues } from "../../extensions/discord/api.js"; export { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/api.js"; export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../extensions/discord/api.js"; -export { getGateway } from "../../extensions/discord/api.js"; -export { getPresence } from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; +export { getGateway } from "../../extensions/discord/runtime-api.js"; +export { getPresence } from "../../extensions/discord/runtime-api.js"; export { readDiscordComponentSpec } from "../../extensions/discord/api.js"; export { resolveDiscordChannelId } from "../../extensions/discord/api.js"; export { @@ -134,5 +134,5 @@ export { unpinMessageDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../extensions/discord/api.js"; -export { discordMessageActions } from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; +export { discordMessageActions } from "../../extensions/discord/runtime-api.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index ec769552348..adad1403eb6 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -42,4 +42,4 @@ export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { collectStatusIssuesFromLastError } from "./status-helpers.js"; -export { sendMessageIMessage } from "../../extensions/imessage/api.js"; +export { sendMessageIMessage } from "../../extensions/imessage/runtime-api.js"; diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index fda5ec6e7b9..f44dfa2f9ff 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -52,6 +52,6 @@ export { listSignalAccountIds, resolveDefaultSignalAccountId, } from "../../extensions/signal/api.js"; -export { resolveSignalReactionLevel } from "../../extensions/signal/api.js"; -export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/api.js"; -export { sendMessageSignal } from "../../extensions/signal/api.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/runtime-api.js"; +export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/runtime-api.js"; +export { sendMessageSignal } from "../../extensions/signal/runtime-api.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 50c08b51a2b..4b78d14480d 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -60,7 +60,7 @@ export { extractSlackToolSend, listSlackMessageActions } from "../../extensions/ export { buildSlackThreadingToolContext } from "../../extensions/slack/api.js"; export { parseSlackBlocksInput } from "../../extensions/slack/api.js"; export { handleSlackHttpRequest } from "../../extensions/slack/api.js"; -export { sendMessageSlack } from "../../extensions/slack/api.js"; +export { sendMessageSlack } from "../../extensions/slack/runtime-api.js"; export { deleteSlackMessage, downloadSlackFile, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index d4a35210e90..9a94e7c2d1c 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -19,7 +19,7 @@ export type { } from "../channels/plugins/types.adapters.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; export type { ResolvedTelegramAccount } from "../../extensions/telegram/api.js"; -export type { TelegramProbe } from "../../extensions/telegram/api.js"; +export type { TelegramProbe } from "../../extensions/telegram/runtime-api.js"; export type { TelegramButtonStyle, TelegramInlineButtons } from "../../extensions/telegram/api.js"; export type { StickerMetadata } from "../../extensions/telegram/api.js"; @@ -96,10 +96,10 @@ export { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../extensions/telegram/api.js"; +} from "../../extensions/telegram/runtime-api.js"; export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js"; -export { resolveTelegramToken } from "../../extensions/telegram/api.js"; -export { telegramMessageActions } from "../../extensions/telegram/api.js"; +export { resolveTelegramToken } from "../../extensions/telegram/runtime-api.js"; +export { telegramMessageActions } from "../../extensions/telegram/runtime-api.js"; export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js"; export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js"; export { diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index d613872cb33..74ab27dac2f 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,8 +1,11 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -export type { WebChannelStatus, WebMonitorTuning } from "../../extensions/whatsapp/api.js"; -export type { WebInboundMessage, WebListenerCloseReason } from "../../extensions/whatsapp/api.js"; +export type { WebChannelStatus, WebMonitorTuning } from "../../extensions/whatsapp/runtime-api.js"; +export type { + WebInboundMessage, + WebListenerCloseReason, +} from "../../extensions/whatsapp/runtime-api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -73,7 +76,7 @@ export { logoutWeb, pickWebChannel, webAuthExists, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { DEFAULT_WEB_MEDIA_BYTES, HEARTBEAT_PROMPT, @@ -81,28 +84,28 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { extractMediaPlaceholder, extractText, monitorWebInbox, -} from "../../extensions/whatsapp/api.js"; -export { loginWeb } from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; +export { loginWeb } from "../../extensions/whatsapp/runtime-api.js"; export { getDefaultLocalRoots, loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { createWaSocket, formatError, getStatusCode, waitForWaConnection, -} from "../../extensions/whatsapp/api.js"; -export { createWhatsAppLoginTool } from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; +export { createWhatsAppLoginTool } from "../../extensions/whatsapp/runtime-api.js"; diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index 42ce8d995a6..74b4de7e48e 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,4 +1,4 @@ -import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/api.js"; +import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/runtime-api.js"; import { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index d115a3a91e7..ee50b7dd02a 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -141,7 +141,7 @@ export type PluginRuntimeChannel = { }; telegram: { auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; - collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/api.js").collectTelegramUnmentionedGroupIds; + collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/runtime-api.js").collectTelegramUnmentionedGroupIds; probeTelegram: typeof import("../../../extensions/telegram/runtime-api.js").probeTelegram; resolveTelegramToken: typeof import("../../../extensions/telegram/runtime-api.js").resolveTelegramToken; sendMessageTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram; From ccf16cd8892402022439346ae1d23352e3707e9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 10:07:42 -0700 Subject: [PATCH 116/393] fix(gateway): clear trusted-proxy control ui scopes --- CHANGELOG.md | 1 + src/gateway/server.auth.control-ui.suite.ts | 53 +++++++++++++++---- .../server/ws-connection/message-handler.ts | 6 +-- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f24d843e508..fda1fff462f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. - Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path. - Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing. +- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity. - Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 44863f61f31..9452c26eb33 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -36,14 +36,12 @@ export function registerControlUiAndPairingSuite(): void { expectedOk: boolean; expectedErrorSubstring?: string; expectedErrorCode?: string; - expectStatusChecks: boolean; }> = [ { name: "allows trusted-proxy control ui operator without device identity", role: "operator", withUnpairedNodeDevice: false, expectedOk: true, - expectStatusChecks: true, }, { name: "rejects trusted-proxy control ui node role without device identity", @@ -52,7 +50,6 @@ export function registerControlUiAndPairingSuite(): void { expectedOk: false, expectedErrorSubstring: "control ui requires device identity", expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - expectStatusChecks: false, }, { name: "requires pairing for trusted-proxy control ui node role with unpaired device", @@ -61,7 +58,6 @@ export function registerControlUiAndPairingSuite(): void { expectedOk: false, expectedErrorSubstring: "pairing required", expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED, - expectStatusChecks: false, }, ]; @@ -96,6 +92,26 @@ export function registerControlUiAndPairingSuite(): void { expect(admin.ok).toBe(true); }; + const expectStatusMissingScopeButHealthOk = async (ws: WebSocket) => { + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message ?? "").toContain("missing scope"); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + }; + + const expectAdminRpcDenied = async (ws: WebSocket) => { + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(false); + expect(admin.error?.message).toBe("missing scope: operator.admin"); + }; + + const expectTalkSecretsDenied = async (ws: WebSocket) => { + const talk = await rpcReq(ws, "talk.config", { includeSecrets: true }); + expect(talk.ok).toBe(false); + expect(talk.error?.message).toBe("missing scope: operator.read"); + }; + const connectControlUiWithoutDeviceAndExpectOk = async (params: { ws: WebSocket; token?: string; @@ -221,17 +237,34 @@ export function registerControlUiAndPairingSuite(): void { ws.close(); return; } - if (tc.expectStatusChecks) { - await expectStatusAndHealthOk(ws); - if (tc.role === "operator") { - await expectAdminRpcOk(ws); - } - } ws.close(); }); }); } + test("clears self-declared scopes for trusted-proxy control ui without device identity", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin"], + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined(); + + await expectStatusMissingScopeButHealthOk(ws); + await expectAdminRpcDenied(ws); + await expectTalkSecretsDenied(ws); + } finally { + ws.close(); + } + }); + }); + test("allows localhost control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, prevToken } = await startServerWithClient("secret", { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index f7eec2153ad..51e4a6fc0c4 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -532,9 +532,9 @@ export function attachGatewayWsMessageHandler(params: { isLocalClient, }); // Shared token/password auth can bypass pairing for trusted operators, but - // device-less backend clients must not self-declare scopes. Control UI - // keeps its explicitly allowed device-less scopes on the allow path. - if (!device && (!isControlUi || decision.kind !== "allow")) { + // device-less clients must not keep self-declared scopes unless the + // operator explicitly chose a local break-glass Control UI mode. + if (!device && (!isControlUi || decision.kind !== "allow" || trustedProxyAuthOk)) { clearUnboundScopes(); } if (decision.kind === "allow") { From 272d6ed24b09e19abcbe5b8cf63aebe0c79030a5 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Tue, 17 Mar 2026 13:11:08 -0400 Subject: [PATCH 117/393] Plugins: add binding resolution callbacks (#48678) Merged via squash. Prepared head SHA: 6d7b32b1849cae1001e581eb6f53b79594dff9b4 Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/tools/plugin.md | 36 +++++++ src/plugins/conversation-binding.test.ts | 116 +++++++++++++++++++++++ src/plugins/conversation-binding.ts | 18 +++- 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fda1fff462f..53114cb9d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. - Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. - Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. +- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. ### Breaking diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a723978bdc7..9f39b7d02bf 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -69,6 +69,42 @@ OpenClaw resolves known Claude marketplace names from `~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit marketplace source with `--marketplace`. +## Conversation binding callbacks + +Plugins that bind a conversation can now react when an approval is resolved. + +Use `api.onConversationBindingResolved(...)` to receive a callback after a bind +request is approved or denied: + +```ts +export default { + id: "my-plugin", + register(api) { + api.onConversationBindingResolved(async (event) => { + if (event.status === "approved") { + // A binding now exists for this plugin + conversation. + console.log(event.binding?.conversationId); + return; + } + + // The request was denied; clear any local pending state. + console.log(event.request.conversation.conversationId); + }); + }, +}; +``` + +Callback payload fields: + +- `status`: `"approved"` or `"denied"` +- `decision`: `"allow-once"`, `"allow-always"`, or `"deny"` +- `binding`: the resolved binding for approved requests +- `request`: the original request summary, detach hint, sender id, and + conversation metadata + +This callback is notification-only. It does not change who is allowed to bind a +conversation, and it runs after core approval handling finishes. + ## Architecture OpenClaw's plugin system has four layers: diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index d3b88697a59..fe01ed3beed 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -143,6 +143,18 @@ async function resolveRequestedBinding(request: PluginBindingRequest) { throw new Error("expected pending or bound bind result"); } +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +function createDeferredVoid(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + describe("plugin conversation binding approvals", () => { beforeEach(() => { sessionBindingState.reset(); @@ -406,6 +418,7 @@ describe("plugin conversation binding approvals", () => { }); expect(approved.status).toBe("approved"); + await flushMicrotasks(); expect(onResolved).toHaveBeenCalledWith({ status: "approved", binding: expect.objectContaining({ @@ -464,6 +477,7 @@ describe("plugin conversation binding approvals", () => { }); expect(denied.status).toBe("denied"); + await flushMicrotasks(); expect(onResolved).toHaveBeenCalledWith({ status: "denied", binding: undefined, @@ -481,6 +495,108 @@ describe("plugin conversation binding approvals", () => { }); }); + it("does not wait for an approved bind callback before returning", async () => { + const registry = createEmptyPluginRegistry(); + const callbackGate = createDeferredVoid(); + const onResolved = vi.fn(async () => callbackGate.promise); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-slow-approve", + handler: onResolved, + source: "/plugins/callback-slow-approve/index.ts", + rootDir: "/plugins/callback-slow-approve", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-approve", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:slow-approve", + }, + binding: { summary: "Bind this conversation to Codex thread slow-approve." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + let settled = false; + const resolutionPromise = resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + + expect(settled).toBe(true); + expect(onResolved).toHaveBeenCalledTimes(1); + + callbackGate.resolve(); + const approved = await resolutionPromise; + expect(approved.status).toBe("approved"); + }); + + it("does not wait for a denied bind callback before returning", async () => { + const registry = createEmptyPluginRegistry(); + const callbackGate = createDeferredVoid(); + const onResolved = vi.fn(async () => callbackGate.promise); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-slow-deny", + handler: onResolved, + source: "/plugins/callback-slow-deny/index.ts", + rootDir: "/plugins/callback-slow-deny", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-deny", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "slow-deny", + }, + binding: { summary: "Bind this conversation to Codex thread slow-deny." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + let settled = false; + const resolutionPromise = resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "deny", + senderId: "user-1", + }).then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + + expect(settled).toBe(true); + expect(onResolved).toHaveBeenCalledTimes(1); + + callbackGate.resolve(); + const denied = await resolutionPromise; + expect(denied.status).toBe("denied"); + }); + it("returns and detaches only bindings owned by the requesting plugin root", async () => { const request = await requestPluginConversationBinding({ pluginId: "codex", diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index 283e6c3d71f..aef5ec92b40 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -722,7 +722,7 @@ export async function resolvePluginConversationBindingApproval(params: { } pendingRequests.delete(params.approvalId); if (params.decision === "deny") { - await notifyPluginConversationBindingResolved({ + dispatchPluginConversationBindingResolved({ status: "denied", decision: "deny", request, @@ -755,7 +755,7 @@ export async function resolvePluginConversationBindingApproval(params: { log.info( `plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, ); - await notifyPluginConversationBindingResolved({ + dispatchPluginConversationBindingResolved({ status: "approved", binding, decision: params.decision, @@ -769,6 +769,20 @@ export async function resolvePluginConversationBindingApproval(params: { }; } +function dispatchPluginConversationBindingResolved(params: { + status: "approved" | "denied"; + binding?: PluginConversationBinding; + decision: PluginConversationBindingResolutionDecision; + request: PendingPluginBindingRequest; +}): void { + // Keep platform interaction acks fast even if the plugin does slow post-bind work. + queueMicrotask(() => { + void notifyPluginConversationBindingResolved(params).catch((error) => { + log.warn(`plugin binding resolved dispatch failed: ${String(error)}`); + }); + }); +} + async function notifyPluginConversationBindingResolved(params: { status: "approved" | "denied"; binding?: PluginConversationBinding; From e4825a0f93856f6417cabe77bb9aed16fe027dc2 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 17 Mar 2026 22:44:15 +0530 Subject: [PATCH 118/393] fix(telegram): unify transport fallback chain (#49148) * fix(telegram): unify transport fallback chain * fix: address telegram fallback review comments * fix: validate pinned SSRF overrides * fix: unify telegram fallback retries (#49148) --- CHANGELOG.md | 1 + .../src/bot/delivery.resolve-media.ts | 7 +- extensions/telegram/src/fetch.test.ts | 62 ++++- extensions/telegram/src/fetch.ts | 237 ++++++++++++------ src/infra/net/fetch-guard.ts | 2 +- src/infra/net/ssrf.dispatcher.test.ts | 52 ++++ src/infra/net/ssrf.pinning.test.ts | 9 + src/infra/net/ssrf.ts | 58 ++++- src/media/fetch.telegram-network.test.ts | 94 ++++++- src/media/fetch.ts | 72 ++++-- 10 files changed, 459 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53114cb9d75..8930840332c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,7 @@ Docs: https://docs.openclaw.ai - ACP/gateway startup: use direct Telegram and Discord startup/status helpers instead of routing probes through the plugin runtime, and prepend the selected daemon Node bin dir to service PATH so plugin-local installs can still find `npm` and `pnpm`. - ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime. - Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing. +- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. ## 2026.3.13 diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 36b3bb50be9..52f6eef966c 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -4,7 +4,7 @@ import { retryAsync } from "openclaw/plugin-sdk/infra-runtime"; import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; -import { shouldRetryTelegramIpv4Fallback, type TelegramTransport } from "../fetch.js"; +import { shouldRetryTelegramTransportFallback, type TelegramTransport } from "../fetch.js"; import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; import { resolveTelegramMediaPlaceholder } from "./helpers.js"; import type { StickerMetadata, TelegramContext } from "./types.js"; @@ -129,9 +129,8 @@ async function downloadAndSaveTelegramFile(params: { const fetched = await fetchRemoteMedia({ url, fetchImpl: params.transport.sourceFetch, - dispatcherPolicy: params.transport.pinnedDispatcherPolicy, - fallbackDispatcherPolicy: params.transport.fallbackPinnedDispatcherPolicy, - shouldRetryFetchError: shouldRetryTelegramIpv4Fallback, + dispatcherAttempts: params.transport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, filePathHint: params.filePath, maxBytes: params.maxBytes, readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, diff --git a/extensions/telegram/src/fetch.test.ts b/extensions/telegram/src/fetch.test.ts index 7681d0c8701..4afdacf0568 100644 --- a/extensions/telegram/src/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -1,6 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveFetch } from "../../../src/infra/fetch.js"; -import { resolveTelegramFetch, resolveTelegramTransport } from "./fetch.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const setDefaultResultOrder = vi.hoisted(() => vi.fn()); const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); @@ -56,6 +54,16 @@ vi.mock("undici", () => ({ setGlobalDispatcher, })); +let resolveFetch: typeof import("../../../src/infra/fetch.js").resolveFetch; +let resolveTelegramFetch: typeof import("./fetch.js").resolveTelegramFetch; +let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTransport; + +beforeEach(async () => { + vi.resetModules(); + ({ resolveFetch } = await import("../../../src/infra/fetch.js")); + ({ resolveTelegramFetch, resolveTelegramTransport } = await import("./fetch.js")); +}); + function resolveTelegramFetchOrThrow( proxyFetch?: typeof fetch, options?: { network?: { autoSelectFamily?: boolean; dnsResultOrder?: "ipv4first" | "verbatim" } }, @@ -152,6 +160,24 @@ function expectPinnedIpv4ConnectDispatcher(args: { } } +function expectPinnedFallbackIpDispatcher(callIndex: number) { + const dispatcher = getDispatcherFromUndiciCall(callIndex); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + lookup: expect.any(Function), + }), + ); + const callback = vi.fn(); + ( + dispatcher?.options?.connect?.lookup as + | ((hostname: string, callback: (err: null, address: string, family: number) => void) => void) + | undefined + )?.("api.telegram.org", callback); + expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4); +} + function expectCallerDispatcherPreserved(callIndexes: number[], dispatcher: unknown) { for (const callIndex of callIndexes) { const callInit = undiciFetch.mock.calls[callIndex - 1]?.[1] as @@ -395,7 +421,7 @@ describe("resolveTelegramFetch", () => { pinnedCall: 2, followupCall: 3, }); - expect(transport.pinnedDispatcherPolicy).toEqual( + expect(transport.dispatcherAttempts?.[0]?.dispatcherPolicy).toEqual( expect.objectContaining({ mode: "direct", }), @@ -533,6 +559,34 @@ describe("resolveTelegramFetch", () => { ); }); + it("escalates from IPv4 fallback to pinned Telegram IP and keeps it sticky", async () => { + undiciFetch + .mockRejectedValueOnce(buildFetchFallbackError("ETIMEDOUT")) + .mockRejectedValueOnce(buildFetchFallbackError("EHOSTUNREACH")) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(4); + + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + const fourthDispatcher = getDispatcherFromUndiciCall(4); + + expect(secondDispatcher).not.toBe(thirdDispatcher); + expect(thirdDispatcher).toBe(fourthDispatcher); + expectPinnedFallbackIpDispatcher(3); + }); + it("preserves caller-provided dispatcher across fallback retry", async () => { const fetchError = buildFetchFallbackError("EHOSTUNREACH"); undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts index 962d0256af1..ad60faab13b 100644 --- a/extensions/telegram/src/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -1,8 +1,11 @@ import * as dns from "node:dns"; import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; -import { hasEnvHttpProxyConfigured } from "openclaw/plugin-sdk/infra-runtime"; -import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { + createPinnedLookup, + hasEnvHttpProxyConfigured, + resolveFetch, + type PinnedDispatcherPolicy, +} from "openclaw/plugin-sdk/infra-runtime"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; import { @@ -15,6 +18,7 @@ const log = createSubsystemLogger("telegram/network"); const TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; const TELEGRAM_API_HOSTNAME = "api.telegram.org"; +const TELEGRAM_FALLBACK_IPS: readonly string[] = ["149.154.167.220"]; type RequestInitWithDispatcher = RequestInit & { dispatcher?: unknown; @@ -24,6 +28,16 @@ type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent; type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy"; +type TelegramDispatcherAttempt = { + dispatcherPolicy?: PinnedDispatcherPolicy; +}; + +type TelegramTransportAttempt = { + createDispatcher: () => TelegramDispatcher; + exportAttempt: TelegramDispatcherAttempt; + logMessage?: string; +}; + type TelegramDnsResultOrder = "ipv4first" | "verbatim"; type LookupCallback = @@ -49,17 +63,17 @@ const FALLBACK_RETRY_ERROR_CODES = [ "UND_ERR_SOCKET", ] as const; -type Ipv4FallbackContext = { +type TelegramTransportFallbackContext = { message: string; codes: Set; }; -type Ipv4FallbackRule = { +type TelegramTransportFallbackRule = { name: string; - matches: (ctx: Ipv4FallbackContext) => boolean; + matches: (ctx: TelegramTransportFallbackContext) => boolean; }; -const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [ +const TELEGRAM_TRANSPORT_FALLBACK_RULES: readonly TelegramTransportFallbackRule[] = [ { name: "fetch-failed-envelope", matches: ({ message }) => message.includes("fetch failed"), @@ -98,7 +112,6 @@ function createDnsResultOrderLookup( const lookupOptions: LookupOptions = { ...baseOptions, order, - // Keep `verbatim` for compatibility with Node runtimes that ignore `order`. verbatim: order === "verbatim", }; lookup(hostname, lookupOptions, callback); @@ -139,14 +152,6 @@ function buildTelegramConnectOptions(params: { } function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { - // We need this classification before dispatch to decide whether sticky IPv4 fallback - // can safely arm. EnvHttpProxyAgent does not expose route decisions (proxy vs direct - // NO_PROXY bypass), so we mirror undici's parsing/matching behavior for this host. - // Match EnvHttpProxyAgent behavior (undici): - // - lower-case no_proxy takes precedence over NO_PROXY - // - entries split by comma or whitespace - // - wildcard handling is exact-string "*" only - // - leading "." and "*." are normalized the same way const noProxyValue = env.no_proxy ?? env.NO_PROXY ?? ""; if (!noProxyValue) { return false; @@ -228,16 +233,32 @@ function resolveTelegramDispatcherPolicy(params: { }; } +function withPinnedLookup( + options: Record | undefined, + pinnedHostname: PinnedDispatcherPolicy["pinnedHostname"], +): Record | undefined { + if (!pinnedHostname) { + return options ? { ...options } : undefined; + } + const lookup = createPinnedLookup({ + hostname: pinnedHostname.hostname, + addresses: [...pinnedHostname.addresses], + fallback: dns.lookup, + }); + return options ? { ...options, lookup } : { lookup }; +} + function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { dispatcher: TelegramDispatcher; mode: TelegramDispatcherMode; effectivePolicy: PinnedDispatcherPolicy; } { if (policy.mode === "explicit-proxy") { - const proxyOptions = policy.proxyTls + const proxyTlsOptions = withPinnedLookup(policy.proxyTls, policy.pinnedHostname); + const proxyOptions = proxyTlsOptions ? ({ uri: policy.proxyUrl, - proxyTls: { ...policy.proxyTls }, + proxyTls: proxyTlsOptions, } satisfies ConstructorParameters[0]) : policy.proxyUrl; try { @@ -253,13 +274,13 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { } if (policy.mode === "env-proxy") { + const connectOptions = withPinnedLookup(policy.connect, policy.pinnedHostname); + const proxyTlsOptions = withPinnedLookup(policy.proxyTls, policy.pinnedHostname); const proxyOptions = - policy.connect || policy.proxyTls + connectOptions || proxyTlsOptions ? ({ - ...(policy.connect ? { connect: { ...policy.connect } } : {}), - // undici's EnvHttpProxyAgent passes `connect` only to the no-proxy Agent. - // Real proxied HTTPS traffic reads transport settings from ProxyAgent.proxyTls. - ...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}), + ...(connectOptions ? { connect: connectOptions } : {}), + ...(proxyTlsOptions ? { proxyTls: proxyTlsOptions } : {}), } satisfies ConstructorParameters[0]) : undefined; try { @@ -276,14 +297,12 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { ); const directPolicy: PinnedDispatcherPolicy = { mode: "direct", - ...(policy.connect ? { connect: { ...policy.connect } } : {}), + ...(connectOptions ? { connect: connectOptions } : {}), }; return { dispatcher: new Agent( directPolicy.connect - ? ({ - connect: { ...directPolicy.connect }, - } satisfies ConstructorParameters[0]) + ? ({ connect: directPolicy.connect } satisfies ConstructorParameters[0]) : undefined, ), mode: "direct", @@ -292,11 +311,12 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { } } + const connectOptions = withPinnedLookup(policy.connect, policy.pinnedHostname); return { dispatcher: new Agent( - policy.connect + connectOptions ? ({ - connect: { ...policy.connect }, + connect: connectOptions, } satisfies ConstructorParameters[0]) : undefined, ), @@ -375,13 +395,13 @@ function formatErrorCodes(err: unknown): string { return codes.length > 0 ? codes.join(",") : "none"; } -function shouldRetryWithIpv4Fallback(err: unknown): boolean { - const ctx: Ipv4FallbackContext = { +function shouldUseTelegramTransportFallback(err: unknown): boolean { + const ctx: TelegramTransportFallbackContext = { message: err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "", codes: collectErrorCodes(err), }; - for (const rule of IPV4_FALLBACK_RULES) { + for (const rule of TELEGRAM_TRANSPORT_FALLBACK_RULES) { if (!rule.matches(ctx)) { return false; } @@ -389,18 +409,71 @@ function shouldRetryWithIpv4Fallback(err: unknown): boolean { return true; } -export function shouldRetryTelegramIpv4Fallback(err: unknown): boolean { - return shouldRetryWithIpv4Fallback(err); +export function shouldRetryTelegramTransportFallback(err: unknown): boolean { + return shouldUseTelegramTransportFallback(err); } -// Prefer wrapped fetch when available to normalize AbortSignal across runtimes. export type TelegramTransport = { fetch: typeof fetch; sourceFetch: typeof fetch; - pinnedDispatcherPolicy?: PinnedDispatcherPolicy; - fallbackPinnedDispatcherPolicy?: PinnedDispatcherPolicy; + dispatcherAttempts?: TelegramDispatcherAttempt[]; }; +function createTelegramTransportAttempts(params: { + defaultDispatcher: ReturnType; + allowFallback: boolean; + fallbackPolicy?: PinnedDispatcherPolicy; +}): TelegramTransportAttempt[] { + const attempts: TelegramTransportAttempt[] = [ + { + createDispatcher: () => params.defaultDispatcher.dispatcher, + exportAttempt: { dispatcherPolicy: params.defaultDispatcher.effectivePolicy }, + }, + ]; + + if (!params.allowFallback || !params.fallbackPolicy) { + return attempts; + } + const fallbackPolicy = params.fallbackPolicy; + + let ipv4Dispatcher: TelegramDispatcher | null = null; + attempts.push({ + createDispatcher: () => { + if (!ipv4Dispatcher) { + ipv4Dispatcher = createTelegramDispatcher(fallbackPolicy).dispatcher; + } + return ipv4Dispatcher; + }, + exportAttempt: { dispatcherPolicy: fallbackPolicy }, + logMessage: "fetch fallback: enabling sticky IPv4-only dispatcher", + }); + + if (TELEGRAM_FALLBACK_IPS.length === 0) { + return attempts; + } + + const fallbackIpPolicy: PinnedDispatcherPolicy = { + ...fallbackPolicy, + pinnedHostname: { + hostname: TELEGRAM_API_HOSTNAME, + addresses: [...TELEGRAM_FALLBACK_IPS], + }, + }; + let fallbackIpDispatcher: TelegramDispatcher | null = null; + attempts.push({ + createDispatcher: () => { + if (!fallbackIpDispatcher) { + fallbackIpDispatcher = createTelegramDispatcher(fallbackIpPolicy).dispatcher; + } + return fallbackIpDispatcher; + }, + exportAttempt: { dispatcherPolicy: fallbackIpPolicy }, + logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP", + }); + + return attempts; +} + export function resolveTelegramTransport( proxyFetch?: typeof fetch, options?: { network?: TelegramNetworkConfig }, @@ -424,7 +497,6 @@ export function resolveTelegramTransport( ? resolveWrappedFetch(proxyFetch) : undiciSourceFetch; const dnsResultOrder = normalizeDnsResultOrder(dnsDecision.value); - // Preserve fully caller-owned custom fetch implementations. if (proxyFetch && !explicitProxyUrl) { return { fetch: sourceFetch, sourceFetch }; } @@ -439,70 +511,75 @@ export function resolveTelegramTransport( }); const defaultDispatcher = createTelegramDispatcher(defaultDispatcherResolution.policy); const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi(); - const allowStickyIpv4Fallback = + const allowStickyFallback = defaultDispatcher.mode === "direct" || (defaultDispatcher.mode === "env-proxy" && shouldBypassEnvProxy); - const stickyShouldUseEnvProxy = defaultDispatcher.mode === "env-proxy"; - const fallbackPinnedDispatcherPolicy = allowStickyIpv4Fallback + const fallbackDispatcherPolicy = allowStickyFallback ? resolveTelegramDispatcherPolicy({ autoSelectFamily: false, dnsResultOrder: "ipv4first", - useEnvProxy: stickyShouldUseEnvProxy, + useEnvProxy: defaultDispatcher.mode === "env-proxy", forceIpv4: true, proxyUrl: explicitProxyUrl, }).policy : undefined; + const transportAttempts = createTelegramTransportAttempts({ + defaultDispatcher, + allowFallback: allowStickyFallback, + fallbackPolicy: fallbackDispatcherPolicy, + }); - let stickyIpv4FallbackEnabled = false; - let stickyIpv4Dispatcher: TelegramDispatcher | null = null; - const resolveStickyIpv4Dispatcher = () => { - if (!stickyIpv4Dispatcher) { - if (!fallbackPinnedDispatcherPolicy) { - return defaultDispatcher.dispatcher; - } - stickyIpv4Dispatcher = createTelegramDispatcher(fallbackPinnedDispatcherPolicy).dispatcher; - } - return stickyIpv4Dispatcher; - }; - + let stickyAttemptIndex = 0; const resolvedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const callerProvidedDispatcher = Boolean( (init as RequestInitWithDispatcher | undefined)?.dispatcher, ); - const initialInit = withDispatcherIfMissing( - init, - stickyIpv4FallbackEnabled ? resolveStickyIpv4Dispatcher() : defaultDispatcher.dispatcher, - ); + const startIndex = Math.min(stickyAttemptIndex, transportAttempts.length - 1); + let err: unknown; + try { - return await sourceFetch(input, initialInit); - } catch (err) { - if (shouldRetryWithIpv4Fallback(err)) { - // Preserve caller-owned dispatchers on retry. - if (callerProvidedDispatcher) { - return sourceFetch(input, init ?? {}); - } - // Proxy routes should not arm sticky IPv4 mode; `family=4` would constrain - // proxy-connect behavior instead of Telegram endpoint selection. - if (!allowStickyIpv4Fallback) { - throw err; - } - if (!stickyIpv4FallbackEnabled) { - stickyIpv4FallbackEnabled = true; - log.warn( - `fetch fallback: enabling sticky IPv4-only dispatcher (codes=${formatErrorCodes(err)})`, - ); - } - return sourceFetch(input, withDispatcherIfMissing(init, resolveStickyIpv4Dispatcher())); - } + return await sourceFetch( + input, + withDispatcherIfMissing(init, transportAttempts[startIndex].createDispatcher()), + ); + } catch (caught) { + err = caught; + } + + if (!shouldUseTelegramTransportFallback(err)) { throw err; } + if (callerProvidedDispatcher) { + return sourceFetch(input, init ?? {}); + } + + for (let nextIndex = startIndex + 1; nextIndex < transportAttempts.length; nextIndex += 1) { + const nextAttempt = transportAttempts[nextIndex]; + if (nextAttempt.logMessage) { + log.warn(`${nextAttempt.logMessage} (codes=${formatErrorCodes(err)})`); + } + try { + const response = await sourceFetch( + input, + withDispatcherIfMissing(init, nextAttempt.createDispatcher()), + ); + stickyAttemptIndex = nextIndex; + return response; + } catch (caught) { + err = caught; + if (!shouldUseTelegramTransportFallback(err)) { + throw err; + } + } + } + + throw err; }) as typeof fetch; return { fetch: resolvedFetch, sourceFetch, - pinnedDispatcherPolicy: defaultDispatcher.effectivePolicy, - fallbackPinnedDispatcherPolicy, + dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt), }; } diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index ed082e92fb9..8aec91a62ef 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -198,7 +198,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { }); }); + it("replaces the pinned lookup when a dispatcher override hostname is provided", () => { + const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"]; + const pinned: PinnedHostname = { + hostname: "api.telegram.org", + addresses: ["149.154.167.221"], + lookup: originalLookup, + }; + + createPinnedDispatcher(pinned, { + mode: "direct", + pinnedHostname: { + hostname: "api.telegram.org", + addresses: ["149.154.167.220"], + }, + }); + + const firstCallArg = agentCtor.mock.calls.at(-1)?.[0] as + | { connect?: { lookup?: PinnedHostname["lookup"] } } + | undefined; + expect(firstCallArg?.connect?.lookup).toBeTypeOf("function"); + + const lookup = firstCallArg?.connect?.lookup; + const callback = vi.fn(); + lookup?.("api.telegram.org", callback); + + expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4); + expect(originalLookup).not.toHaveBeenCalled(); + }); + + it("rejects pinned override addresses that violate SSRF policy", () => { + const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"]; + const pinned: PinnedHostname = { + hostname: "api.telegram.org", + addresses: ["149.154.167.221"], + lookup: originalLookup, + }; + + expect(() => + createPinnedDispatcher( + pinned, + { + mode: "direct", + pinnedHostname: { + hostname: "api.telegram.org", + addresses: ["127.0.0.1"], + }, + }, + undefined, + ), + ).toThrow(/private|internal|blocked/i); + }); + it("keeps env proxy route while pinning the direct no-proxy path", () => { const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; const pinned: PinnedHostname = { diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 28420ea373f..a8847c26642 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -99,6 +99,15 @@ describe("ssrf pinning", () => { expect(result.address).toBe("1.2.3.4"); }); + it("fails loud when a pinned lookup is created without any addresses", () => { + expect(() => + createPinnedLookup({ + hostname: "example.com", + addresses: [], + }), + ).toThrow("Pinned lookup requires at least one address for example.com"); + }); + it("enforces hostname allowlist when configured", async () => { const lookup = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index db70664a43f..fd633fcb20d 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -67,6 +67,13 @@ export function isPrivateNetworkAllowedByPolicy(policy?: SsrFPolicy): boolean { return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; } +function shouldSkipPrivateNetworkChecks(hostname: string, policy?: SsrFPolicy): boolean { + return ( + isPrivateNetworkAllowedByPolicy(policy) || + normalizeHostnameSet(policy?.allowedHostnames).has(hostname) + ); +} + function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions { return { allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true, @@ -198,6 +205,9 @@ export function createPinnedLookup(params: { fallback?: typeof dnsLookupCb; }): typeof dnsLookupCb { const normalizedHost = normalizeHostname(params.hostname); + if (params.addresses.length === 0) { + throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`); + } const fallback = params.fallback ?? dnsLookupCb; const fallbackLookup = fallback as unknown as ( hostname: string, @@ -255,20 +265,28 @@ export type PinnedHostname = { lookup: typeof dnsLookupCb; }; +export type PinnedHostnameOverride = { + hostname: string; + addresses: string[]; +}; + export type PinnedDispatcherPolicy = | { mode: "direct"; connect?: Record; + pinnedHostname?: PinnedHostnameOverride; } | { mode: "env-proxy"; connect?: Record; proxyTls?: Record; + pinnedHostname?: PinnedHostnameOverride; } | { mode: "explicit-proxy"; proxyUrl: string; proxyTls?: Record; + pinnedHostname?: PinnedHostnameOverride; }; function dedupeAndPreferIpv4(results: readonly LookupAddress[]): string[] { @@ -298,11 +316,8 @@ export async function resolvePinnedHostnameWithPolicy( throw new Error("Invalid hostname"); } - const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy); - const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames); const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist); - const isExplicitAllowed = allowedHostnames.has(normalized); - const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed; + const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, params.policy); if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) { throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`); @@ -352,19 +367,50 @@ function withPinnedLookup( return connect ? { ...connect, lookup } : { lookup }; } +function resolvePinnedDispatcherLookup( + pinned: PinnedHostname, + override?: PinnedHostnameOverride, + policy?: SsrFPolicy, +): PinnedHostname["lookup"] { + if (!override) { + return pinned.lookup; + } + const normalizedOverrideHost = normalizeHostname(override.hostname); + if (!normalizedOverrideHost || normalizedOverrideHost !== pinned.hostname) { + throw new Error( + `Pinned dispatcher override hostname mismatch: expected ${pinned.hostname}, got ${override.hostname}`, + ); + } + const records = override.addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })); + if (!shouldSkipPrivateNetworkChecks(pinned.hostname, policy)) { + assertAllowedResolvedAddressesOrThrow(records, policy); + } + return createPinnedLookup({ + hostname: pinned.hostname, + addresses: [...override.addresses], + fallback: pinned.lookup, + }); +} + export function createPinnedDispatcher( pinned: PinnedHostname, policy?: PinnedDispatcherPolicy, + ssrfPolicy?: SsrFPolicy, ): Dispatcher { + const lookup = resolvePinnedDispatcherLookup(pinned, policy?.pinnedHostname, ssrfPolicy); + if (!policy || policy.mode === "direct") { return new Agent({ - connect: withPinnedLookup(pinned.lookup, policy?.connect), + connect: withPinnedLookup(lookup, policy?.connect), }); } if (policy.mode === "env-proxy") { return new EnvHttpProxyAgent({ - connect: withPinnedLookup(pinned.lookup, policy.connect), + connect: withPinnedLookup(lookup, policy.connect), ...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}), }); } diff --git a/src/media/fetch.telegram-network.test.ts b/src/media/fetch.telegram-network.test.ts index 60e60f1c48c..faf16314d98 100644 --- a/src/media/fetch.telegram-network.test.ts +++ b/src/media/fetch.telegram-network.test.ts @@ -22,7 +22,7 @@ vi.mock("undici", () => ({ })); let resolveTelegramTransport: typeof import("../../extensions/telegram/src/fetch.js").resolveTelegramTransport; -let shouldRetryTelegramIpv4Fallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramIpv4Fallback; +let shouldRetryTelegramTransportFallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramTransportFallback; let fetchRemoteMedia: typeof import("./fetch.js").fetchRemoteMedia; describe("fetchRemoteMedia telegram network policy", () => { @@ -30,7 +30,7 @@ describe("fetchRemoteMedia telegram network policy", () => { beforeEach(async () => { vi.resetModules(); - ({ resolveTelegramTransport, shouldRetryTelegramIpv4Fallback } = + ({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } = await import("../../extensions/telegram/src/fetch.js")); ({ fetchRemoteMedia } = await import("./fetch.js")); }); @@ -70,7 +70,7 @@ describe("fetchRemoteMedia telegram network policy", () => { await fetchRemoteMedia({ url: "https://api.telegram.org/file/bottok/photos/1.jpg", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, + dispatcherAttempts: telegramTransport.dispatcherAttempts, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -120,7 +120,7 @@ describe("fetchRemoteMedia telegram network policy", () => { await fetchRemoteMedia({ url: "https://api.telegram.org/file/bottok/files/1.pdf", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, + dispatcherAttempts: telegramTransport.dispatcherAttempts, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -167,9 +167,8 @@ describe("fetchRemoteMedia telegram network policy", () => { await fetchRemoteMedia({ url: "https://api.telegram.org/file/bottok/photos/2.jpg", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, - fallbackDispatcherPolicy: telegramTransport.fallbackPinnedDispatcherPolicy, - shouldRetryFetchError: shouldRetryTelegramIpv4Fallback, + dispatcherAttempts: telegramTransport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -214,14 +213,83 @@ describe("fetchRemoteMedia telegram network policy", () => { ); }); - it("preserves both primary and fallback errors when Telegram media retry fails twice", async () => { + it("retries Telegram file downloads with pinned Telegram IP after IPv4 fallback fails", async () => { + const lookupFn = vi.fn(async () => [ + { address: "149.154.167.221", family: 4 }, + { address: "2001:67c:4e8:f004::9", family: 6 }, + ]) as unknown as LookupFn; + undiciMocks.fetch + .mockRejectedValueOnce(createTelegramFetchFailedError("EHOSTUNREACH")) + .mockRejectedValueOnce(createTelegramFetchFailedError("ETIMEDOUT")) + .mockResolvedValueOnce( + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + + const telegramTransport = resolveTelegramTransport(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await fetchRemoteMedia({ + url: "https://api.telegram.org/file/bottok/photos/3.jpg", + fetchImpl: telegramTransport.sourceFetch, + dispatcherAttempts: telegramTransport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, + lookupFn, + maxBytes: 1024, + ssrfPolicy: { + allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: true, + }, + }); + + const thirdInit = undiciMocks.fetch.mock.calls[2]?.[1] as + | (RequestInit & { + dispatcher?: { + options?: { + connect?: Record; + }; + }; + }) + | undefined; + const callback = vi.fn(); + ( + thirdInit?.dispatcher?.options?.connect?.lookup as + | (( + hostname: string, + callback: (err: null, address: string, family: number) => void, + ) => void) + | undefined + )?.("api.telegram.org", callback); + + expect(undiciMocks.fetch).toHaveBeenCalledTimes(3); + expect(thirdInit?.dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + lookup: expect.any(Function), + }), + ); + expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4); + }); + + it("preserves both primary and final fallback errors when Telegram media retry chain fails", async () => { const lookupFn = vi.fn(async () => [ { address: "149.154.167.220", family: 4 }, { address: "2001:67c:4e8:f004::9", family: 6 }, ]) as unknown as LookupFn; const primaryError = createTelegramFetchFailedError("EHOSTUNREACH"); + const ipv4Error = createTelegramFetchFailedError("ETIMEDOUT"); const fallbackError = createTelegramFetchFailedError("ETIMEDOUT"); - undiciMocks.fetch.mockRejectedValueOnce(primaryError).mockRejectedValueOnce(fallbackError); + undiciMocks.fetch + .mockRejectedValueOnce(primaryError) + .mockRejectedValueOnce(ipv4Error) + .mockRejectedValueOnce(fallbackError); const telegramTransport = resolveTelegramTransport(undefined, { network: { @@ -232,11 +300,10 @@ describe("fetchRemoteMedia telegram network policy", () => { await expect( fetchRemoteMedia({ - url: "https://api.telegram.org/file/bottok/photos/3.jpg", + url: "https://api.telegram.org/file/bottok/photos/4.jpg", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, - fallbackDispatcherPolicy: telegramTransport.fallbackPinnedDispatcherPolicy, - shouldRetryFetchError: shouldRetryTelegramIpv4Fallback, + dispatcherAttempts: telegramTransport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -250,6 +317,7 @@ describe("fetchRemoteMedia telegram network policy", () => { cause: expect.objectContaining({ name: "Error", cause: fallbackError, + attemptErrors: [primaryError, ipv4Error, fallbackError], primaryError, }), }); diff --git a/src/media/fetch.ts b/src/media/fetch.ts index 020ac8040bd..3893b1366d4 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -26,6 +26,11 @@ export class MediaFetchError extends Error { export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +export type FetchDispatcherAttempt = { + dispatcherPolicy?: PinnedDispatcherPolicy; + lookupFn?: LookupFn; +}; + type FetchMediaOptions = { url: string; fetchImpl?: FetchLike; @@ -37,8 +42,7 @@ type FetchMediaOptions = { readIdleTimeoutMs?: number; ssrfPolicy?: SsrFPolicy; lookupFn?: LookupFn; - dispatcherPolicy?: PinnedDispatcherPolicy; - fallbackDispatcherPolicy?: PinnedDispatcherPolicy; + dispatcherAttempts?: FetchDispatcherAttempt[]; shouldRetryFetchError?: (error: unknown) => boolean; }; @@ -101,8 +105,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise Promise) | null = null; - const runGuardedFetch = async (policy?: PinnedDispatcherPolicy) => + const attempts = + dispatcherAttempts && dispatcherAttempts.length > 0 + ? dispatcherAttempts + : [{ dispatcherPolicy: undefined, lookupFn }]; + const runGuardedFetch = async (attempt: FetchDispatcherAttempt) => await fetchWithSsrFGuard( withStrictGuardedFetchMode({ url, @@ -118,32 +125,43 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise>; + const attemptErrors: unknown[] = []; + for (let i = 0; i < attempts.length; i += 1) { + try { + result = await runGuardedFetch(attempts[i]); + break; + } catch (err) { + if ( + typeof shouldRetryFetchError !== "function" || + !shouldRetryFetchError(err) || + i === attempts.length - 1 + ) { + if (attemptErrors.length > 0) { + const combined = new Error( + `Primary fetch failed and fallback fetch also failed for ${sourceUrl}`, + { cause: err }, + ); + ( + combined as Error & { + primaryError?: unknown; + attemptErrors?: unknown[]; + } + ).primaryError = attemptErrors[0]; + (combined as Error & { attemptErrors?: unknown[] }).attemptErrors = [ + ...attemptErrors, + err, + ]; + throw combined; + } + throw err; } - } else { - throw err; + attemptErrors.push(err); } } res = result.response; From 3983928958b65b3fdd866001cc9cac93e55c334f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:15:22 -0700 Subject: [PATCH 119/393] Plugins: add inspect command and capability report --- src/auto-reply/reply/commands-plugins.test.ts | 14 +- src/auto-reply/reply/commands-plugins.ts | 48 ++++- src/auto-reply/reply/plugins-commands.ts | 13 +- src/cli/plugins-cli.ts | 200 ++++++++++++------ src/plugins/status.test.ts | 99 ++++++++- src/plugins/status.ts | 176 +++++++++++++++ 6 files changed, 469 insertions(+), 81 deletions(-) diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 1aeb184e5b7..e5764574d29 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -35,7 +35,7 @@ describe("handleCommands /plugins", () => { await workspaceHarness.cleanupWorkspaces(); }); - it("lists discovered plugins and shows plugin details", async () => { + it("lists discovered plugins and inspects plugin details", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" }); @@ -49,13 +49,19 @@ describe("handleCommands /plugins", () => { expect(listResult.reply?.text).toContain("superpowers"); expect(listResult.reply?.text).toContain("[disabled]"); - const showParams = buildCommandTestParams("/plugin show superpowers", buildCfg(), undefined, { - workspaceDir, - }); + const showParams = buildCommandTestParams( + "/plugins inspect superpowers", + buildCfg(), + undefined, + { + workspaceDir, + }, + ); showParams.command.senderIsOwner = true; const showResult = await handleCommands(showParams); expect(showResult.reply?.text).toContain('"id": "superpowers"'); expect(showResult.reply?.text).toContain('"bundleFormat": "claude"'); + expect(showResult.reply?.text).toContain('"shape":'); }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index ea2c4fbf4b9..197786479e8 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -4,8 +4,13 @@ import { writeConfigFile, } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { PluginInstallRecord } from "../../config/types.plugins.js"; import type { PluginRecord } from "../../plugins/registry.js"; -import { buildPluginStatusReport, type PluginStatusReport } from "../../plugins/status.js"; +import { + buildPluginInspectReport, + buildPluginStatusReport, + type PluginStatusReport, +} from "../../plugins/status.js"; import { setPluginEnabledInConfig } from "../../plugins/toggle-config.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { @@ -21,6 +26,28 @@ function renderJsonBlock(label: string, value: unknown): string { return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; } +function buildPluginInspectJson(params: { + id: string; + config: OpenClawConfig; + report: PluginStatusReport; +}): { + inspect: NonNullable>; + install: PluginInstallRecord | null; +} | null { + const inspect = buildPluginInspectReport({ + id: params.id, + config: params.config, + report: params.report, + }); + if (!inspect) { + return null; + } + return { + inspect, + install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, + }; +} + function formatPluginLabel(plugin: PluginRecord): string { if (!plugin.name || plugin.name === plugin.id) { return plugin.id; @@ -95,7 +122,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm return unauthorized; } const allowInternalReadOnly = - (pluginsCommand.action === "list" || pluginsCommand.action === "show") && + (pluginsCommand.action === "list" || pluginsCommand.action === "inspect") && isInternalMessageChannel(params.command.channel); const nonOwner = allowInternalReadOnly ? null : rejectNonOwnerCommand(params, "/plugins"); if (nonOwner) { @@ -130,27 +157,30 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm }; } - if (pluginsCommand.action === "show") { + if (pluginsCommand.action === "inspect") { if (!pluginsCommand.name) { return { shouldContinue: false, reply: { text: formatPluginsList(loaded.report) }, }; } - const plugin = findPlugin(loaded.report, pluginsCommand.name); - if (!plugin) { + const payload = buildPluginInspectJson({ + id: pluginsCommand.name, + config: loaded.config, + report: loaded.report, + }); + if (!payload) { return { shouldContinue: false, reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` }, }; } - const install = loaded.config.plugins?.installs?.[plugin.id] ?? null; return { shouldContinue: false, reply: { - text: renderJsonBlock(`🔌 Plugin "${plugin.id}"`, { - plugin, - install, + text: renderJsonBlock(`🔌 Plugin "${payload.inspect.plugin.id}"`, { + ...payload.inspect, + install: payload.install, }), }, }; diff --git a/src/auto-reply/reply/plugins-commands.ts b/src/auto-reply/reply/plugins-commands.ts index 2b5c0456849..95da9d8bc2b 100644 --- a/src/auto-reply/reply/plugins-commands.ts +++ b/src/auto-reply/reply/plugins-commands.ts @@ -1,6 +1,6 @@ export type PluginsCommand = | { action: "list" } - | { action: "show"; name?: string } + | { action: "inspect"; name?: string } | { action: "enable"; name: string } | { action: "disable"; name: string } | { action: "error"; message: string }; @@ -22,12 +22,15 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null { if (action === "list") { return name - ? { action: "error", message: "Usage: /plugins list|show|get|enable|disable [plugin]" } + ? { + action: "error", + message: "Usage: /plugins list|inspect|show|get|enable|disable [plugin]", + } : { action: "list" }; } - if (action === "show" || action === "get") { - return { action: "show", name: name || undefined }; + if (action === "inspect" || action === "show" || action === "get") { + return { action: "inspect", name: name || undefined }; } if (action === "enable" || action === "disable") { @@ -42,6 +45,6 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null { return { action: "error", - message: "Usage: /plugins list|show|get|enable|disable [plugin]", + message: "Usage: /plugins list|inspect|show|get|enable|disable [plugin]", }; } diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index b4b197bf96c..a73defc736b 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -5,6 +5,7 @@ import type { Command } from "commander"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; @@ -19,7 +20,7 @@ import { import type { PluginRecord } from "../plugins/registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; -import { buildPluginStatusReport } from "../plugins/status.js"; +import { buildPluginInspectReport, buildPluginStatusReport } from "../plugins/status.js"; import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; @@ -42,7 +43,7 @@ export type PluginsListOptions = { verbose?: boolean; }; -export type PluginInfoOptions = { +export type PluginInspectOptions = { json?: boolean; }; @@ -133,6 +134,36 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string { return parts.join("\n"); } +function formatInspectSection(title: string, lines: string[]): string[] { + if (lines.length === 0) { + return []; + } + return ["", `${theme.muted(`${title}:`)}`, ...lines]; +} + +function formatInstallLines(install: PluginInstallRecord | undefined): string[] { + if (!install) { + return []; + } + const lines = [`Source: ${install.source}`]; + if (install.spec) { + lines.push(`Spec: ${install.spec}`); + } + if (install.sourcePath) { + lines.push(`Source path: ${shortenHomePath(install.sourcePath)}`); + } + if (install.installPath) { + lines.push(`Install path: ${shortenHomePath(install.installPath)}`); + } + if (install.version) { + lines.push(`Recorded version: ${install.version}`); + } + if (install.installedAt) { + lines.push(`Installed at: ${install.installedAt}`); + } + return lines; +} + function applySlotSelectionForPlugin( config: OpenClawConfig, pluginId: string, @@ -542,88 +573,133 @@ export function registerPluginsCli(program: Command) { }); plugins - .command("info") - .description("Show plugin details") + .command("inspect") + .alias("info") + .description("Inspect plugin details") .argument("", "Plugin id") .option("--json", "Print JSON") - .action((id: string, opts: PluginInfoOptions) => { - const report = buildPluginStatusReport(); - const plugin = report.plugins.find((p) => p.id === id || p.name === id); - if (!plugin) { + .action((id: string, opts: PluginInspectOptions) => { + const cfg = loadConfig(); + const report = buildPluginStatusReport({ config: cfg }); + const inspect = buildPluginInspectReport({ + id, + config: cfg, + report, + }); + if (!inspect) { defaultRuntime.error(`Plugin not found: ${id}`); process.exit(1); } - const cfg = loadConfig(); - const install = cfg.plugins?.installs?.[plugin.id]; + const install = cfg.plugins?.installs?.[inspect.plugin.id]; if (opts.json) { - defaultRuntime.log(JSON.stringify(plugin, null, 2)); + defaultRuntime.log( + JSON.stringify( + { + ...inspect, + install, + }, + null, + 2, + ), + ); return; } const lines: string[] = []; - lines.push(theme.heading(plugin.name || plugin.id)); - if (plugin.name && plugin.name !== plugin.id) { - lines.push(theme.muted(`id: ${plugin.id}`)); + lines.push(theme.heading(inspect.plugin.name || inspect.plugin.id)); + if (inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id) { + lines.push(theme.muted(`id: ${inspect.plugin.id}`)); } - if (plugin.description) { - lines.push(plugin.description); + if (inspect.plugin.description) { + lines.push(inspect.plugin.description); } lines.push(""); - lines.push(`${theme.muted("Status:")} ${plugin.status}`); - lines.push(`${theme.muted("Format:")} ${plugin.format ?? "openclaw"}`); - if (plugin.bundleFormat) { - lines.push(`${theme.muted("Bundle format:")} ${plugin.bundleFormat}`); + lines.push(`${theme.muted("Status:")} ${inspect.plugin.status}`); + lines.push(`${theme.muted("Format:")} ${inspect.plugin.format ?? "openclaw"}`); + if (inspect.plugin.bundleFormat) { + lines.push(`${theme.muted("Bundle format:")} ${inspect.plugin.bundleFormat}`); } - lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`); - lines.push(`${theme.muted("Origin:")} ${plugin.origin}`); - if (plugin.version) { - lines.push(`${theme.muted("Version:")} ${plugin.version}`); + lines.push(`${theme.muted("Source:")} ${shortenHomeInString(inspect.plugin.source)}`); + lines.push(`${theme.muted("Origin:")} ${inspect.plugin.origin}`); + if (inspect.plugin.version) { + lines.push(`${theme.muted("Version:")} ${inspect.plugin.version}`); } - if (plugin.toolNames.length > 0) { - lines.push(`${theme.muted("Tools:")} ${plugin.toolNames.join(", ")}`); - } - if (plugin.hookNames.length > 0) { - lines.push(`${theme.muted("Hooks:")} ${plugin.hookNames.join(", ")}`); - } - if (plugin.gatewayMethods.length > 0) { - lines.push(`${theme.muted("Gateway methods:")} ${plugin.gatewayMethods.join(", ")}`); - } - if (plugin.providerIds.length > 0) { - lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`); - } - if ((plugin.bundleCapabilities?.length ?? 0) > 0) { + lines.push(`${theme.muted("Shape:")} ${inspect.shape}`); + lines.push(`${theme.muted("Capability mode:")} ${inspect.capabilityMode}`); + lines.push( + `${theme.muted("Legacy before_agent_start:")} ${inspect.usesLegacyBeforeAgentStart ? "yes" : "no"}`, + ); + if ((inspect.plugin.bundleCapabilities?.length ?? 0) > 0) { lines.push( - `${theme.muted("Bundle capabilities:")} ${plugin.bundleCapabilities?.join(", ")}`, + `${theme.muted("Bundle capabilities:")} ${inspect.plugin.bundleCapabilities?.join(", ")}`, ); } - if (plugin.cliCommands.length > 0) { - lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`); + lines.push( + ...formatInspectSection( + "Capabilities", + inspect.capabilities.map( + (entry) => + `${entry.kind}: ${entry.ids.length > 0 ? entry.ids.join(", ") : "(registered)"}`, + ), + ), + ); + lines.push( + ...formatInspectSection( + "Typed hooks", + inspect.typedHooks.map((entry) => + entry.priority == null ? entry.name : `${entry.name} (priority ${entry.priority})`, + ), + ), + ); + lines.push( + ...formatInspectSection( + "Custom hooks", + inspect.customHooks.map((entry) => `${entry.name}: ${entry.events.join(", ")}`), + ), + ); + lines.push( + ...formatInspectSection( + "Tools", + inspect.tools.map((entry) => { + const names = entry.names.length > 0 ? entry.names.join(", ") : "(anonymous)"; + return entry.optional ? `${names} [optional]` : names; + }), + ), + ); + lines.push(...formatInspectSection("Commands", inspect.commands)); + lines.push(...formatInspectSection("CLI commands", inspect.cliCommands)); + lines.push(...formatInspectSection("Services", inspect.services)); + lines.push(...formatInspectSection("Gateway methods", inspect.gatewayMethods)); + if (inspect.httpRouteCount > 0) { + lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)])); } - if (plugin.services.length > 0) { - lines.push(`${theme.muted("Services:")} ${plugin.services.join(", ")}`); + const policyLines: string[] = []; + if (typeof inspect.policy.allowPromptInjection === "boolean") { + policyLines.push(`allowPromptInjection: ${inspect.policy.allowPromptInjection}`); } - if (plugin.error) { - lines.push(`${theme.error("Error:")} ${plugin.error}`); + if (typeof inspect.policy.allowModelOverride === "boolean") { + policyLines.push(`allowModelOverride: ${inspect.policy.allowModelOverride}`); } - if (install) { - lines.push(""); - lines.push(`${theme.muted("Install:")} ${install.source}`); - if (install.spec) { - lines.push(`${theme.muted("Spec:")} ${install.spec}`); - } - if (install.sourcePath) { - lines.push(`${theme.muted("Source path:")} ${shortenHomePath(install.sourcePath)}`); - } - if (install.installPath) { - lines.push(`${theme.muted("Install path:")} ${shortenHomePath(install.installPath)}`); - } - if (install.version) { - lines.push(`${theme.muted("Recorded version:")} ${install.version}`); - } - if (install.installedAt) { - lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`); - } + if (inspect.policy.hasAllowedModelsConfig) { + policyLines.push( + `allowedModels: ${ + inspect.policy.allowedModels.length > 0 + ? inspect.policy.allowedModels.join(", ") + : "(configured but empty)" + }`, + ); + } + lines.push(...formatInspectSection("Policy", policyLines)); + lines.push( + ...formatInspectSection( + "Diagnostics", + inspect.diagnostics.map((entry) => `${entry.level.toUpperCase()}: ${entry.message}`), + ), + ); + lines.push(...formatInspectSection("Install", formatInstallLines(install))); + if (inspect.plugin.error) { + lines.push("", `${theme.error("Error:")} ${inspect.plugin.error}`); } defaultRuntime.log(lines.join("\n")); }); diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 3c7bc35cba6..b6e7aff30c0 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport; +let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), @@ -32,14 +33,21 @@ describe("buildPluginStatusReport", () => { diagnostics: [], channels: [], providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], tools: [], hooks: [], + typedHooks: [], + channelSetups: [], + httpRoutes: [], gatewayHandlers: {}, cliRegistrars: [], services: [], commands: [], }); - ({ buildPluginStatusReport } = await import("./status.js")); + ({ buildPluginInspectReport, buildPluginStatusReport } = await import("./status.js")); }); it("forwards an explicit env to plugin loading", () => { @@ -59,4 +67,93 @@ describe("buildPluginStatusReport", () => { }), ); }); + + it("builds an inspect report with capability shape and policy", () => { + loadConfigMock.mockReturnValue({ + plugins: { + entries: { + google: { + hooks: { allowPromptInjection: false }, + subagent: { + allowModelOverride: true, + allowedModels: ["openai/gpt-5.4"], + }, + }, + }, + }, + }); + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "google", + name: "Google", + description: "Google provider plugin", + source: "/tmp/google/index.ts", + origin: "bundled", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["google"], + speechProviderIds: [], + mediaUnderstandingProviderIds: ["google"], + imageGenerationProviderIds: ["google"], + webSearchProviderIds: ["google"], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [{ level: "warn", pluginId: "google", message: "watch this seam" }], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [ + { + pluginId: "google", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/google/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + const inspect = buildPluginInspectReport({ id: "google" }); + + expect(inspect).not.toBeNull(); + expect(inspect?.shape).toBe("hybrid-capability"); + expect(inspect?.capabilityMode).toBe("hybrid"); + expect(inspect?.capabilities.map((entry) => entry.kind)).toEqual([ + "text-inference", + "media-understanding", + "image-generation", + "web-search", + ]); + expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect?.policy).toEqual({ + allowPromptInjection: false, + allowModelOverride: true, + allowedModels: ["openai/gpt-5.4"], + hasAllowedModelsConfig: true, + }); + expect(inspect?.diagnostics).toEqual([ + { level: "warn", pluginId: "google", message: "watch this seam" }, + ]); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 65c48203eb8..b85d9e1cd24 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -2,14 +2,67 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import type { PluginRegistry } from "./registry.js"; +import type { PluginDiagnostic, PluginHookName } from "./types.js"; export type PluginStatusReport = PluginRegistry & { workspaceDir?: string; }; +export type PluginCapabilityKind = + | "text-inference" + | "speech" + | "media-understanding" + | "image-generation" + | "web-search" + | "channel"; + +export type PluginInspectShape = + | "hook-only" + | "plain-capability" + | "hybrid-capability" + | "non-capability"; + +export type PluginInspectReport = { + workspaceDir?: string; + plugin: PluginRegistry["plugins"][number]; + shape: PluginInspectShape; + capabilityMode: "none" | "plain" | "hybrid"; + capabilityCount: number; + capabilities: Array<{ + kind: PluginCapabilityKind; + ids: string[]; + }>; + typedHooks: Array<{ + name: PluginHookName; + priority?: number; + }>; + customHooks: Array<{ + name: string; + events: string[]; + }>; + tools: Array<{ + names: string[]; + optional: boolean; + }>; + commands: string[]; + cliCommands: string[]; + services: string[]; + gatewayMethods: string[]; + httpRouteCount: number; + diagnostics: PluginDiagnostic[]; + policy: { + allowPromptInjection?: boolean; + allowModelOverride?: boolean; + allowedModels: string[]; + hasAllowedModelsConfig: boolean; + }; + usesLegacyBeforeAgentStart: boolean; +}; + const log = createSubsystemLogger("plugins"); export function buildPluginStatusReport(params?: { @@ -36,3 +89,126 @@ export function buildPluginStatusReport(params?: { ...registry, }; } + +function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) { + return [ + { kind: "text-inference" as const, ids: plugin.providerIds }, + { kind: "speech" as const, ids: plugin.speechProviderIds }, + { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, + { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, + { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, + { kind: "channel" as const, ids: plugin.channelIds }, + ].filter((entry) => entry.ids.length > 0); +} + +function deriveInspectShape(params: { + capabilityCount: number; + typedHookCount: number; + customHookCount: number; + toolCount: number; + commandCount: number; + cliCount: number; + serviceCount: number; + gatewayMethodCount: number; + httpRouteCount: number; +}): PluginInspectShape { + if (params.capabilityCount > 1) { + return "hybrid-capability"; + } + if (params.capabilityCount === 1) { + return "plain-capability"; + } + const hasOnlyHooks = + params.typedHookCount + params.customHookCount > 0 && + params.toolCount === 0 && + params.commandCount === 0 && + params.cliCount === 0 && + params.serviceCount === 0 && + params.gatewayMethodCount === 0 && + params.httpRouteCount === 0; + if (hasOnlyHooks) { + return "hook-only"; + } + return "non-capability"; +} + +export function buildPluginInspectReport(params: { + id: string; + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): PluginInspectReport | null { + const config = params.config ?? loadConfig(); + const report = + params.report ?? + buildPluginStatusReport({ + config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const plugin = report.plugins.find((entry) => entry.id === params.id || entry.name === params.id); + if (!plugin) { + return null; + } + + const capabilities = buildCapabilityEntries(plugin); + const typedHooks = report.typedHooks + .filter((entry) => entry.pluginId === plugin.id) + .map((entry) => ({ + name: entry.hookName, + priority: entry.priority, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const customHooks = report.hooks + .filter((entry) => entry.pluginId === plugin.id) + .map((entry) => ({ + name: entry.entry.hook.name, + events: [...entry.events].sort(), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const tools = report.tools + .filter((entry) => entry.pluginId === plugin.id) + .map((entry) => ({ + names: [...entry.names], + optional: entry.optional, + })); + const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); + const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; + const capabilityCount = capabilities.length; + + return { + workspaceDir: report.workspaceDir, + plugin, + shape: deriveInspectShape({ + capabilityCount, + typedHookCount: typedHooks.length, + customHookCount: customHooks.length, + toolCount: tools.length, + commandCount: plugin.commands.length, + cliCount: plugin.cliCommands.length, + serviceCount: plugin.services.length, + gatewayMethodCount: plugin.gatewayMethods.length, + httpRouteCount: plugin.httpRoutes, + }), + capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", + capabilityCount, + capabilities, + typedHooks, + customHooks, + tools, + commands: [...plugin.commands], + cliCommands: [...plugin.cliCommands], + services: [...plugin.services], + gatewayMethods: [...plugin.gatewayMethods], + httpRouteCount: plugin.httpRoutes, + diagnostics, + policy: { + allowPromptInjection: policyEntry?.hooks?.allowPromptInjection, + allowModelOverride: policyEntry?.subagent?.allowModelOverride, + allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])], + hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true, + }, + usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"), + }; +} From 0d80897476795607ea27f622c8d4a261cfbe0005 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:33:35 -0700 Subject: [PATCH 120/393] Plugins: add inspect matrix and trim export --- package.json | 4 - scripts/lib/plugin-sdk-entrypoints.json | 1 - src/auto-reply/reply/commands-plugins.test.ts | 14 +++ src/auto-reply/reply/commands-plugins.ts | 25 +++++ src/cli/plugins-cli.ts | 105 +++++++++++++++++- src/plugins/status.test.ts | 103 ++++++++++++++++- src/plugins/status.ts | 26 +++++ 7 files changed, 269 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 9ee2b8e82bd..473a4fcfefe 100644 --- a/package.json +++ b/package.json @@ -430,10 +430,6 @@ "types": "./dist/plugin-sdk/image-generation.d.ts", "default": "./dist/plugin-sdk/image-generation.js" }, - "./plugin-sdk/image-generation-runtime": { - "types": "./dist/plugin-sdk/image-generation-runtime.d.ts", - "default": "./dist/plugin-sdk/image-generation-runtime.js" - }, "./plugin-sdk/reply-history": { "types": "./dist/plugin-sdk/reply-history.d.ts", "default": "./dist/plugin-sdk/reply-history.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index d67f48733f5..72de88ed3ca 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -97,7 +97,6 @@ "provider-usage", "provider-web-search", "image-generation", - "image-generation-runtime", "reply-history", "media-understanding", "google", diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index e5764574d29..1bf3feb772b 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -62,6 +62,20 @@ describe("handleCommands /plugins", () => { expect(showResult.reply?.text).toContain('"id": "superpowers"'); expect(showResult.reply?.text).toContain('"bundleFormat": "claude"'); expect(showResult.reply?.text).toContain('"shape":'); + + const inspectAllParams = buildCommandTestParams( + "/plugins inspect all", + buildCfg(), + undefined, + { + workspaceDir, + }, + ); + inspectAllParams.command.senderIsOwner = true; + const inspectAllResult = await handleCommands(inspectAllParams); + expect(inspectAllResult.reply?.text).toContain("```json"); + expect(inspectAllResult.reply?.text).toContain('"plugin"'); + expect(inspectAllResult.reply?.text).toContain('"superpowers"'); }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 197786479e8..1adbf57e717 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; import type { PluginRecord } from "../../plugins/registry.js"; import { + buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport, type PluginStatusReport, @@ -48,6 +49,22 @@ function buildPluginInspectJson(params: { }; } +function buildAllPluginInspectJson(params: { + config: OpenClawConfig; + report: PluginStatusReport; +}): Array<{ + inspect: ReturnType[number]; + install: PluginInstallRecord | null; +}> { + return buildAllPluginInspectReports({ + config: params.config, + report: params.report, + }).map((inspect) => ({ + inspect, + install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, + })); +} + function formatPluginLabel(plugin: PluginRecord): string { if (!plugin.name || plugin.name === plugin.id) { return plugin.id; @@ -164,6 +181,14 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm reply: { text: formatPluginsList(loaded.report) }, }; } + if (pluginsCommand.name.toLowerCase() === "all") { + return { + shouldContinue: false, + reply: { + text: renderJsonBlock("🔌 Plugins", buildAllPluginInspectJson(loaded)), + }, + }; + } const payload = buildPluginInspectJson({ id: pluginsCommand.name, config: loaded.config, diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index a73defc736b..c91f65c04c7 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -20,7 +20,11 @@ import { import type { PluginRecord } from "../plugins/registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; -import { buildPluginInspectReport, buildPluginStatusReport } from "../plugins/status.js"; +import { + buildAllPluginInspectReports, + buildPluginInspectReport, + buildPluginStatusReport, +} from "../plugins/status.js"; import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; @@ -45,6 +49,7 @@ export type PluginsListOptions = { export type PluginInspectOptions = { json?: boolean; + all?: boolean; }; export type PluginUpdateOptions = { @@ -141,6 +146,37 @@ function formatInspectSection(title: string, lines: string[]): string[] { return ["", `${theme.muted(`${title}:`)}`, ...lines]; } +function formatCapabilityKinds( + capabilities: Array<{ + kind: string; + }>, +): string { + if (capabilities.length === 0) { + return "-"; + } + return capabilities.map((entry) => entry.kind).join(", "); +} + +function formatHookSummary(params: { + usesLegacyBeforeAgentStart: boolean; + typedHookCount: number; + customHookCount: number; +}): string { + const parts: string[] = []; + if (params.usesLegacyBeforeAgentStart) { + parts.push("before_agent_start"); + } + const nonLegacyTypedHookCount = + params.typedHookCount - (params.usesLegacyBeforeAgentStart ? 1 : 0); + if (nonLegacyTypedHookCount > 0) { + parts.push(`${nonLegacyTypedHookCount} typed`); + } + if (params.customHookCount > 0) { + parts.push(`${params.customHookCount} custom`); + } + return parts.length > 0 ? parts.join(", ") : "-"; +} + function formatInstallLines(install: PluginInstallRecord | undefined): string[] { if (!install) { return []; @@ -576,11 +612,74 @@ export function registerPluginsCli(program: Command) { .command("inspect") .alias("info") .description("Inspect plugin details") - .argument("", "Plugin id") + .argument("[id]", "Plugin id") + .option("--all", "Inspect all plugins") .option("--json", "Print JSON") - .action((id: string, opts: PluginInspectOptions) => { + .action((id: string | undefined, opts: PluginInspectOptions) => { const cfg = loadConfig(); const report = buildPluginStatusReport({ config: cfg }); + if (opts.all) { + if (id) { + defaultRuntime.error("Pass either a plugin id or --all, not both."); + process.exit(1); + } + const inspectAll = buildAllPluginInspectReports({ + config: cfg, + report, + }); + const inspectAllWithInstall = inspectAll.map((inspect) => ({ + ...inspect, + install: cfg.plugins?.installs?.[inspect.plugin.id], + })); + + if (opts.json) { + defaultRuntime.log(JSON.stringify(inspectAllWithInstall, null, 2)); + return; + } + + const tableWidth = getTerminalTableWidth(); + const rows = inspectAll.map((inspect) => ({ + Name: inspect.plugin.name || inspect.plugin.id, + ID: + inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id + ? inspect.plugin.id + : "", + Status: + inspect.plugin.status === "loaded" + ? theme.success("loaded") + : inspect.plugin.status === "disabled" + ? theme.warn("disabled") + : theme.error("error"), + Shape: inspect.shape, + Capabilities: formatCapabilityKinds(inspect.capabilities), + Hooks: formatHookSummary({ + usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart, + typedHookCount: inspect.typedHooks.length, + customHookCount: inspect.customHooks.length, + }), + })); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Name", header: "Name", minWidth: 14, flex: true }, + { key: "ID", header: "ID", minWidth: 10, flex: true }, + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Shape", header: "Shape", minWidth: 18 }, + { key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true }, + { key: "Hooks", header: "Hooks", minWidth: 20, flex: true }, + ], + rows, + }).trimEnd(), + ); + return; + } + + if (!id) { + defaultRuntime.error("Provide a plugin id or use --all."); + process.exit(1); + } + const inspect = buildPluginInspectReport({ id, config: cfg, diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index b6e7aff30c0..d16db23da4b 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -4,6 +4,7 @@ const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport; let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport; +let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), @@ -47,7 +48,8 @@ describe("buildPluginStatusReport", () => { services: [], commands: [], }); - ({ buildPluginInspectReport, buildPluginStatusReport } = await import("./status.js")); + ({ buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport } = + await import("./status.js")); }); it("forwards an explicit env to plugin loading", () => { @@ -156,4 +158,103 @@ describe("buildPluginStatusReport", () => { { level: "warn", pluginId: "google", message: "watch this seam" }, ]); }); + + it("builds inspect reports for every loaded plugin", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "lca", + name: "LCA", + description: "Legacy hook plugin", + source: "/tmp/lca/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + { + id: "microsoft", + name: "Microsoft", + description: "Hybrid capability plugin", + source: "/tmp/microsoft/index.ts", + origin: "bundled", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["microsoft"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: ["microsoft"], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [ + { + pluginId: "lca", + events: ["message"], + entry: { + hook: { + name: "legacy", + handler: () => undefined, + }, + }, + }, + ], + typedHooks: [ + { + pluginId: "lca", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/lca/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + const inspect = buildAllPluginInspectReports(); + + expect(inspect.map((entry) => entry.plugin.id)).toEqual(["lca", "microsoft"]); + expect(inspect.map((entry) => entry.shape)).toEqual(["hook-only", "hybrid-capability"]); + expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect[1]?.capabilities.map((entry) => entry.kind)).toEqual([ + "text-inference", + "web-search", + ]); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index b85d9e1cd24..09a75e02516 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -212,3 +212,29 @@ export function buildPluginInspectReport(params: { usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"), }; } + +export function buildAllPluginInspectReports(params?: { + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): PluginInspectReport[] { + const config = params?.config ?? loadConfig(); + const report = + params?.report ?? + buildPluginStatusReport({ + config, + workspaceDir: params?.workspaceDir, + env: params?.env, + }); + + return report.plugins + .map((plugin) => + buildPluginInspectReport({ + id: plugin.id, + config, + report, + }), + ) + .filter((entry): entry is PluginInspectReport => entry !== null); +} From 4b2aec622bdf1ac6e41d840a685c70cfe0ab496c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:36:48 -0700 Subject: [PATCH 121/393] Plugins: add local extension API barrels --- extensions/talk-voice/api.ts | 1 + extensions/talk-voice/index.ts | 2 +- extensions/voice-call/api.ts | 1 + extensions/voice-call/index.ts | 2 +- extensions/voice-call/src/cli.ts | 2 +- extensions/voice-call/src/config.ts | 7 +------ extensions/voice-call/src/core-bridge.ts | 2 +- .../voice-call/src/providers/shared/guarded-json-api.ts | 2 +- extensions/voice-call/src/providers/tts-openai.ts | 2 +- extensions/voice-call/src/response-generator.ts | 2 +- extensions/voice-call/src/webhook.ts | 2 +- 11 files changed, 11 insertions(+), 14 deletions(-) create mode 100644 extensions/talk-voice/api.ts create mode 100644 extensions/voice-call/api.ts diff --git a/extensions/talk-voice/api.ts b/extensions/talk-voice/api.ts new file mode 100644 index 00000000000..a5ae821e944 --- /dev/null +++ b/extensions/talk-voice/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/talk-voice"; diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 5448c3425b0..d0916ea6b99 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -1,6 +1,6 @@ import { resolveActiveTalkProviderConfig } from "openclaw/plugin-sdk/config-runtime"; import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; -import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/talk-voice"; +import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; function mask(s: string, keep: number = 6): string { const trimmed = s.trim(); diff --git a/extensions/voice-call/api.ts b/extensions/voice-call/api.ts new file mode 100644 index 00000000000..ef9f7d7a3c0 --- /dev/null +++ b/extensions/voice-call/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/voice-call"; diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 9f976881a11..ad63cf1f52a 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -3,7 +3,7 @@ import { definePluginEntry, type GatewayRequestHandlerOptions, type OpenClawPluginApi, -} from "openclaw/plugin-sdk/voice-call"; +} from "./api.js"; import { registerVoiceCallCli } from "./src/cli.js"; import { VoiceCallConfigSchema, diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index c1abc9a1f0e..322a9dae355 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; -import { sleep } from "openclaw/plugin-sdk/voice-call"; +import { sleep } from "../api.js"; import type { VoiceCallConfig } from "./config.js"; import type { VoiceCallRuntime } from "./runtime.js"; import { resolveUserPath } from "./utils.js"; diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 2d1494c7876..5ecd4f01bd3 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -1,10 +1,5 @@ -import { - TtsAutoSchema, - TtsConfigSchema, - TtsModeSchema, - TtsProviderSchema, -} from "openclaw/plugin-sdk/voice-call"; import { z } from "zod"; +import { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema } from "../api.js"; import { deepMergeDefined } from "./deep-merge.js"; // ----------------------------------------------------------------------------- diff --git a/extensions/voice-call/src/core-bridge.ts b/extensions/voice-call/src/core-bridge.ts index 13ed56302fe..8c3981db346 100644 --- a/extensions/voice-call/src/core-bridge.ts +++ b/extensions/voice-call/src/core-bridge.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/voice-call"; +import type { OpenClawPluginApi } from "../api.js"; import type { VoiceCallTtsConfig } from "./config.js"; export type CoreConfig = { diff --git a/extensions/voice-call/src/providers/shared/guarded-json-api.ts b/extensions/voice-call/src/providers/shared/guarded-json-api.ts index cc8d1f33e03..625ad0f833a 100644 --- a/extensions/voice-call/src/providers/shared/guarded-json-api.ts +++ b/extensions/voice-call/src/providers/shared/guarded-json-api.ts @@ -1,4 +1,4 @@ -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/voice-call"; +import { fetchWithSsrFGuard } from "../../../api.js"; type GuardedJsonApiRequestParams = { url: string; diff --git a/extensions/voice-call/src/providers/tts-openai.ts b/extensions/voice-call/src/providers/tts-openai.ts index 0a7c74d90ac..c16b20c0a66 100644 --- a/extensions/voice-call/src/providers/tts-openai.ts +++ b/extensions/voice-call/src/providers/tts-openai.ts @@ -1,4 +1,4 @@ -import { resolveOpenAITtsInstructions } from "openclaw/plugin-sdk/voice-call"; +import { resolveOpenAITtsInstructions } from "../../api.js"; import { pcmToMulaw } from "../telephony-audio.js"; /** diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index 3c8a45eadfb..d1903410f86 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -4,7 +4,7 @@ */ import crypto from "node:crypto"; -import type { SessionEntry } from "openclaw/plugin-sdk/voice-call"; +import type { SessionEntry } from "../api.js"; import type { VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 2b79309c9f0..fe015727e73 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -4,7 +4,7 @@ import { isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/voice-call"; +} from "../api.js"; import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; import type { CallManager } from "./manager.js"; From 0f56b16d47f1f09f734b881011074d1b264213cf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:42:52 -0700 Subject: [PATCH 122/393] Plugins: internalize more extension SDK imports --- extensions/device-pair/api.ts | 1 + extensions/device-pair/index.ts | 4 ++-- extensions/device-pair/notify.ts | 4 ++-- extensions/llm-task/api.ts | 1 + extensions/llm-task/index.ts | 6 +----- extensions/llm-task/src/llm-task-tool.ts | 4 ++-- extensions/memory-lancedb/api.ts | 1 + extensions/memory-lancedb/index.ts | 2 +- extensions/thread-ownership/api.ts | 1 + extensions/thread-ownership/index.ts | 6 +----- 10 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 extensions/device-pair/api.ts create mode 100644 extensions/llm-task/api.ts create mode 100644 extensions/memory-lancedb/api.ts create mode 100644 extensions/thread-ownership/api.ts diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts new file mode 100644 index 00000000000..299ad90f05d --- /dev/null +++ b/extensions/device-pair/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/device-pair"; diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index ce007756389..defd3b5c4c6 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,4 +1,5 @@ import os from "node:os"; +import qrcode from "qrcode-terminal"; import { approveDevicePairing, definePluginEntry, @@ -8,8 +9,7 @@ import { runPluginCommandWithTimeout, resolveTailnetHostWithRunner, type OpenClawPluginApi, -} from "openclaw/plugin-sdk/device-pair"; -import qrcode from "qrcode-terminal"; +} from "./api.js"; import { armPairNotifyOnce, formatPendingRequests, diff --git a/extensions/device-pair/notify.ts b/extensions/device-pair/notify.ts index 3ef3005cf73..ba45e856372 100644 --- a/extensions/device-pair/notify.ts +++ b/extensions/device-pair/notify.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair"; -import { listDevicePairing } from "openclaw/plugin-sdk/device-pair"; +import type { OpenClawPluginApi } from "./api.js"; +import { listDevicePairing } from "./api.js"; const NOTIFY_STATE_FILE = "device-pair-notify.json"; const NOTIFY_POLL_INTERVAL_MS = 10_000; diff --git a/extensions/llm-task/api.ts b/extensions/llm-task/api.ts new file mode 100644 index 00000000000..8eebdd06e0b --- /dev/null +++ b/extensions/llm-task/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/llm-task"; diff --git a/extensions/llm-task/index.ts b/extensions/llm-task/index.ts index a3920e5806e..68dd70503c2 100644 --- a/extensions/llm-task/index.ts +++ b/extensions/llm-task/index.ts @@ -1,8 +1,4 @@ -import { - definePluginEntry, - type AnyAgentTool, - type OpenClawPluginApi, -} from "openclaw/plugin-sdk/llm-task"; +import { definePluginEntry, type AnyAgentTool, type OpenClawPluginApi } from "./api.js"; import { createLlmTaskTool } from "./src/llm-task-tool.js"; export default definePluginEntry({ diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index bcc422290c6..47c7efbea76 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -8,8 +8,8 @@ import { normalizeThinkLevel, resolvePreferredOpenClawTmpDir, supportsXHighThinking, -} from "openclaw/plugin-sdk/llm-task"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/llm-task"; +} from "../api.js"; +import type { OpenClawPluginApi } from "../api.js"; function stripCodeFences(s: string): string { const trimmed = s.trim(); diff --git a/extensions/memory-lancedb/api.ts b/extensions/memory-lancedb/api.ts new file mode 100644 index 00000000000..c1bd12dd4b7 --- /dev/null +++ b/extensions/memory-lancedb/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/memory-lancedb"; diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index b3033b118c9..96f77d0a90b 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto"; import type * as LanceDB from "@lancedb/lancedb"; import { Type } from "@sinclair/typebox"; import OpenAI from "openai"; -import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/memory-lancedb"; +import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; import { DEFAULT_CAPTURE_MAX_CHARS, MEMORY_CATEGORIES, diff --git a/extensions/thread-ownership/api.ts b/extensions/thread-ownership/api.ts new file mode 100644 index 00000000000..d94a5fd68e1 --- /dev/null +++ b/extensions/thread-ownership/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/thread-ownership"; diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts index 8e6e5c1d020..603b064bc68 100644 --- a/extensions/thread-ownership/index.ts +++ b/extensions/thread-ownership/index.ts @@ -1,8 +1,4 @@ -import { - definePluginEntry, - type OpenClawConfig, - type OpenClawPluginApi, -} from "openclaw/plugin-sdk/thread-ownership"; +import { definePluginEntry, type OpenClawConfig, type OpenClawPluginApi } from "./api.js"; type ThreadOwnershipConfig = { forwarderUrl?: string; From ff19ae17683da7f33b637f1454b15eeb81ab9e67 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:44:31 -0700 Subject: [PATCH 123/393] Plugins: internalize diffs SDK imports --- extensions/diffs/api.ts | 1 + extensions/diffs/index.test.ts | 2 +- extensions/diffs/index.ts | 4 ++-- extensions/diffs/src/browser.test.ts | 2 +- extensions/diffs/src/browser.ts | 2 +- extensions/diffs/src/config.ts | 2 +- extensions/diffs/src/http.ts | 2 +- extensions/diffs/src/store.ts | 2 +- extensions/diffs/src/tool.test.ts | 2 +- extensions/diffs/src/tool.ts | 2 +- extensions/diffs/src/url.ts | 2 +- 11 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 extensions/diffs/api.ts diff --git a/extensions/diffs/api.ts b/extensions/diffs/api.ts new file mode 100644 index 00000000000..e6fbaf9022a --- /dev/null +++ b/extensions/diffs/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/diffs"; diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index 3e7fd3c474b..02ce339e47c 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -1,8 +1,8 @@ import type { IncomingMessage } from "node:http"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { describe, expect, it, vi } from "vitest"; import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js"; import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import type { OpenClawPluginApi } from "./api.js"; import plugin from "./index.js"; describe("diffs plugin registration", () => { diff --git a/extensions/diffs/index.ts b/extensions/diffs/index.ts index b1547b1087d..5ce8c94fabd 100644 --- a/extensions/diffs/index.ts +++ b/extensions/diffs/index.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/diffs"; +import type { OpenClawPluginApi } from "./api.js"; +import { resolvePreferredOpenClawTmpDir } from "./api.js"; import { diffsPluginConfigSchema, resolveDiffsPluginDefaults, diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index c0b03d62cc0..8c16530ec15 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../api.js"; import { createTempDiffRoot } from "./test-helpers.js"; const { launchMock } = vi.hoisted(() => ({ diff --git a/extensions/diffs/src/browser.ts b/extensions/diffs/src/browser.ts index 904996946b6..53794ef83ee 100644 --- a/extensions/diffs/src/browser.ts +++ b/extensions/diffs/src/browser.ts @@ -1,8 +1,8 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; import { chromium } from "playwright-core"; +import type { OpenClawConfig } from "../api.js"; import type { DiffRenderOptions, DiffTheme } from "./types.js"; import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js"; diff --git a/extensions/diffs/src/config.ts b/extensions/diffs/src/config.ts index fbc9a108060..faaa8535bde 100644 --- a/extensions/diffs/src/config.ts +++ b/extensions/diffs/src/config.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/diffs"; +import type { OpenClawPluginConfigSchema } from "../api.js"; import { DIFF_IMAGE_QUALITY_PRESETS, DIFF_INDICATORS, diff --git a/extensions/diffs/src/http.ts b/extensions/diffs/src/http.ts index 445500b2340..48d9341bfce 100644 --- a/extensions/diffs/src/http.ts +++ b/extensions/diffs/src/http.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { PluginLogger } from "openclaw/plugin-sdk/diffs"; +import type { PluginLogger } from "../api.js"; import type { DiffArtifactStore } from "./store.js"; import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js"; import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js"; diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index e53a555356c..baab4757384 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import type { PluginLogger } from "openclaw/plugin-sdk/diffs"; +import type { PluginLogger } from "../api.js"; import type { DiffArtifactMeta, DiffOutputFormat } from "./types.js"; const DEFAULT_TTL_MS = 30 * 60 * 1000; diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index b0e019f33e2..f79098dd907 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js"; +import type { OpenClawPluginApi } from "../api.js"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index c6eb4b528c4..b20f11fee15 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import { Static, Type } from "@sinclair/typebox"; -import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; +import type { AnyAgentTool, OpenClawPluginApi } from "../api.js"; import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js"; import { resolveDiffImageRenderOptions } from "./config.js"; import { renderDiffDocument } from "./render.js"; diff --git a/extensions/diffs/src/url.ts b/extensions/diffs/src/url.ts index feee5c7af05..e8c40e05753 100644 --- a/extensions/diffs/src/url.ts +++ b/extensions/diffs/src/url.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; +import type { OpenClawConfig } from "../api.js"; const DEFAULT_GATEWAY_PORT = 18789; From 8124253cdff4e6a3511ca1989e9b4f68b8511db2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:46:08 -0700 Subject: [PATCH 124/393] Plugins: internalize diagnostics OTel imports --- extensions/diagnostics-otel/api.ts | 1 + extensions/diagnostics-otel/src/service.test.ts | 10 ++++------ extensions/diagnostics-otel/src/service.ts | 11 ++--------- 3 files changed, 7 insertions(+), 15 deletions(-) create mode 100644 extensions/diagnostics-otel/api.ts diff --git a/extensions/diagnostics-otel/api.ts b/extensions/diagnostics-otel/api.ts new file mode 100644 index 00000000000..01d7aed8989 --- /dev/null +++ b/extensions/diagnostics-otel/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/diagnostics-otel"; diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index d310b227be3..c8d08d07f1b 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -98,18 +98,16 @@ vi.mock("@opentelemetry/semantic-conventions", () => ({ ATTR_SERVICE_NAME: "service.name", })); -vi.mock("openclaw/plugin-sdk/diagnostics-otel", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/diagnostics-otel", - ); +vi.mock("../api.js", async () => { + const actual = await vi.importActual("../api.js"); return { ...actual, registerLogTransport: registerLogTransportMock, }; }); -import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk/diagnostics-otel"; -import { emitDiagnosticEvent } from "openclaw/plugin-sdk/diagnostics-otel"; +import type { OpenClawPluginServiceContext } from "../api.js"; +import { emitDiagnosticEvent } from "../api.js"; import { createDiagnosticsOtelService } from "./service.js"; const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test"; diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index b7224d034dd..2516b4c52e3 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -9,15 +9,8 @@ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; import { NodeSDK } from "@opentelemetry/sdk-node"; import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; -import type { - DiagnosticEventPayload, - OpenClawPluginService, -} from "openclaw/plugin-sdk/diagnostics-otel"; -import { - onDiagnosticEvent, - redactSensitiveText, - registerLogTransport, -} from "openclaw/plugin-sdk/diagnostics-otel"; +import type { DiagnosticEventPayload, OpenClawPluginService } from "../api.js"; +import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "../api.js"; const DEFAULT_SERVICE_NAME = "openclaw"; From 464f3da53fff565d9c84e9db5c2fe4c3ac95fb97 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:51:48 -0700 Subject: [PATCH 125/393] docs(plugins): document public capability model, plugin shapes, and inspection Add the public capability model section documenting the six capability types, plugin shape classification, capability labels, legacy hook guidance, export boundary rules, and the new plugins inspect command. Co-Authored-By: Claude Opus 4.6 --- docs/tools/plugin.md | 118 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 9f39b7d02bf..be14f5cfb99 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -105,6 +105,61 @@ Callback payload fields: This callback is notification-only. It does not change who is allowed to bind a conversation, and it runs after core approval handling finishes. +## Public capability model + +Capabilities are the public plugin model. Every native OpenClaw plugin +registers against one or more capability types: + +| Capability | Registration method | Example plugins | +|---|---|---| +| Text inference | `api.registerProvider(...)` | `openai`, `anthropic` | +| Speech | `api.registerSpeechProvider(...)` | `elevenlabs`, `microsoft` | +| Media understanding | `api.registerMediaUnderstandingProvider(...)` | `openai`, `google` | +| Image generation | `api.registerImageGenerationProvider(...)` | `openai`, `google` | +| Web search | `api.registerWebSearchProvider(...)` | `google` | +| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` | + +A plugin that registers zero capabilities but provides hooks, tools, or +services is a **legacy hook-only** plugin. That shape is still fully supported. + +### Plugin shapes + +OpenClaw classifies every loaded plugin into a shape based on its actual +registration behavior (not just static metadata): + +- **plain-capability** — registers exactly one capability type (for example a + provider-only plugin like `mistral`) +- **hybrid-capability** — registers multiple capability types (for example + `openai` owns text inference, speech, media understanding, and image + generation) +- **hook-only** — registers only hooks (typed or custom), no capabilities, + tools, commands, or services +- **non-capability** — registers tools, commands, services, or routes but no + capabilities + +Use `openclaw plugins inspect ` to see a plugin's shape and capability +breakdown. See [CLI reference](/cli/plugins#inspect) for details. + +### Capability labels + +Plugin capabilities use two stability labels: + +- `public` — stable, documented, and safe to depend on +- `experimental` — may change between releases + +### Legacy hooks + +The `before_agent_start` hook remains supported as a compatibility path for +hook-only plugins. Legacy real-world plugins still depend on it. + +Direction: + +- keep it working +- document it as legacy +- prefer `before_model_resolve` for model/provider override work +- prefer `before_prompt_build` for prompt mutation work +- remove only after real usage drops and fixture coverage proves migration safety + ## Architecture OpenClaw's plugin system has four layers: @@ -420,18 +475,24 @@ Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config validation does not execute plugin code**; it uses the plugin manifest and JSON Schema instead. See [Plugin manifest](/plugins/manifest). -Native OpenClaw plugins can register: +Native OpenClaw plugins can register capabilities and surfaces: -- Gateway RPC methods -- Gateway HTTP routes +**Capabilities** (public plugin model): + +- Text inference providers (model catalogs, auth, runtime hooks) +- Speech providers +- Media understanding providers +- Image generation providers +- Web search providers +- Channel / messaging connectors + +**Surfaces** (supporting infrastructure): + +- Gateway RPC methods and HTTP routes - Agent tools - CLI commands -- Speech providers -- Web search providers - Background services - Context engines -- Provider auth flows and model catalogs -- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, missing-auth hints, built-in model suppression, catalog augmentation, runtime auth exchange, and usage/billing auth + snapshot resolution - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) @@ -496,6 +557,49 @@ Bad plugin contracts are: When in doubt, raise the abstraction level: define the capability first, then let plugins plug into it. +## Export boundary + +OpenClaw exports capabilities, not implementation convenience. + +Keep capability registration public. Trim non-contract helper exports: + +- bundled-plugin-specific helper subpaths +- runtime plumbing subpaths not intended as public API +- vendor-specific convenience helpers +- setup/onboarding helpers that are implementation details + +## Plugin inspection + +Use `openclaw plugins inspect ` for deep plugin introspection. This is the +canonical command for understanding a plugin's shape and registration behavior. + +```bash +openclaw plugins inspect openai +openclaw plugins inspect openai --json +``` + +The inspect report shows: + +- identity, load status, source, and root +- plugin shape (plain-capability, hybrid-capability, hook-only, non-capability) +- capability mode and registered capabilities +- hooks (typed and custom), tools, commands, services +- channel registration +- config policy flags +- diagnostics +- whether the plugin uses the legacy `before_agent_start` hook +- install metadata + +Classification comes from actual registration behavior, not just static +metadata. + +Summary commands remain summary-focused: + +- `plugins list` — compact inventory +- `plugins status` — operational summary +- `doctor` — issue-focused diagnostics +- `plugins inspect` — deep detail + ## Provider runtime hooks Provider plugins now have two layers: From 025bdc7e8f1544bbd52635de2dde5fdc09708374 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:52:22 -0700 Subject: [PATCH 126/393] docs(cli): add plugins inspect command reference Co-Authored-By: Claude Opus 4.6 --- docs/cli/plugins.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 5e551a9c64f..6a137137af1 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -21,7 +21,7 @@ Related: ```bash openclaw plugins list -openclaw plugins info +openclaw plugins inspect openclaw plugins enable openclaw plugins disable openclaw plugins uninstall @@ -31,6 +31,8 @@ openclaw plugins update --all openclaw plugins marketplace list ``` +`info` is an alias for `inspect`. + Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to activate them. @@ -148,3 +150,26 @@ marketplace installs. When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw prints a warning and asks for confirmation before proceeding. Use global `--yes` to bypass prompts in CI/non-interactive runs. + +### Inspect + +```bash +openclaw plugins inspect +openclaw plugins inspect --json +``` + +Deep introspection for a single plugin. Shows identity, load status, source, +plugin shape, registered capabilities, hooks, tools, commands, services, +gateway methods, HTTP routes, policy flags, diagnostics, and install metadata. + +Plugin shape is derived from actual registration behavior: + +- **plain-capability** — one capability type registered +- **hybrid-capability** — multiple capability types registered +- **hook-only** — only hooks, no capabilities or surfaces +- **non-capability** — tools/commands/services but no capabilities + +The `--json` flag outputs a machine-readable report suitable for scripting and +auditing. + +`info` is an alias for `inspect`. From de564689dacdb8566a34847464f7379d405681c5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:53:02 -0700 Subject: [PATCH 127/393] docs(refactor): align plugin SDK plan with public capability model Add capability plan alignment section with key decisions and required test matrix. Rename seams to capabilities for consistency. Co-Authored-By: Claude Opus 4.6 --- docs/refactor/plugin-sdk.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 5a630982a97..edf79de266d 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -213,7 +213,33 @@ Notes: Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration). -## Implemented channel-owned seams +## Capability plan alignment + +The plugin SDK refactor now aligns with the public capability model documented +in [Plugins](/tools/plugin#public-capability-model). + +Key decisions: + +- Capabilities are the public plugin model. Registration is explicit and typed. +- Legacy hook-only plugins remain supported without migration. +- Plugin shapes (plain-capability, hybrid-capability, hook-only, non-capability) + are classified from actual registration behavior. +- `openclaw plugins inspect` provides canonical deep introspection for any + loaded plugin, showing shape, capabilities, hooks, tools, and diagnostics. +- Export boundary: export capabilities, not implementation convenience. Trim + non-contract helper exports. + +Required test matrix for the capability model: + +- hook-only legacy plugin fixture +- plain capability plugin fixture +- hybrid capability plugin fixture +- real-world legacy hook-style plugin fixture +- `before_agent_start` still works +- typed hooks remain additive +- capability usage and plugin shape are inspectable + +## Implemented channel-owned capabilities Recent refactor work widened the channel plugin contract so core can stop owning channel-specific UX and routing behavior: @@ -234,5 +260,5 @@ channel-specific UX and routing behavior: config mutation/removal - `allowlist.supportsScope`: channel-owned allowlist scope advertisement -These hooks should be preferred over new `channel === "discord"` / `telegram` -branches in shared core flows. +These capabilities should be preferred over new `channel === "discord"` / +`telegram` branches in shared core flows. From dd7b5dc46f5c823e9b3e0b97adc73bfd432e0378 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 10:59:30 -0700 Subject: [PATCH 128/393] docs(providers): clarify provider capabilities vs public capability model Co-Authored-By: Claude Opus 4.6 --- docs/concepts/model-providers.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 6adbb5d0f26..f5a73d7256e 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -32,6 +32,10 @@ For model selection rules, see [/concepts/models](/concepts/models). `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, and `fetchUsageSnapshot`. +- Note: provider runtime `capabilities` is shared runner metadata (provider + family, transcript/tooling quirks, transport/cache hints). It is not the + same as the [public capability model](/tools/plugin#public-capability-model) + which describes what a plugin registers (text inference, speech, etc.). ## Plugin-owned provider behavior From 3d3f292f66536aca8715416cf755529b41790aad Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 19:05:30 +0000 Subject: [PATCH 129/393] update contributing focus areas --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14a9b3c8bcd..9e487f254cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ Welcome to the lobster tank! 🦞 - **Christoph Nakazawa** - JS Infra - GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa) -- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI +- **Gustavo Madeira Santana** - Multi-agents, CLI, Performance, Plugins, Matrix - GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras) - **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams From 5a2a4abc120d60d2916ecf7eac6531ea9d982e27 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:17:41 -0500 Subject: [PATCH 130/393] CI: add built plugin singleton smoke (#48710) --- .github/workflows/ci.yml | 3 + package.json | 1 + scripts/test-built-plugin-singleton.mjs | 143 ++++++++++++++++++++++++ src/plugins/build-smoke-entry.ts | 7 ++ tsdown.config.ts | 1 + 5 files changed, 155 insertions(+) create mode 100644 scripts/test-built-plugin-singleton.mjs create mode 100644 src/plugins/build-smoke-entry.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dc68d2275a..c7bacc8504f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,6 +330,9 @@ jobs: - name: Smoke test CLI launcher status json run: node openclaw.mjs status --json --timeout 1 + - name: Smoke test built bundled plugin singleton + run: pnpm test:build:singleton + - name: Check CLI startup memory run: pnpm test:startup:memory diff --git a/package.json b/package.json index 473a4fcfefe..5dc22fb6bea 100644 --- a/package.json +++ b/package.json @@ -564,6 +564,7 @@ "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", + "test:build:singleton": "node scripts/test-built-plugin-singleton.mjs", "test:channels": "vitest run --config vitest.channels.config.ts", "test:contracts": "pnpm test:contracts:channels && pnpm test:contracts:plugins", "test:contracts:channels": "OPENCLAW_TEST_PROFILE=low pnpm test -- src/channels/plugins/contracts", diff --git a/scripts/test-built-plugin-singleton.mjs b/scripts/test-built-plugin-singleton.mjs new file mode 100644 index 00000000000..04e11c5f900 --- /dev/null +++ b/scripts/test-built-plugin-singleton.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js"); +assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`); + +const { clearPluginCommands, getPluginCommandSpecs, loadOpenClawPlugins, matchPluginCommand } = + await import(pathToFileURL(smokeEntryPath).href); + +assert.equal(typeof loadOpenClawPlugins, "function", "built loader export missing"); +assert.equal(typeof clearPluginCommands, "function", "clearPluginCommands missing"); +assert.equal(typeof getPluginCommandSpecs, "function", "getPluginCommandSpecs missing"); +assert.equal(typeof matchPluginCommand, "function", "matchPluginCommand missing"); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-smoke-")); + +function cleanup() { + clearPluginCommands(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +process.on("exit", cleanup); +process.on("SIGINT", () => { + cleanup(); + process.exit(130); +}); +process.on("SIGTERM", () => { + cleanup(); + process.exit(143); +}); + +const pluginId = "build-smoke-plugin"; +const distPluginDir = path.join(tempRoot, "dist", "extensions", pluginId); +fs.mkdirSync(distPluginDir, { recursive: true }); +fs.writeFileSync(path.join(tempRoot, "package.json"), '{ "type": "module" }\n', "utf8"); +fs.writeFileSync( + path.join(distPluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/build-smoke-plugin", + type: "module", + openclaw: { + extensions: ["./index.js"], + }, + }, + null, + 2, + ), + "utf8", +); +fs.writeFileSync( + path.join(distPluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + ), + "utf8", +); +fs.writeFileSync( + path.join(distPluginDir, "index.js"), + [ + "import sdk from 'openclaw/plugin-sdk';", + "const { emptyPluginConfigSchema } = sdk;", + "", + "export default {", + ` id: ${JSON.stringify(pluginId)},`, + " configSchema: emptyPluginConfigSchema(),", + " register(api) {", + " api.registerCommand({", + " name: 'pair',", + " description: 'Pair a device',", + " acceptsArgs: true,", + " nativeNames: { telegram: 'pair', discord: 'pair' },", + " async handler({ args }) {", + " return { text: `paired:${args ?? ''}` };", + " },", + " });", + " },", + "};", + "", + ].join("\n"), + "utf8", +); + +stageBundledPluginRuntime({ repoRoot: tempRoot }); + +const runtimeEntryPath = path.join(tempRoot, "dist-runtime", "extensions", pluginId, "index.js"); +assert.ok(fs.existsSync(runtimeEntryPath), "runtime overlay entry missing"); +assert.equal( + fs.existsSync(path.join(tempRoot, "dist-runtime", "plugins", "commands.js")), + false, + "dist-runtime must not stage a duplicate commands module", +); + +clearPluginCommands(); + +const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: tempRoot, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(tempRoot, "dist-runtime", "extensions"), + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + }, + config: { + plugins: { + enabled: true, + allow: [pluginId], + entries: { + [pluginId]: { enabled: true }, + }, + }, + }, +}); + +const record = registry.plugins.find((entry) => entry.id === pluginId); +assert.ok(record, "smoke plugin missing from registry"); +assert.equal(record.status, "loaded", record.error ?? "smoke plugin failed to load"); + +assert.deepEqual(getPluginCommandSpecs("telegram"), [ + { name: "pair", description: "Pair a device", acceptsArgs: true }, +]); + +const match = matchPluginCommand("/pair now"); +assert.ok(match, "canonical built command registry did not receive the command"); +assert.equal(match.args, "now"); +const result = await match.command.handler({ args: match.args }); +assert.deepEqual(result, { text: "paired:now" }); + +process.stdout.write("[build-smoke] built plugin singleton smoke passed\n"); diff --git a/src/plugins/build-smoke-entry.ts b/src/plugins/build-smoke-entry.ts new file mode 100644 index 00000000000..c4604dedfeb --- /dev/null +++ b/src/plugins/build-smoke-entry.ts @@ -0,0 +1,7 @@ +export { + clearPluginCommands, + executePluginCommand, + getPluginCommandSpecs, + matchPluginCommand, +} from "./commands.js"; +export { loadOpenClawPlugins } from "./loader.js"; diff --git a/tsdown.config.ts b/tsdown.config.ts index 966e12afc10..48e69927f98 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -171,6 +171,7 @@ function buildCoreDistEntries(): Record { "line/accounts": "src/line/accounts.ts", "line/send": "src/line/send.ts", "line/template-messages": "src/line/template-messages.ts", + "plugins/build-smoke-entry": "src/plugins/build-smoke-entry.ts", "plugins/runtime/index": "src/plugins/runtime/index.ts", "llm-slug-generator": "src/hooks/llm-slug-generator.ts", }; From b31b68108882fba2163f73eb110574e5684df88e Mon Sep 17 00:00:00 2001 From: darkamenosa Date: Wed, 18 Mar 2026 03:33:22 +0700 Subject: [PATCH 131/393] fix(zalouser): fix setup-only onboarding flow (#49219) * zalouser: extract shared plugin base to reduce duplication * fix(zalouser): bump zca-js to 2.1.2 and fix state dir resolution * fix(zalouser): allow empty allowlist during onboarding and add quickstart DM policy prompt * fix minor review * fix(zalouser): restore forceAllowFrom setup flow * fix(zalouser): default group access to allowlist --- extensions/zalouser/package.json | 4 +- extensions/zalouser/setup-entry.ts | 6 +- extensions/zalouser/src/accounts.test.ts | 13 + extensions/zalouser/src/accounts.ts | 7 +- extensions/zalouser/src/channel.setup.test.ts | 35 +++ extensions/zalouser/src/channel.setup.ts | 12 + extensions/zalouser/src/channel.ts | 79 +----- extensions/zalouser/src/config-schema.ts | 2 +- .../zalouser/src/monitor.group-gating.test.ts | 29 +++ extensions/zalouser/src/setup-surface.test.ts | 238 ++++++++++++++++++ extensions/zalouser/src/setup-surface.ts | 159 ++++++++++-- extensions/zalouser/src/shared.ts | 95 +++++++ extensions/zalouser/src/zalo-js.ts | 4 +- pnpm-lock.yaml | 12 +- 14 files changed, 584 insertions(+), 111 deletions(-) create mode 100644 extensions/zalouser/src/channel.setup.test.ts create mode 100644 extensions/zalouser/src/channel.setup.ts create mode 100644 extensions/zalouser/src/shared.ts diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 5e3a1070237..322053904fd 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "@sinclair/typebox": "0.34.48", - "zca-js": "2.1.1", + "zca-js": "2.1.2", "zod": "^4.3.6" }, "openclaw": { @@ -24,7 +24,7 @@ "zlu" ], "order": 85, - "quickstartAllowFrom": true + "quickstartAllowFrom": false }, "install": { "npmSpec": "@openclaw/zalouser", diff --git a/extensions/zalouser/setup-entry.ts b/extensions/zalouser/setup-entry.ts index 0320d3cf945..df1681dd12d 100644 --- a/extensions/zalouser/setup-entry.ts +++ b/extensions/zalouser/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; -import { zalouserPlugin } from "./src/channel.js"; +import { zalouserSetupPlugin } from "./src/channel.setup.js"; -export default defineSetupPluginEntry(zalouserPlugin); +export { zalouserSetupPlugin } from "./src/channel.setup.js"; + +export default defineSetupPluginEntry(zalouserSetupPlugin); diff --git a/extensions/zalouser/src/accounts.test.ts b/extensions/zalouser/src/accounts.test.ts index 7b6a63d66a7..11f9704f759 100644 --- a/extensions/zalouser/src/accounts.test.ts +++ b/extensions/zalouser/src/accounts.test.ts @@ -124,6 +124,19 @@ describe("zalouser account resolution", () => { expect(resolved.config.allowFrom).toEqual(["123"]); }); + it("defaults group policy to allowlist when unset", () => { + const cfg = asConfig({ + channels: { + zalouser: { + enabled: true, + }, + }, + }); + + const resolved = resolveZalouserAccountSync({ cfg, accountId: "default" }); + expect(resolved.config.groupPolicy).toBe("allowlist"); + }); + it("resolves profile precedence correctly", () => { const cfg = asConfig({ channels: { diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 26a02ed47a0..71385db0e17 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -24,7 +24,12 @@ function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): Zal const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig; const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; + const merged = { ...base, ...account }; + return { + ...merged, + // Match Telegram's safe default: groups stay allowlisted unless explicitly opened. + groupPolicy: merged.groupPolicy ?? "allowlist", + }; } function resolveProfile(config: ZalouserAccountConfig, accountId: string): string { diff --git a/extensions/zalouser/src/channel.setup.test.ts b/extensions/zalouser/src/channel.setup.test.ts new file mode 100644 index 00000000000..552a45c882e --- /dev/null +++ b/extensions/zalouser/src/channel.setup.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; +import { zalouserSetupPlugin } from "./channel.setup.js"; + +const zalouserSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ + plugin: zalouserSetupPlugin, + wizard: zalouserSetupPlugin.setupWizard!, +}); + +describe("zalouser setup plugin", () => { + it("builds setup status without an initialized runtime", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-setup-")); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await expect( + zalouserSetupAdapter.getStatus({ + cfg: {}, + accountOverrides: {}, + }), + ).resolves.toMatchObject({ + channel: "zalouser", + configured: false, + statusLines: ["Zalo Personal: needs QR login"], + }); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/zalouser/src/channel.setup.ts b/extensions/zalouser/src/channel.setup.ts new file mode 100644 index 00000000000..1280bbb0e51 --- /dev/null +++ b/extensions/zalouser/src/channel.setup.ts @@ -0,0 +1,12 @@ +import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser"; +import type { ResolvedZalouserAccount } from "./accounts.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; +import { zalouserSetupWizard } from "./setup-surface.js"; +import { createZalouserPluginBase } from "./shared.js"; + +export const zalouserSetupPlugin: ChannelPlugin = { + ...createZalouserPluginBase({ + setupWizard: zalouserSetupWizard, + setup: zalouserSetupAdapter, + }), +}; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 1fee83709ef..4822ecb3f3e 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,4 +1,3 @@ -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { buildAccountScopedDmSecurityPolicy } from "openclaw/plugin-sdk/channel-policy"; import type { @@ -13,15 +12,11 @@ import type { import { buildChannelSendResult, buildBaseAccountStatusSnapshot, - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatAllowFromLowercase, isDangerousNameMatchingEnabled, isNumericTargetId, normalizeAccountId, sendPayloadWithChunkedTextAndMedia, - setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalouser"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -32,7 +27,6 @@ import { checkZcaAuthenticated, type ResolvedZalouserAccount, } from "./accounts.js"; -import { ZalouserConfigSchema } from "./config-schema.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; import { probeZalouser } from "./probe.js"; @@ -41,6 +35,7 @@ import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; import { zalouserSetupAdapter } from "./setup-core.js"; import { zalouserSetupWizard } from "./setup-surface.js"; +import { createZalouserPluginBase } from "./shared.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, @@ -52,18 +47,6 @@ import { getZaloUserInfo, } from "./zalo-js.js"; -const meta = { - id: "zalouser", - label: "Zalo Personal", - selectionLabel: "Zalo (Personal Account)", - docsPath: "/channels/zalouser", - docsLabel: "zalouser", - blurb: "Zalo personal account via QR code login.", - aliases: ["zlu"], - order: 85, - quickstartAllowFrom: true, -}; - const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; function stripZalouserTargetPrefix(raw: string): string { @@ -304,62 +287,10 @@ const zalouserMessageActions: ChannelMessageActionAdapter = { }; export const zalouserPlugin: ChannelPlugin = { - id: "zalouser", - meta, - setup: zalouserSetupAdapter, - setupWizard: zalouserSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - threads: false, - polls: false, - nativeCommands: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.zalouser"] }, - configSchema: buildChannelConfigSchema(ZalouserConfigSchema), - config: { - listAccountIds: (cfg) => listZalouserAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "zalouser", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "zalouser", - accountId, - clearBaseFields: [ - "profile", - "name", - "dmPolicy", - "allowFrom", - "historyLimit", - "groupAllowFrom", - "groupPolicy", - "groups", - "messagePrefix", - ], - }), - isConfigured: async (account) => await checkZcaAuthenticated(account.profile), - describeAccount: (account): ChannelAccountSnapshot => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: undefined, - }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }), - }, + ...createZalouserPluginBase({ + setupWizard: zalouserSetupWizard, + setup: zalouserSetupAdapter, + }), security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 475ba16bca2..e3c4c4ae7ea 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -24,7 +24,7 @@ const zalouserAccountSchema = z.object({ allowFrom: AllowFromListSchema, historyLimit: z.number().int().min(0).optional(), groupAllowFrom: AllowFromListSchema, - groupPolicy: GroupPolicySchema.optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), groups: z.object({}).catchall(groupConfigSchema).optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index 9ac3b29841b..ebf28342f26 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./monitor.send-mocks.js"; +import { resolveZalouserAccountSync } from "./accounts.js"; import { __testing } from "./monitor.js"; import { sendDeliveredZalouserMock, @@ -376,6 +377,34 @@ describe("zalouser monitor group mention gating", () => { await expectSkippedGroupMessage(); }); + it("blocks mentioned group messages by default when groupPolicy is omitted", async () => { + const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ + commandAuthorized: false, + }); + const cfg: OpenClawConfig = { + channels: { + zalouser: { + enabled: true, + }, + }, + }; + const account = resolveZalouserAccountSync({ cfg, accountId: "default" }); + + await __testing.processMessage({ + message: createGroupMessage({ + content: "ping @bot", + hasAnyMention: true, + wasExplicitlyMentioned: true, + }), + account, + config: cfg, + runtime: createRuntimeEnv(), + }); + + expect(account.config.groupPolicy).toBe("allowlist"); + expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + it("fails closed when requireMention=true but mention detection is unavailable", async () => { await expectSkippedGroupMessage({ canResolveExplicitMention: false, diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index af95c35465b..b36b5801a54 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -61,5 +61,243 @@ describe("zalouser setup wizard", () => { expect(result.accountId).toBe("default"); expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + }); + + it("prompts DM policy before group access in quickstart", async () => { + const runtime = createRuntimeEnv(); + const seen: string[] = []; + const prompter = createTestWizardPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + seen.push(message); + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + select: vi.fn( + async ({ message, options }: { message: string; options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + seen.push(message); + if (message === "Zalo Personal DM policy") { + return "pairing"; + } + return first.value; + }, + ) as ReturnType["select"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: { quickstartDefaults: true }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("pairing"); + expect(seen.indexOf("Zalo Personal DM policy")).toBeGreaterThanOrEqual(0); + expect(seen.indexOf("Configure Zalo groups access?")).toBeGreaterThanOrEqual(0); + expect(seen.indexOf("Zalo Personal DM policy")).toBeLessThan( + seen.indexOf("Configure Zalo groups access?"), + ); + }); + + it("allows an empty quickstart DM allowlist with a warning", async () => { + const runtime = createRuntimeEnv(); + const note = vi.fn(async (_message: string, _title?: string) => {}); + const prompter = createTestWizardPrompter({ + note, + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + select: vi.fn( + async ({ message, options }: { message: string; options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + if (message === "Zalo Personal DM policy") { + return "allowlist"; + } + return first.value; + }, + ) as ReturnType["select"], + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Zalouser allowFrom (name or user id)") { + return ""; + } + return ""; + }) as ReturnType["text"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: { quickstartDefaults: true }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]); + expect( + note.mock.calls.some(([message]) => + String(message).includes("No DM allowlist entries added yet."), + ), + ).toBe(true); + }); + + it("allows an empty group allowlist with a warning", async () => { + const runtime = createRuntimeEnv(); + const note = vi.fn(async (_message: string, _title?: string) => {}); + const prompter = createTestWizardPrompter({ + note, + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return true; + } + return false; + }), + select: vi.fn( + async ({ message, options }: { message: string; options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + if (message === "Zalo groups access") { + return "allowlist"; + } + return first.value; + }, + ) as ReturnType["select"], + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Zalo groups allowlist (comma-separated)") { + return ""; + } + return ""; + }) as ReturnType["text"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.zalouser?.groupPolicy).toBe("allowlist"); + expect(result.cfg.channels?.zalouser?.groups).toEqual({}); + expect( + note.mock.calls.some(([message]) => + String(message).includes("No group allowlist entries added yet."), + ), + ).toBe(true); + }); + + it("preserves non-quickstart forceAllowFrom behavior", async () => { + const runtime = createRuntimeEnv(); + const note = vi.fn(async (_message: string, _title?: string) => {}); + const seen: string[] = []; + const prompter = createTestWizardPrompter({ + note, + confirm: vi.fn(async ({ message }: { message: string }) => { + seen.push(message); + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + text: vi.fn(async ({ message }: { message: string }) => { + seen.push(message); + if (message === "Zalouser allowFrom (name or user id)") { + return ""; + } + return ""; + }) as ReturnType["text"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]); + expect(seen).not.toContain("Zalo Personal DM policy"); + expect(seen).toContain("Zalouser allowFrom (name or user id)"); + expect( + note.mock.calls.some(([message]) => + String(message).includes("No DM allowlist entries added yet."), + ), + ).toBe(true); + }); + + it("allowlists the plugin when a plugin allowlist already exists", async () => { + const runtime = createRuntimeEnv(); + const prompter = createTestWizardPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: { + plugins: { + allow: ["telegram"], + }, + } as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.allow).toEqual(["telegram", "zalouser"]); }); }); diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index f51b55ff068..1249bf9b5de 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,5 +1,6 @@ import { DEFAULT_ACCOUNT_ID, + formatCliCommand, formatDocsLink, formatResolvedUnresolvedNote, mergeAllowFromEntries, @@ -8,6 +9,7 @@ import { setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupDmPolicy, type ChannelSetupWizard, + type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import { @@ -27,6 +29,18 @@ import { } from "./zalo-js.js"; const channel = "zalouser" as const; +const ZALOUSER_ALLOW_FROM_PLACEHOLDER = "Alice, 123456789, or leave empty to configure later"; +const ZALOUSER_GROUPS_PLACEHOLDER = "Family, Work, 123456789, or leave empty for now"; +const ZALOUSER_DM_ACCESS_TITLE = "Zalo Personal DM access"; +const ZALOUSER_ALLOWLIST_TITLE = "Zalo Personal allowlist"; +const ZALOUSER_GROUPS_TITLE = "Zalo groups"; + +function parseZalouserEntries(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} function setZalouserAccountScopedConfig( cfg: OpenClawConfig, @@ -43,10 +57,7 @@ function setZalouserAccountScopedConfig( }) as OpenClawConfig; } -function setZalouserDmPolicy( - cfg: OpenClawConfig, - dmPolicy: "pairing" | "allowlist" | "open" | "disabled", -): OpenClawConfig { +function setZalouserDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, channel, @@ -69,12 +80,41 @@ function setZalouserGroupAllowlist( accountId: string, groupKeys: string[], ): OpenClawConfig { - const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); + const groups = Object.fromEntries( + groupKeys.map((key) => [key, { allow: true, requireMention: true }]), + ); return setZalouserAccountScopedConfig(cfg, accountId, { groups, }); } +function ensureZalouserPluginEnabled(cfg: OpenClawConfig): OpenClawConfig { + const next: OpenClawConfig = { + ...cfg, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + zalouser: { + ...cfg.plugins?.entries?.zalouser, + enabled: true, + }, + }, + }, + }; + const allow = next.plugins?.allow; + if (!Array.isArray(allow) || allow.includes(channel)) { + return next; + } + return { + ...next, + plugins: { + ...next.plugins, + allow: [...allow, channel], + }, + }; +} + async function noteZalouserHelp( prompter: Parameters>[0]["prompter"], ): Promise { @@ -98,20 +138,28 @@ async function promptZalouserAllowFrom(params: { const { cfg, prompter, accountId } = params; const resolved = resolveZalouserAccountSync({ cfg, accountId }); const existingAllowFrom = resolved.config.allowFrom ?? []; - const parseInput = (raw: string) => - raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); while (true) { const entry = await prompter.text({ message: "Zalouser allowFrom (name or user id)", - placeholder: "Alice, 123456789", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + placeholder: ZALOUSER_ALLOW_FROM_PLACEHOLDER, + initialValue: existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : undefined, }); - const parts = parseInput(String(entry)); + const parts = parseZalouserEntries(String(entry)); + if (parts.length === 0) { + await prompter.note( + [ + "No DM allowlist entries added yet.", + "Direct chats will stay blocked until you add people later.", + `Tip: use \`${formatCliCommand("openclaw directory peers list --channel zalouser")}\` to look up people after onboarding.`, + ].join("\n"), + ZALOUSER_ALLOWLIST_TITLE, + ); + return setZalouserAccountScopedConfig(cfg, accountId, { + dmPolicy: "allowlist", + allowFrom: [], + }); + } const resolvedEntries = await resolveZaloAllowFromEntries({ profile: resolved.profile, entries: parts, @@ -121,7 +169,7 @@ async function promptZalouserAllowFrom(params: { if (unresolved.length > 0) { await prompter.note( `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`, - "Zalo Personal allowlist", + ZALOUSER_ALLOWLIST_TITLE, ); continue; } @@ -135,7 +183,7 @@ async function promptZalouserAllowFrom(params: { .filter((item) => item.note) .map((item) => `${item.input} -> ${item.id} (${item.note})`); if (notes.length > 0) { - await prompter.note(notes.join("\n"), "Zalo Personal allowlist"); + await prompter.note(notes.join("\n"), ZALOUSER_ALLOWLIST_TITLE); } return setZalouserAccountScopedConfig(cfg, accountId, { @@ -150,7 +198,7 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = { channel, policyKey: "channels.zalouser.dmPolicy", allowFromKey: "channels.zalouser.allowFrom", - getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as "pairing", + getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy, setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = @@ -165,6 +213,52 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = { }, }; +async function promptZalouserQuickstartDmPolicy(params: { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const resolved = resolveZalouserAccountSync({ cfg, accountId }); + const existingPolicy = (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy; + const existingAllowFrom = resolved.config.allowFrom ?? []; + const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + + await prompter.note( + [ + "Direct chats are configured separately from group chats.", + "- pairing (default): unknown people get a pairing code", + "- allowlist: only listed people can DM", + "- open: anyone can DM", + "- disabled: ignore DMs", + "", + `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, + "If you choose allowlist now, you can leave it empty and add people later.", + ].join("\n"), + ZALOUSER_DM_ACCESS_TITLE, + ); + + const policy = (await prompter.select({ + message: "Zalo Personal DM policy", + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "allowlist", label: "Allowlist (specific users only)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore DMs)" }, + ], + initialValue: existingPolicy, + })) as DmPolicy; + + if (policy === "allowlist") { + return await promptZalouserAllowFrom({ + cfg, + prompter, + accountId, + }); + } + return setZalouserDmPolicy(cfg, policy); +} + export { zalouserSetupAdapter } from "./setup-core.js"; export const zalouserSetupWizard: ChannelSetupWizard = { @@ -191,7 +285,7 @@ export const zalouserSetupWizard: ChannelSetupWizard = { return [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`]; }, }, - prepare: async ({ cfg, accountId, prompter }) => { + prepare: async ({ cfg, accountId, prompter, options }) => { let next = cfg; const account = resolveZalouserAccountSync({ cfg: next, accountId }); const alreadyAuthenticated = await checkZcaAuthenticated(account.profile); @@ -265,12 +359,20 @@ export const zalouserSetupWizard: ChannelSetupWizard = { { profile: account.profile, enabled: true }, ); + if (options?.quickstartDefaults) { + next = await promptZalouserQuickstartDmPolicy({ + cfg: next, + prompter, + accountId, + }); + } + return { cfg: next }; }, credentials: [], groupAccess: { label: "Zalo groups", - placeholder: "Family, Work, 123456789", + placeholder: ZALOUSER_GROUPS_PLACEHOLDER, currentPolicy: ({ cfg, accountId }) => resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist", currentEntries: ({ cfg, accountId }) => @@ -281,6 +383,15 @@ export const zalouserSetupWizard: ChannelSetupWizard = { setZalouserGroupPolicy(cfg as OpenClawConfig, accountId, policy), resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => { if (entries.length === 0) { + await prompter.note( + [ + "No group allowlist entries added yet.", + "Group chats will stay blocked until you add groups later.", + `Tip: use \`${formatCliCommand("openclaw directory groups list --channel zalouser")}\` after onboarding to find group IDs.`, + "Mention requirement stays on by default for groups you allow later.", + ].join("\n"), + ZALOUSER_GROUPS_TITLE, + ); return []; } const updatedAccount = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }); @@ -299,13 +410,13 @@ export const zalouserSetupWizard: ChannelSetupWizard = { unresolved, }); if (resolution) { - await prompter.note(resolution, "Zalo groups"); + await prompter.note(resolution, ZALOUSER_GROUPS_TITLE); } return keys; } catch (err) { await prompter.note( `Group lookup failed; keeping entries as typed. ${String(err)}`, - "Zalo groups", + ZALOUSER_GROUPS_TITLE, ); return entries.map((entry) => entry.trim()).filter(Boolean); } @@ -313,16 +424,16 @@ export const zalouserSetupWizard: ChannelSetupWizard = { applyAllowlist: ({ cfg, accountId, resolved }) => setZalouserGroupAllowlist(cfg as OpenClawConfig, accountId, resolved as string[]), }, - finalize: async ({ cfg, accountId, forceAllowFrom, prompter }) => { + finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => { let next = cfg; - if (forceAllowFrom) { + if (forceAllowFrom && !options?.quickstartDefaults) { next = await promptZalouserAllowFrom({ cfg: next, prompter, accountId, }); } - return { cfg: next }; + return { cfg: ensureZalouserPluginEnabled(next) }; }, dmPolicy: zalouserDmPolicy, }; diff --git a/extensions/zalouser/src/shared.ts b/extensions/zalouser/src/shared.ts new file mode 100644 index 00000000000..bac69441806 --- /dev/null +++ b/extensions/zalouser/src/shared.ts @@ -0,0 +1,95 @@ +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser"; +import { + buildChannelConfigSchema, + deleteAccountFromConfigSection, + formatAllowFromLowercase, + setAccountEnabledInConfigSection, +} from "openclaw/plugin-sdk/zalouser"; +import { + listZalouserAccountIds, + resolveDefaultZalouserAccountId, + resolveZalouserAccountSync, + checkZcaAuthenticated, + type ResolvedZalouserAccount, +} from "./accounts.js"; +import { ZalouserConfigSchema } from "./config-schema.js"; + +export const zalouserMeta = { + id: "zalouser", + label: "Zalo Personal", + selectionLabel: "Zalo (Personal Account)", + docsPath: "/channels/zalouser", + docsLabel: "zalouser", + blurb: "Zalo personal account via QR code login.", + aliases: ["zlu"], + order: 85, + quickstartAllowFrom: false, +} satisfies ChannelPlugin["meta"]; + +export function createZalouserPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" +> { + return { + id: "zalouser", + meta: zalouserMeta, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + threads: false, + polls: false, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.zalouser"] }, + configSchema: buildChannelConfigSchema(ZalouserConfigSchema), + config: { + listAccountIds: (cfg) => listZalouserAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "zalouser", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "zalouser", + accountId, + clearBaseFields: [ + "profile", + "name", + "dmPolicy", + "allowFrom", + "historyLimit", + "groupAllowFrom", + "groupPolicy", + "groups", + "messagePrefix", + ], + }), + isConfigured: async (account) => await checkZcaAuthenticated(account.profile), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: undefined, + }), + resolveAllowFrom: ({ cfg, accountId }) => + mapAllowFromEntries(resolveZalouserAccountSync({ cfg, accountId }).config.allowFrom), + formatAllowFrom: ({ allowFrom }) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }), + }, + setup: params.setup, + }; +} diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 0e2d744232f..8cc20e59158 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -3,9 +3,9 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resolveStateDir as resolvePluginStateDir } from "openclaw/plugin-sdk/state-paths"; import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/zalouser"; import { normalizeZaloReactionIcon } from "./reaction.js"; -import { getZalouserRuntime } from "./runtime.js"; import type { ZaloAuthStatus, ZaloEventMessage, @@ -85,7 +85,7 @@ type StoredZaloCredentials = { }; function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string { - return getZalouserRuntime().state.resolveStateDir(env, os.homedir); + return resolvePluginStateDir(env, os.homedir); } function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bde6311c766..46365a29362 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,8 @@ importers: extensions/byteplus: {} + extensions/chutes: {} + extensions/cloudflare-ai-gateway: {} extensions/copilot-proxy: {} @@ -601,8 +603,8 @@ importers: specifier: 0.34.48 version: 0.34.48 zca-js: - specifier: 2.1.1 - version: 2.1.1 + specifier: 2.1.2 + version: 2.1.2 zod: specifier: ^4.3.6 version: 4.3.6 @@ -6879,8 +6881,8 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zca-js@2.1.1: - resolution: {integrity: sha512-6zCmaIIWg/1eYlvCvO4rVsFt6SQ8MRodro3dCzMkk+LNgB3MyaEMBywBJfsw44WhODmOh8iMlPv4xDTNTMWDWA==} + zca-js@2.1.2: + resolution: {integrity: sha512-82+zCqoIXnXEF6C9YuN3Kf7WKlyyujY/6Ejl2n8PkwazYkBK0k7kiPd8S7nHvC5Wl7vjwGRhDYeAM8zTHyoRxQ==} engines: {node: '>=18.0.0'} zod-to-json-schema@3.25.1: @@ -14349,7 +14351,7 @@ snapshots: yoctocolors@2.1.2: {} - zca-js@2.1.1: + zca-js@2.1.2: dependencies: crypto-js: 4.2.0 form-data: 2.5.4 From ba09092a4438f7a164e836c259592fa0f71a82c2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 14:54:12 -0700 Subject: [PATCH 132/393] Plugins: guard internalized extension SDK imports --- .../channel-import-guardrails.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 7321adb1264..f5e8c0ae6e1 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -115,6 +115,17 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [ }, ]; +const LOCAL_EXTENSION_API_BARREL_GUARDS = [ + "device-pair", + "diagnostics-otel", + "diffs", + "llm-task", + "memory-lancedb", + "talk-voice", + "thread-ownership", + "voice-call", +] as const; + function readSource(path: string): string { return readFileSync(resolve(ROOT_DIR, "..", path), "utf8"); } @@ -216,6 +227,36 @@ function collectCoreSourceFiles(): string[] { return files; } +function collectExtensionFiles(extensionId: string): string[] { + const extensionDir = resolve(ROOT_DIR, "..", "extensions", extensionId); + const files: string[] = []; + const stack = [extensionDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile() || !/\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(entry.name)) { + continue; + } + if (entry.name.endsWith(".d.ts")) { + continue; + } + files.push(fullPath); + } + } + return files; +} + function collectExtensionImports(text: string): string[] { return [...text.matchAll(/["']([^"']*extensions\/[^"']+\.(?:[cm]?[jt]sx?))["']/g)].map( (match) => match[1] ?? "", @@ -299,4 +340,26 @@ describe("channel import guardrails", () => { expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); } }); + + it("keeps internalized extension helper seams behind local api barrels", () => { + for (const extensionId of LOCAL_EXTENSION_API_BARREL_GUARDS) { + for (const file of collectExtensionFiles(extensionId)) { + const normalized = file.replaceAll("\\", "/"); + if ( + normalized.endsWith("/api.ts") || + normalized.includes(".test.") || + normalized.includes(".spec.") || + normalized.includes(".fixture.") || + normalized.includes(".snap") + ) { + continue; + } + const text = readFileSync(file, "utf8"); + expect( + text, + `${normalized} should import ${extensionId} helpers via the local api barrel`, + ).not.toMatch(new RegExp(`["']openclaw/plugin-sdk/${extensionId}["']`, "u")); + } + } + }); }); From 7d5a90e5896a6c741fa68351e8a124547f4d2bde Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 14:58:22 -0700 Subject: [PATCH 133/393] Plugins: add shape compatibility matrix --- src/plugins/contracts/shape.contract.test.ts | 154 +++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/plugins/contracts/shape.contract.test.ts diff --git a/src/plugins/contracts/shape.contract.test.ts b/src/plugins/contracts/shape.contract.test.ts new file mode 100644 index 00000000000..e94961f7e01 --- /dev/null +++ b/src/plugins/contracts/shape.contract.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { createPluginRegistry, type PluginRecord } from "../registry.js"; +import type { PluginRuntime } from "../runtime/types.js"; +import { buildAllPluginInspectReports } from "../status.js"; +import type { OpenClawPluginApi } from "../types.js"; + +function createPluginRecord(id: string, name: string): PluginRecord { + return { + id, + name, + source: `/virtual/${id}/index.ts`, + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +function registerTestPlugin(params: { + registry: ReturnType; + config: OpenClawConfig; + record: PluginRecord; + register(api: OpenClawPluginApi): void; +}) { + params.registry.registry.plugins.push(params.record); + params.register( + params.registry.createApi(params.record, { + config: params.config, + }), + ); +} + +describe("plugin shape compatibility matrix", () => { + it("keeps legacy hook-only, plain capability, and hybrid capability shapes explicit", () => { + const config = {} as OpenClawConfig; + const registry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord("lca-legacy", "LCA Legacy"), + register(api) { + api.on("before_agent_start", () => ({ + prependContext: "legacy", + })); + }, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord("plain-provider", "Plain Provider"), + register(api) { + api.registerProvider({ + id: "plain-provider", + label: "Plain Provider", + auth: [], + }); + }, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord("hybrid-company", "Hybrid Company"), + register(api) { + api.registerProvider({ + id: "hybrid-company", + label: "Hybrid Company", + auth: [], + }); + api.registerWebSearchProvider({ + id: "hybrid-search", + label: "Hybrid Search", + hint: "Search the web", + envVars: ["HYBRID_SEARCH_KEY"], + placeholder: "hsk_...", + signupUrl: "https://example.com/signup", + getCredentialValue: () => "hsk-test", + setCredentialValue(searchConfigTarget, value) { + searchConfigTarget.apiKey = value; + }, + createTool: () => ({ + description: "Hybrid search", + parameters: {}, + execute: async () => ({}), + }), + }); + }, + }); + + const inspect = buildAllPluginInspectReports({ + config, + report: { + workspaceDir: "/virtual-workspace", + ...registry.registry, + }, + }); + + expect( + inspect.map((entry) => ({ + id: entry.plugin.id, + shape: entry.shape, + capabilityMode: entry.capabilityMode, + })), + ).toEqual([ + { + id: "lca-legacy", + shape: "hook-only", + capabilityMode: "none", + }, + { + id: "plain-provider", + shape: "plain-capability", + capabilityMode: "plain", + }, + { + id: "hybrid-company", + shape: "hybrid-capability", + capabilityMode: "hybrid", + }, + ]); + + expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect[1]?.capabilities.map((entry) => entry.kind)).toEqual(["text-inference"]); + expect(inspect[2]?.capabilities.map((entry) => entry.kind)).toEqual([ + "text-inference", + "web-search", + ]); + }); +}); From 45bfe3f44b8b9c700a496795093edd22f84e374a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:00:15 -0700 Subject: [PATCH 134/393] Plugins: cover channel shape in compatibility matrix --- src/plugins/contracts/shape.contract.test.ts | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/plugins/contracts/shape.contract.test.ts b/src/plugins/contracts/shape.contract.test.ts index e94961f7e01..ffc7c92360a 100644 --- a/src/plugins/contracts/shape.contract.test.ts +++ b/src/plugins/contracts/shape.contract.test.ts @@ -112,6 +112,32 @@ describe("plugin shape compatibility matrix", () => { }, }); + registerTestPlugin({ + registry, + config, + record: createPluginRecord("channel-demo", "Channel Demo"), + register(api) { + api.registerChannel({ + plugin: { + id: "channel-demo", + meta: { + id: "channel-demo", + label: "Channel Demo", + selectionLabel: "Channel Demo", + docsPath: "/channels/channel-demo", + blurb: "channel demo", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, + }); + const inspect = buildAllPluginInspectReports({ config, report: { @@ -142,6 +168,11 @@ describe("plugin shape compatibility matrix", () => { shape: "hybrid-capability", capabilityMode: "hybrid", }, + { + id: "channel-demo", + shape: "plain-capability", + capabilityMode: "plain", + }, ]); expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true); @@ -150,5 +181,6 @@ describe("plugin shape compatibility matrix", () => { "text-inference", "web-search", ]); + expect(inspect[3]?.capabilities.map((entry) => entry.kind)).toEqual(["channel"]); }); }); From 698192225442cd55b4e7939cfa3e96b474ecefea Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 14:58:14 -0700 Subject: [PATCH 135/393] docs(plugins): replace seam terminology with capability language Align with the decided convention: use capabilities, entry points, and extension surfaces instead of seams. Co-Authored-By: Claude Opus 4.6 --- docs/tools/capability-cookbook.md | 2 +- docs/tools/plugin.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tools/capability-cookbook.md b/docs/tools/capability-cookbook.md index 5cfc94ef3c0..f439c362e89 100644 --- a/docs/tools/capability-cookbook.md +++ b/docs/tools/capability-cookbook.md @@ -1,7 +1,7 @@ --- summary: "Cookbook for adding a new shared capability to OpenClaw" read_when: - - Adding a new core capability and plugin seam + - Adding a new core capability and plugin registration surface - Deciding whether code belongs in core, a vendor plugin, or a feature plugin - Wiring a new runtime helper for channels or tools title: "Capability Cookbook" diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index be14f5cfb99..e6f18d5353e 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -611,7 +611,7 @@ Provider plugins now have two layers: - runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `formatApiKey`, `refreshOAuth`, `buildAuthDoctorHint`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and -tool policy. These hooks are the seam for provider-specific behavior without +tool policy. These hooks are the extension surface for provider-specific behavior without needing a whole custom inference transport. Use manifest `providerAuthEnvVars` when the provider has env-based credentials @@ -1099,10 +1099,10 @@ authoring plugins: a one-time deprecation warning outside test environments. - Bundled extension internals remain private. External plugins should use only `openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo - public seams under `extensions//index.js`, `api.js`, `runtime-api.js`, + public entry points under `extensions//index.js`, `api.js`, `runtime-api.js`, `setup-entry.js`, and narrowly scoped files such as `login-qr-api.js`. Never import `extensions//src/*` from core or from another extension. -- Repo seam split: +- Repo entry point split: `extensions//api.js` is the helper/types barrel, `extensions//runtime-api.js` is the runtime-only barrel, `extensions//index.js` is the bundled plugin entry, @@ -1660,7 +1660,7 @@ Recommended sequence: lifecycle, channel-facing semantics, and runtime helper shape. 2. add typed plugin registration/runtime surfaces Extend `OpenClawPluginApi` and/or `api.runtime` with the smallest useful - typed seam. + typed capability surface. 3. wire core + channel/feature consumers Channels and feature plugins should consume the new capability through core, not by importing a vendor implementation directly. @@ -1850,8 +1850,8 @@ Plugins can register **model providers** so users can run OAuth or API-key setup inside OpenClaw, surface provider setup in onboarding/model-pickers, and contribute implicit provider discovery. -Provider plugins are the modular extension seam for model-provider setup. They -are not just "OAuth helpers" anymore. +Provider plugins are the modular extension surface for model-provider setup. +They are not just "OAuth helpers" anymore. ### Provider plugin lifecycle From 77f145f1dbb4678882b814f7cf3b1f9ca613e51d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 14:59:25 -0700 Subject: [PATCH 136/393] docs(types): add JSDoc to plugin API capability registration methods Label each registerX method with its capability type and add module-level doc comment to channel runtime types. Co-Authored-By: Claude Opus 4.6 --- src/plugins/runtime/types-channel.ts | 7 +++++++ src/plugins/types.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index ee50b7dd02a..a346a2f8e3a 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -1,3 +1,10 @@ +/** + * Runtime helpers for native channel plugins. + * + * This surface exposes core and channel-specific helpers used by bundled + * plugins. Prefer hooks unless you need tight in-process coupling with the + * OpenClaw messaging/runtime stack. + */ type ReadChannelAllowFromStore = typeof import("../../pairing/pairing-store.js").readChannelAllowFromStore; type UpsertChannelPairingRequest = diff --git a/src/plugins/types.ts b/src/plugins/types.ts index ae5b2d116b4..b5bd28cc110 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1266,6 +1266,12 @@ export type OpenClawPluginApi = { registrationMode: PluginRegistrationMode; config: OpenClawConfig; pluginConfig?: Record; + /** + * In-process runtime helpers for trusted native plugins. + * + * This surface is broader than hooks. Prefer hooks for third-party + * automation/integration unless you need native registry integration. + */ runtime: PluginRuntime; logger: PluginLogger; registerTool: ( @@ -1278,14 +1284,20 @@ export type OpenClawPluginApi = { opts?: OpenClawPluginHookOptions, ) => void; registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void; + /** Register a native messaging channel plugin (channel capability). */ registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void; registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; + /** Register a native model/provider plugin (text inference capability). */ registerProvider: (provider: ProviderPlugin) => void; + /** Register a speech synthesis provider (speech capability). */ registerSpeechProvider: (provider: SpeechProviderPlugin) => void; + /** Register a media understanding provider (media understanding capability). */ registerMediaUnderstandingProvider: (provider: MediaUnderstandingProviderPlugin) => void; + /** Register an image generation provider (image generation capability). */ registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void; + /** Register a web search provider (web search capability). */ registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; onConversationBindingResolved: ( From 681d16a892c79ef6c80be272ef4228eb97340fde Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:00:08 -0700 Subject: [PATCH 137/393] docs(manifest): cross-reference public capability model Co-Authored-By: Claude Opus 4.6 --- docs/plugins/manifest.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 5ef77b9ef68..0db89ec5df9 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -32,6 +32,7 @@ Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the plugin errors and block config validation. See the full plugin system guide: [Plugins](/tools/plugin). +For the public capability model: [Capability model](/tools/plugin#public-capability-model). ## Required fields @@ -54,8 +55,8 @@ Required keys: Optional keys: - `kind` (string): plugin kind (examples: `"memory"`, `"context-engine"`). -- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`). -- `providers` (array): provider ids registered by this plugin. +- `channels` (array): channel ids registered by this plugin (channel capability; example: `["matrix"]`). +- `providers` (array): provider ids registered by this plugin (text inference capability). - `providerAuthEnvVars` (object): auth env vars keyed by provider id. Use this when OpenClaw should resolve provider credentials from env without loading plugin runtime first. From f23a069d37b9bce1d547b7230a2b7ba42ea568fb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:05:59 -0700 Subject: [PATCH 138/393] Plugins: internalize synology chat SDK imports --- extensions/synology-chat/api.ts | 1 + extensions/synology-chat/src/channel.ts | 4 ++-- extensions/synology-chat/src/runtime.ts | 2 +- extensions/synology-chat/src/security.ts | 5 +---- extensions/synology-chat/src/webhook-handler.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 11 +++++++++++ 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/extensions/synology-chat/api.ts b/extensions/synology-chat/api.ts index 7c705aec6e5..1707865e258 100644 --- a/extensions/synology-chat/api.ts +++ b/extensions/synology-chat/api.ts @@ -1 +1,2 @@ +export * from "openclaw/plugin-sdk/synology-chat"; export * from "./src/setup-surface.js"; diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 0bc771a7d26..67aadff1c12 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -4,13 +4,13 @@ * Implements the ChannelPlugin interface following the LINE pattern. */ +import { z } from "zod"; import { DEFAULT_ACCOUNT_ID, setAccountEnabledInConfigSection, registerPluginHttpRoute, buildChannelConfigSchema, -} from "openclaw/plugin-sdk/synology-chat"; -import { z } from "zod"; +} from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index 68df66decc7..e1288f74468 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = createPluginRuntimeStore( diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 5b661eb6b84..8ac50016a12 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -3,10 +3,7 @@ */ import * as crypto from "node:crypto"; -import { - createFixedWindowRateLimiter, - type FixedWindowRateLimiter, -} from "openclaw/plugin-sdk/synology-chat"; +import { createFixedWindowRateLimiter, type FixedWindowRateLimiter } from "../api.js"; export type DmAuthorizationResult = | { allowed: true } diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 05cd425b06f..4f38136e9a5 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -9,7 +9,7 @@ import { isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/synology-chat"; +} from "../api.js"; import { sendMessage, resolveChatUserId } from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index f5e8c0ae6e1..a15c68c9833 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -121,6 +121,7 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "diffs", "llm-task", "memory-lancedb", + "synology-chat", "talk-voice", "thread-ownership", "voice-call", @@ -175,6 +176,7 @@ function collectExtensionSourceFiles(): string[] { } if ( fullPath.includes(".test.") || + fullPath.includes(".test-") || fullPath.includes(".fixture.") || fullPath.includes(".snap") || fullPath.includes("test-support") || @@ -251,6 +253,15 @@ function collectExtensionFiles(extensionId: string): string[] { if (entry.name.endsWith(".d.ts")) { continue; } + if ( + fullPath.includes(".test.") || + fullPath.includes(".test-") || + fullPath.includes(".spec.") || + fullPath.includes(".fixture.") || + fullPath.includes(".snap") + ) { + continue; + } files.push(fullPath); } } From dcdfed995a1f9962be9b6dba4657e971dc0eb1c0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:08:06 -0700 Subject: [PATCH 139/393] Plugins: internalize nostr SDK imports --- extensions/nostr/api.ts | 1 + extensions/nostr/src/channel.ts | 10 +++++----- extensions/nostr/src/config-schema.ts | 2 +- extensions/nostr/src/nostr-profile-http.ts | 4 ++-- extensions/nostr/src/runtime.ts | 2 +- extensions/nostr/src/types.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 1 + 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts index 7c705aec6e5..f2914e34190 100644 --- a/extensions/nostr/api.ts +++ b/extensions/nostr/api.ts @@ -1 +1,2 @@ +export * from "openclaw/plugin-sdk/nostr"; export * from "./src/setup-surface.js"; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 21dfce3a9da..4296f71b9ac 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,3 +1,7 @@ +import { + buildPassiveChannelStatusSummary, + buildTrafficStatusSummary, +} from "../../shared/channel-status-summary.js"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -6,11 +10,7 @@ import { formatPairingApproveHint, mapAllowFromEntries, type ChannelPlugin, -} from "openclaw/plugin-sdk/nostr"; -import { - buildPassiveChannelStatusSummary, - buildTrafficStatusSummary, -} from "../../shared/channel-status-summary.js"; +} from "../api.js"; import type { NostrProfile } from "./config-schema.js"; import { NostrConfigSchema } from "./config-schema.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 53346b0789d..2746d518fe6 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,6 @@ import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr"; import { z } from "zod"; +import { MarkdownConfigSchema, buildChannelConfigSchema } from "../api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index 3dedf745125..5af5feb9d84 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -8,13 +8,13 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod"; import { createFixedWindowRateLimiter, isBlockedHostnameOrIp, readJsonBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/nostr"; -import { z } from "zod"; +} from "../api.js"; import { publishNostrProfile, getNostrProfileState } from "./channel.js"; import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js"; diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts index 7c70d903712..6d99a5799a2 100644 --- a/extensions/nostr/src/runtime.ts +++ b/extensions/nostr/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/nostr"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = createPluginRuntimeStore("Nostr runtime not initialized"); diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index e2419c44ac3..78793b5e8d5 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -3,7 +3,7 @@ import { normalizeAccountId, normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-id"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; +import type { OpenClawConfig } from "../api.js"; import type { NostrProfile } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; import { getPublicKeyFromPrivate } from "./nostr-bus.js"; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index a15c68c9833..8b7f099ae8a 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -22,6 +22,7 @@ const GUARDED_CHANNEL_EXTENSIONS = new Set([ "matrix", "mattermost", "msteams", + "nostr", "nextcloud-talk", "nostr", "signal", From 90a0d50ae9476f6e1cd2db8af395a7a8e99efd0e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:10:20 -0700 Subject: [PATCH 140/393] Plugins: internalize line SDK imports --- extensions/line/api.ts | 1 + extensions/line/src/card-command.ts | 4 ++-- extensions/line/src/channel.setup.ts | 8 ++------ extensions/line/src/channel.ts | 2 +- extensions/line/src/runtime.ts | 2 +- extensions/line/src/setup-core.ts | 6 +++--- extensions/line/src/setup-surface.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 1 + 8 files changed, 12 insertions(+), 14 deletions(-) diff --git a/extensions/line/api.ts b/extensions/line/api.ts index 8f7fe4d268b..c4150b2a242 100644 --- a/extensions/line/api.ts +++ b/extensions/line/api.ts @@ -1,2 +1,3 @@ +export * from "openclaw/plugin-sdk/line"; export * from "./src/setup-core.js"; export * from "./src/setup-surface.js"; diff --git a/extensions/line/src/card-command.ts b/extensions/line/src/card-command.ts index cc5ec78eeab..f63e42576c9 100644 --- a/extensions/line/src/card-command.ts +++ b/extensions/line/src/card-command.ts @@ -1,4 +1,4 @@ -import type { LineChannelData, OpenClawPluginApi, ReplyPayload } from "openclaw/plugin-sdk/line"; +import type { LineChannelData, OpenClawPluginApi, ReplyPayload } from "../api.js"; import { createActionCard, createImageCard, @@ -7,7 +7,7 @@ import { createReceiptCard, type CardAction, type ListItem, -} from "openclaw/plugin-sdk/line"; +} from "../api.js"; const CARD_USAGE = `Usage: /card "title" "body" [options] diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts index 771107dff58..5df541d6286 100644 --- a/extensions/line/src/channel.setup.ts +++ b/extensions/line/src/channel.setup.ts @@ -4,12 +4,8 @@ import { type ChannelPlugin, type OpenClawConfig, type ResolvedLineAccount, -} from "openclaw/plugin-sdk/line"; -import { - listLineAccountIds, - resolveDefaultLineAccountId, - resolveLineAccount, -} from "openclaw/plugin-sdk/line"; +} from "../api.js"; +import { listLineAccountIds, resolveDefaultLineAccountId, resolveLineAccount } from "../api.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index ee3c9597eab..f2d4b84f8bc 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -18,7 +18,7 @@ import { type LineConfig, type LineChannelData, type ResolvedLineAccount, -} from "openclaw/plugin-sdk/line"; +} from "../api.js"; import { getLineRuntime } from "./runtime.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts index 65dd4d5394b..3541eed4ed7 100644 --- a/extensions/line/src/runtime.ts +++ b/extensions/line/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/line"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } = createPluginRuntimeStore("LINE runtime not initialized - plugin not registered"); diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 737ba1cc856..3fd00dcdbc3 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,11 +1,11 @@ +import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; import { listLineAccountIds, normalizeAccountId, resolveLineAccount, type LineConfig, -} from "openclaw/plugin-sdk/line"; -import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +} from "../api.js"; const channel = "line" as const; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index d548b096434..24afb238b6a 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,4 +1,3 @@ -import { resolveLineAccount } from "openclaw/plugin-sdk/line"; import { DEFAULT_ACCOUNT_ID, formatDocsLink, @@ -8,6 +7,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; +import { resolveLineAccount } from "../api.js"; import { isLineConfigured, listLineAccountIds, diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 8b7f099ae8a..cb32b5e8b12 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -121,6 +121,7 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "diagnostics-otel", "diffs", "llm-task", + "line", "memory-lancedb", "synology-chat", "talk-voice", From 2f65ae1b80d320ed2c2c5835f3cd12565e6264a5 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 17 Mar 2026 15:27:58 -0700 Subject: [PATCH 141/393] fix: break Synology Chat plugin-sdk reexport cycle (#49281) Build failed because src/plugin-sdk/synology-chat.ts reexported setup symbols through extensions/synology-chat/api.ts, and that API shim reexports openclaw/plugin-sdk/synology-chat back into the same entry. Export the setup symbols directly from the concrete setup surface so tsdown can bundle the SDK subpath without a self-referential export graph. --- src/plugin-sdk/synology-chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index 17b916385bc..f5fae73fbb2 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -20,4 +20,4 @@ export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { synologyChatSetupAdapter, synologyChatSetupWizard, -} from "../../extensions/synology-chat/api.js"; +} from "../../extensions/synology-chat/src/setup-surface.js"; From e7422716bbf4d5e51947cc350ae89675e81ec74e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:32:58 -0700 Subject: [PATCH 142/393] docs(plugins): rename plugins info to plugins inspect across all docs Update all references from `plugins info` to `plugins inspect` in bundles, plugin system, and CLI index docs to match the renamed command. Co-Authored-By: Claude Opus 4.6 --- docs/cli/index.md | 2 +- docs/plugins/bundles.md | 8 ++++---- docs/tools/plugin.md | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index 8700655c766..4b4197cde6f 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -283,7 +283,7 @@ Note: plugins can add additional top-level commands (for example `openclaw voice Manage extensions and their config: - `openclaw plugins list` — discover plugins (use `--json` for machine output). -- `openclaw plugins info ` — show details for a plugin. +- `openclaw plugins inspect ` — show details for a plugin (`info` is an alias). - `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). - `openclaw plugins marketplace list ` — list marketplace entries before install. - `openclaw plugins enable ` / `disable ` — toggle `plugins.entries..enabled`. diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index bc6bc49e5a0..82a5605e099 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -19,7 +19,7 @@ Today that means three closely related ecosystems: - Cursor bundles OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`. -Verbose output and `openclaw plugins info ` also show the subtype +Verbose output and `openclaw plugins inspect ` also show the subtype (`codex`, `claude`, or `cursor`). Related: @@ -141,7 +141,7 @@ diagnostics/info output, but OpenClaw does not run them yet: ## Capability reporting -`openclaw plugins info ` shows bundle capabilities from the normalized +`openclaw plugins inspect ` shows bundle capabilities from the normalized bundle record. Supported capabilities are loaded quietly. Unsupported capabilities produce a @@ -269,7 +269,7 @@ openclaw plugins install ./my-cursor-bundle openclaw plugins install ./my-bundle.tgz openclaw plugins marketplace list openclaw plugins install @ -openclaw plugins info my-bundle +openclaw plugins inspect my-bundle ``` If the directory is a native OpenClaw plugin/package, the native install path @@ -284,7 +284,7 @@ sources; after resolution, the normal install rules still apply. ### Bundle is detected but capabilities do not run -Check `openclaw plugins info `. +Check `openclaw plugins inspect `. If the capability is listed but OpenClaw says it is not wired yet, that is a real product limit, not a broken install. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index e6f18d5353e..27979dcb125 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -110,14 +110,14 @@ conversation, and it runs after core approval handling finishes. Capabilities are the public plugin model. Every native OpenClaw plugin registers against one or more capability types: -| Capability | Registration method | Example plugins | -|---|---|---| -| Text inference | `api.registerProvider(...)` | `openai`, `anthropic` | -| Speech | `api.registerSpeechProvider(...)` | `elevenlabs`, `microsoft` | -| Media understanding | `api.registerMediaUnderstandingProvider(...)` | `openai`, `google` | -| Image generation | `api.registerImageGenerationProvider(...)` | `openai`, `google` | -| Web search | `api.registerWebSearchProvider(...)` | `google` | -| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` | +| Capability | Registration method | Example plugins | +| ------------------- | --------------------------------------------- | ------------------------- | +| Text inference | `api.registerProvider(...)` | `openai`, `anthropic` | +| Speech | `api.registerSpeechProvider(...)` | `elevenlabs`, `microsoft` | +| Media understanding | `api.registerMediaUnderstandingProvider(...)` | `openai`, `google` | +| Image generation | `api.registerImageGenerationProvider(...)` | `openai`, `google` | +| Web search | `api.registerWebSearchProvider(...)` | `google` | +| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` | A plugin that registers zero capabilities but provides hooks, tools, or services is a **legacy hook-only** plugin. That shape is still fully supported. @@ -1590,7 +1590,7 @@ Example: ```bash openclaw plugins list -openclaw plugins info +openclaw plugins inspect openclaw plugins install # copy a local file/dir into ~/.openclaw/extensions/ openclaw plugins install ./extensions/voice-call # relative path ok openclaw plugins install ./plugin.tgz # install from a local tarball From af63b729010d3e2b2d7c1ef5a6ee3b4b6211e0a0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:55:34 -0700 Subject: [PATCH 143/393] Plugins: internalize nextcloud talk SDK imports --- extensions/nextcloud-talk/runtime-api.ts | 1 + extensions/nextcloud-talk/src/accounts.ts | 2 +- extensions/nextcloud-talk/src/channel.ts | 4 ++-- extensions/nextcloud-talk/src/config-schema.ts | 6 +++--- extensions/nextcloud-talk/src/inbound.ts | 2 +- extensions/nextcloud-talk/src/monitor.ts | 4 ++-- extensions/nextcloud-talk/src/policy.ts | 4 ++-- extensions/nextcloud-talk/src/replay-guard.ts | 2 +- extensions/nextcloud-talk/src/room-info.ts | 3 +-- extensions/nextcloud-talk/src/runtime.ts | 2 +- extensions/nextcloud-talk/src/secret-input.ts | 2 +- extensions/nextcloud-talk/src/types.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 4 +++- 13 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 extensions/nextcloud-talk/runtime-api.ts diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts new file mode 100644 index 00000000000..fc9283930bd --- /dev/null +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/nextcloud-talk"; diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index 1b9d2c16f93..d6a2a4edcaa 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -4,7 +4,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveAccountWithDefaultFallback, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js"; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 6101136a5e3..16910b7371e 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -6,6 +6,7 @@ import { collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { buildBaseChannelStatusSummary, buildChannelConfigSchema, @@ -16,8 +17,7 @@ import { setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; +} from "../runtime-api.js"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 85cb14ff213..020a69d7992 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { BlockStreamingCoalesceSchema, DmConfigSchema, @@ -7,9 +9,7 @@ import { ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { z } from "zod"; -import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; +} from "../runtime-api.js"; import { buildSecretInputSchema } from "./secret-input.js"; export const NextcloudTalkRoomSchema = z diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 10ecd924fd7..9eefe831835 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -14,7 +14,7 @@ import { type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { normalizeNextcloudTalkAllowlist, diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index d66a40d7429..8721ff5fe6b 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,12 +1,12 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import os from "node:os"; +import { resolveLoggerBackedRuntime } from "../../shared/runtime.js"; import { type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { resolveLoggerBackedRuntime } from "../../shared/runtime.js"; +} from "../runtime-api.js"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { handleNextcloudTalkInbound } from "./inbound.js"; import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts index 15e19da84de..849efac51e6 100644 --- a/extensions/nextcloud-talk/src/policy.ts +++ b/extensions/nextcloud-talk/src/policy.ts @@ -3,7 +3,7 @@ import type { ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import { buildChannelKeyCandidates, evaluateMatchedGroupAccessForPolicy, @@ -11,7 +11,7 @@ import { resolveChannelEntryMatchWithFallback, resolveMentionGatingWithBypass, resolveNestedAllowlistDecision, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import type { NextcloudTalkRoomConfig } from "./types.js"; function normalizeAllowEntry(raw: string): string { diff --git a/extensions/nextcloud-talk/src/replay-guard.ts b/extensions/nextcloud-talk/src/replay-guard.ts index 8dc8477e13f..ed4d1c7b79b 100644 --- a/extensions/nextcloud-talk/src/replay-guard.ts +++ b/extensions/nextcloud-talk/src/replay-guard.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { createPersistentDedupe } from "openclaw/plugin-sdk/nextcloud-talk"; +import { createPersistentDedupe } from "../runtime-api.js"; const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_MEMORY_MAX_SIZE = 1_000; diff --git a/extensions/nextcloud-talk/src/room-info.ts b/extensions/nextcloud-talk/src/room-info.ts index eae5a1eeb51..eb1072e8baa 100644 --- a/extensions/nextcloud-talk/src/room-info.ts +++ b/extensions/nextcloud-talk/src/room-info.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/nextcloud-talk"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/nextcloud-talk"; +import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts index facf3a0cc05..c8251669314 100644 --- a/extensions/nextcloud-talk/src/runtime.ts +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../runtime-api.js"; const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } = createPluginRuntimeStore("Nextcloud Talk runtime not initialized"); diff --git a/extensions/nextcloud-talk/src/secret-input.ts b/extensions/nextcloud-talk/src/secret-input.ts index d26cb8e4e23..ad5746ffc31 100644 --- a/extensions/nextcloud-talk/src/secret-input.ts +++ b/extensions/nextcloud-talk/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index a9cfbef7d06..a7f2dc38ab0 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -4,7 +4,7 @@ import type { DmPolicy, GroupPolicy, SecretInput, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; export type { DmPolicy, GroupPolicy }; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index cb32b5e8b12..0d49e580d11 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -123,6 +123,7 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "llm-task", "line", "memory-lancedb", + "nextcloud-talk", "synology-chat", "talk-voice", "thread-ownership", @@ -260,7 +261,8 @@ function collectExtensionFiles(extensionId: string): string[] { fullPath.includes(".test-") || fullPath.includes(".spec.") || fullPath.includes(".fixture.") || - fullPath.includes(".snap") + fullPath.includes(".snap") || + fullPath.endsWith("/runtime-api.ts") ) { continue; } From bd21442f7e606e1baf816ccf2e8fa8a4dc9bf9f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 15:56:21 -0700 Subject: [PATCH 144/393] Perf: add extension memory profiling command --- package.json | 1 + scripts/profile-extension-memory.mjs | 359 +++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 scripts/profile-extension-memory.mjs diff --git a/package.json b/package.json index 5dc22fb6bea..32f107da7cc 100644 --- a/package.json +++ b/package.json @@ -583,6 +583,7 @@ "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts", "test:extension": "node scripts/test-extension.mjs", "test:extensions": "vitest run --config vitest.extensions.config.ts", + "test:extensions:memory": "node scripts/profile-extension-memory.mjs", "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", "test:gateway": "vitest run --config vitest.gateway.config.ts --pool=forks", diff --git a/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs new file mode 100644 index 00000000000..0145ed832a4 --- /dev/null +++ b/scripts/profile-extension-memory.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_CONCURRENCY = 6; +const DEFAULT_TIMEOUT_MS = 90_000; +const DEFAULT_COMBINED_TIMEOUT_MS = 180_000; +const DEFAULT_TOP = 10; +const RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; + +function printHelp() { + console.log(`Usage: node scripts/profile-extension-memory.mjs [options] + +Profiles peak RSS for built extension entrypoints in dist/extensions/*/index.js. +Run pnpm build first if you want stats for the latest source changes. + +Options: + --extension, -e Limit profiling to one or more extension ids (repeatable) + --concurrency Number of per-extension workers (default: ${DEFAULT_CONCURRENCY}) + --timeout-ms Per-extension timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}) + --combined-timeout-ms + Combined-import timeout in milliseconds (default: ${DEFAULT_COMBINED_TIMEOUT_MS}) + --top Show top N entries by delta from baseline (default: ${DEFAULT_TOP}) + --json Write full JSON report to this path + --skip-combined Skip the combined all-imports measurement + --help Show this help + +Examples: + pnpm test:extensions:memory + pnpm test:extensions:memory -- --extension discord + pnpm test:extensions:memory -- --extension discord --extension telegram --skip-combined +`); +} + +function parsePositiveInt(raw, flagName) { + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${flagName} must be a positive integer`); + } + return parsed; +} + +function parseArgs(argv) { + const options = { + extensions: [], + concurrency: DEFAULT_CONCURRENCY, + timeoutMs: DEFAULT_TIMEOUT_MS, + combinedTimeoutMs: DEFAULT_COMBINED_TIMEOUT_MS, + top: DEFAULT_TOP, + jsonPath: null, + skipCombined: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--": + break; + case "--extension": + case "-e": { + const next = argv[index + 1]; + if (!next) { + throw new Error(`${arg} requires a value`); + } + options.extensions.push(next); + index += 1; + break; + } + case "--concurrency": + options.concurrency = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--timeout-ms": + options.timeoutMs = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--combined-timeout-ms": + options.combinedTimeoutMs = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--top": + options.top = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--json": { + const next = argv[index + 1]; + if (!next) { + throw new Error(`${arg} requires a value`); + } + options.jsonPath = path.resolve(next); + index += 1; + break; + } + case "--skip-combined": + options.skipCombined = true; + break; + case "--help": + case "-h": + printHelp(); + process.exit(0); + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +function parseMaxRssMb(stderr) { + const matches = [...stderr.matchAll(new RegExp(`^${RSS_MARKER}(\\d+)\\s*$`, "gm"))]; + const last = matches.at(-1); + return last ? Number(last[1]) / 1024 : null; +} + +function summarizeStderr(stderr, lines = 8) { + return stderr.trim().split("\n").filter(Boolean).slice(0, lines).join("\n"); +} + +async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { + return await new Promise((resolve) => { + const child = spawn( + process.execPath, + ["--import", hookPath, "--input-type=module", "--eval", body], + { + cwd: repoRoot, + env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + resolve({ + name, + code, + signal, + timedOut, + stdout, + stderr, + maxRssMb: parseMaxRssMb(stderr), + }); + }); + }); +} + +function buildImportBody(entryFiles, label) { + const imports = entryFiles + .map((filePath) => `await import(${JSON.stringify(filePath)});`) + .join("\n"); + return `${imports}\nconsole.log(${JSON.stringify(label)});\nprocess.exit(0);\n`; +} + +function findExtensionEntries(repoRoot) { + const extensionsDir = path.join(repoRoot, "dist", "extensions"); + if (!existsSync(extensionsDir)) { + throw new Error("dist/extensions not found. Run pnpm build first."); + } + + const entries = readdirSync(extensionsDir) + .map((dir) => ({ dir, file: path.join(extensionsDir, dir, "index.js") })) + .filter((entry) => existsSync(entry.file)) + .toSorted((a, b) => a.dir.localeCompare(b.dir)); + + if (entries.length === 0) { + throw new Error("No built extension entrypoints found under dist/extensions/*/index.js"); + } + return entries; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const repoRoot = process.cwd(); + const allEntries = findExtensionEntries(repoRoot); + const selectedEntries = + options.extensions.length === 0 + ? allEntries + : allEntries.filter((entry) => options.extensions.includes(entry.dir)); + + const missing = options.extensions.filter((id) => !allEntries.some((entry) => entry.dir === id)); + if (missing.length > 0) { + throw new Error(`Unknown built extension ids: ${missing.join(", ")}`); + } + if (selectedEntries.length === 0) { + throw new Error("No extensions selected for profiling"); + } + + const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-extension-memory-")); + const hookPath = path.join(tmpHome, "measure-rss.mjs"); + const jsonPath = options.jsonPath ?? path.join(os.tmpdir(), "openclaw-extension-memory.json"); + + writeFileSync( + hookPath, + [ + "process.on('exit', () => {", + " const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;", + ` if (usage && typeof usage.maxRSS === 'number') console.error('${RSS_MARKER}' + String(usage.maxRSS));`, + "});", + "", + ].join("\n"), + "utf8", + ); + + const env = { + ...process.env, + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, ".config"), + XDG_DATA_HOME: path.join(tmpHome, ".local", "share"), + XDG_CACHE_HOME: path.join(tmpHome, ".cache"), + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_NO_RESPAWN: "1", + TERM: process.env.TERM ?? "dumb", + LANG: process.env.LANG ?? "C.UTF-8", + }; + + try { + const baseline = await runCase({ + repoRoot, + env, + hookPath, + name: "baseline", + body: "process.exit(0)", + timeoutMs: options.timeoutMs, + }); + + const combined = options.skipCombined + ? null + : await runCase({ + repoRoot, + env, + hookPath, + name: "combined", + body: buildImportBody( + selectedEntries.map((entry) => entry.file), + "IMPORTED_ALL", + ), + timeoutMs: options.combinedTimeoutMs, + }); + + const pending = [...selectedEntries]; + const results = []; + + async function worker() { + while (pending.length > 0) { + const next = pending.shift(); + if (next === undefined) { + return; + } + const result = await runCase({ + repoRoot, + env, + hookPath, + name: next.dir, + body: buildImportBody([next.file], "IMPORTED"), + timeoutMs: options.timeoutMs, + }); + results.push({ + dir: next.dir, + file: next.file, + status: result.timedOut ? "timeout" : result.code === 0 ? "ok" : "fail", + maxRssMb: result.maxRssMb, + deltaFromBaselineMb: + result.maxRssMb !== null && baseline.maxRssMb !== null + ? result.maxRssMb - baseline.maxRssMb + : null, + stderrPreview: summarizeStderr(result.stderr), + }); + + const status = result.timedOut ? "timeout" : result.code === 0 ? "ok" : "fail"; + const rss = result.maxRssMb === null ? "n/a" : `${result.maxRssMb.toFixed(1)} MB`; + console.log(`[extension-memory] ${next.dir}: ${status} ${rss}`); + } + } + + await Promise.all( + Array.from({ length: Math.min(options.concurrency, selectedEntries.length) }, () => worker()), + ); + + results.sort((a, b) => a.dir.localeCompare(b.dir)); + const top = results + .filter((entry) => entry.status === "ok" && typeof entry.deltaFromBaselineMb === "number") + .toSorted((a, b) => (b.deltaFromBaselineMb ?? 0) - (a.deltaFromBaselineMb ?? 0)) + .slice(0, options.top); + + const report = { + generatedAt: new Date().toISOString(), + repoRoot, + selectedExtensions: selectedEntries.map((entry) => entry.dir), + baseline: { + status: baseline.timedOut ? "timeout" : baseline.code === 0 ? "ok" : "fail", + maxRssMb: baseline.maxRssMb, + }, + combined: + combined === null + ? null + : { + status: combined.timedOut ? "timeout" : combined.code === 0 ? "ok" : "fail", + maxRssMb: combined.maxRssMb, + stderrPreview: summarizeStderr(combined.stderr, 12), + }, + counts: { + totalEntries: selectedEntries.length, + ok: results.filter((entry) => entry.status === "ok").length, + fail: results.filter((entry) => entry.status === "fail").length, + timeout: results.filter((entry) => entry.status === "timeout").length, + }, + options: { + concurrency: options.concurrency, + timeoutMs: options.timeoutMs, + combinedTimeoutMs: options.combinedTimeoutMs, + skipCombined: options.skipCombined, + }, + topByDeltaMb: top, + results, + }; + + writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + + console.log(`[extension-memory] report: ${jsonPath}`); + console.log( + JSON.stringify( + { + baselineMb: report.baseline.maxRssMb, + combinedMb: report.combined?.maxRssMb ?? null, + counts: report.counts, + topByDeltaMb: report.topByDeltaMb, + }, + null, + 2, + ), + ); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + } +} + +try { + await main(); +} catch (error) { + console.error(`[extension-memory] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} From e99963100d22c870633ad890d766a98ab611a36f Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:15:49 -0500 Subject: [PATCH 145/393] CLI: expand config set with SecretRef/provider builders and dry-run (#49296) * CLI: expand config set ref/provider builder and dry-run * Docs: revert README Discord token example --- README.md | 2 +- docs/channels/discord.md | 14 +- docs/cli/config.md | 214 ++++++- docs/cli/index.md | 11 +- src/cli/config-cli.integration.test.ts | 186 ++++++ src/cli/config-cli.test.ts | 505 ++++++++++++++++ src/cli/config-cli.ts | 782 ++++++++++++++++++++++++- src/cli/config-set-dryrun.ts | 20 + src/cli/config-set-input.test.ts | 113 ++++ src/cli/config-set-input.ts | 130 ++++ src/cli/config-set-mode.test.ts | 80 +++ src/cli/config-set-parser.ts | 43 ++ src/secrets/target-registry-query.ts | 18 + src/secrets/target-registry.test.ts | 16 +- 14 files changed, 2102 insertions(+), 32 deletions(-) create mode 100644 src/cli/config-cli.integration.test.ts create mode 100644 src/cli/config-set-dryrun.ts create mode 100644 src/cli/config-set-input.test.ts create mode 100644 src/cli/config-set-input.ts create mode 100644 src/cli/config-set-mode.test.ts create mode 100644 src/cli/config-set-parser.ts diff --git a/README.md b/README.md index 418e2a070af..1c836da84ee 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker ### [Discord](https://docs.openclaw.ai/channels/discord) -- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins). +- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`. - Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed. ```json5 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 2b2266c4c83..0f7b6ac7074 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -96,8 +96,10 @@ You will need to create a new application with a bot, add the bot to your server Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent. ```bash -openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json -openclaw config set channels.discord.enabled true --json +export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN" +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN +openclaw config set channels.discord.enabled true --strict-json openclaw gateway ``` @@ -121,7 +123,11 @@ openclaw gateway channels: { discord: { enabled: true, - token: "YOUR_BOT_TOKEN", + token: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, }, }, } @@ -133,7 +139,7 @@ openclaw gateway DISCORD_BOT_TOKEN=... ``` - SecretRef values are also supported for `channels.discord.token` (env/file/exec providers). See [Secrets Management](/gateway/secrets). + Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets). diff --git a/docs/cli/config.md b/docs/cli/config.md index fa0d62e8511..ba4e6adf60f 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -7,9 +7,9 @@ title: "config" # `openclaw config` -Config helpers: get/set/unset/validate values by path and print the active -config file. Run without a subcommand to open -the configure wizard (same as `openclaw configure`). +Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/validate +values by path and print the active config file. Run without a subcommand to +open the configure wizard (same as `openclaw configure`). ## Examples @@ -19,7 +19,10 @@ openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set agents.defaults.heartbeat.every "2h" openclaw config set agents.list[0].tools.exec.node "node-id-or-name" +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN +openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json openclaw config unset tools.web.search.apiKey +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run openclaw config validate openclaw config validate --json ``` @@ -51,6 +54,211 @@ openclaw config set gateway.port 19001 --strict-json openclaw config set channels.whatsapp.groups '["*"]' --strict-json ``` +## `config set` modes + +`openclaw config set` supports four assignment styles: + +1. Value mode: `openclaw config set ` +2. SecretRef builder mode: + +```bash +openclaw config set channels.discord.token \ + --ref-provider default \ + --ref-source env \ + --ref-id DISCORD_BOT_TOKEN +``` + +3. Provider builder mode (`secrets.providers.` path only): + +```bash +openclaw config set secrets.providers.vault \ + --provider-source exec \ + --provider-command /usr/local/bin/openclaw-vault \ + --provider-arg read \ + --provider-arg openai/api-key \ + --provider-timeout-ms 5000 +``` + +4. Batch mode (`--batch-json` or `--batch-file`): + +```bash +openclaw config set --batch-json '[ + { + "path": "secrets.providers.default", + "provider": { "source": "env" } + }, + { + "path": "channels.discord.token", + "ref": { "source": "env", "provider": "default", "id": "DISCORD_BOT_TOKEN" } + } +]' +``` + +```bash +openclaw config set --batch-file ./config-set.batch.json --dry-run +``` + +Batch parsing always uses the batch payload (`--batch-json`/`--batch-file`) as the source of truth. +`--strict-json` / `--json` do not change batch parsing behavior. + +JSON path/value mode remains supported for both SecretRefs and providers: + +```bash +openclaw config set channels.discord.token \ + '{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}' \ + --strict-json + +openclaw config set secrets.providers.vaultfile \ + '{"source":"file","path":"/etc/openclaw/secrets.json","mode":"json"}' \ + --strict-json +``` + +## Provider Builder Flags + +Provider builder targets must use `secrets.providers.` as the path. + +Common flags: + +- `--provider-source ` +- `--provider-timeout-ms ` (`file`, `exec`) + +Env provider (`--provider-source env`): + +- `--provider-allowlist ` (repeatable) + +File provider (`--provider-source file`): + +- `--provider-path ` (required) +- `--provider-mode ` +- `--provider-max-bytes ` + +Exec provider (`--provider-source exec`): + +- `--provider-command ` (required) +- `--provider-arg ` (repeatable) +- `--provider-no-output-timeout-ms ` +- `--provider-max-output-bytes ` +- `--provider-json-only` +- `--provider-env ` (repeatable) +- `--provider-pass-env ` (repeatable) +- `--provider-trusted-dir ` (repeatable) +- `--provider-allow-insecure-path` +- `--provider-allow-symlink-command` + +Hardened exec provider example: + +```bash +openclaw config set secrets.providers.vault \ + --provider-source exec \ + --provider-command /usr/local/bin/openclaw-vault \ + --provider-arg read \ + --provider-arg openai/api-key \ + --provider-json-only \ + --provider-pass-env VAULT_TOKEN \ + --provider-trusted-dir /usr/local/bin \ + --provider-timeout-ms 5000 +``` + +## Dry run + +Use `--dry-run` to validate changes without writing `openclaw.json`. + +```bash +openclaw config set channels.discord.token \ + --ref-provider default \ + --ref-source env \ + --ref-id DISCORD_BOT_TOKEN \ + --dry-run + +openclaw config set channels.discord.token \ + --ref-provider default \ + --ref-source env \ + --ref-id DISCORD_BOT_TOKEN \ + --dry-run \ + --json +``` + +Dry-run behavior: + +- Builder mode: requires full SecretRef resolvability for changed refs/providers. +- JSON mode (`--strict-json`, `--json`, or batch mode): requires full resolvability and schema validation. + +`--dry-run --json` prints a machine-readable report: + +- `ok`: whether dry-run passed +- `operations`: number of assignments evaluated +- `checks`: whether schema/resolvability checks ran +- `refsChecked`: number of refs resolved during dry-run +- `errors`: structured schema/resolvability failures when `ok=false` + +### JSON Output Shape + +```json5 +{ + ok: boolean, + operations: number, + configPath: string, + inputModes: ["value" | "json" | "builder", ...], + checks: { + schema: boolean, + resolvability: boolean, + }, + refsChecked: number, + errors?: [ + { + kind: "schema" | "resolvability", + message: string, + ref?: string, // present for resolvability errors + }, + ], +} +``` + +Success example: + +```json +{ + "ok": true, + "operations": 1, + "configPath": "~/.openclaw/openclaw.json", + "inputModes": ["builder"], + "checks": { + "schema": false, + "resolvability": true + }, + "refsChecked": 1 +} +``` + +Failure example: + +```json +{ + "ok": false, + "operations": 1, + "configPath": "~/.openclaw/openclaw.json", + "inputModes": ["builder"], + "checks": { + "schema": false, + "resolvability": true + }, + "refsChecked": 1, + "errors": [ + { + "kind": "resolvability", + "message": "Error: Environment variable \"MISSING_TEST_SECRET\" is not set.", + "ref": "env:default:MISSING_TEST_SECRET" + } + ] +} +``` + +If dry-run fails: + +- `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape. +- `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch). +- For batch mode, fix failing entries and rerun `--dry-run` before writing. + ## Subcommands - `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location). diff --git a/docs/cli/index.md b/docs/cli/index.md index 4b4197cde6f..5acbb4b3166 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -101,6 +101,8 @@ openclaw [--dev] [--profile ] get set unset + file + validate completion doctor dashboard @@ -393,7 +395,14 @@ subcommand launches the wizard. Subcommands: - `config get `: print a config value (dot/bracket path). -- `config set `: set a value (JSON5 or raw string). +- `config set`: supports four assignment modes: + - value mode: `config set ` (JSON5-or-string parsing) + - SecretRef builder mode: `config set --ref-provider --ref-source --ref-id ` + - provider builder mode: `config set secrets.providers. --provider-source ...` + - batch mode: `config set --batch-json ''` or `config set --batch-file ` +- `config set --dry-run`: validate assignments without writing `openclaw.json`. +- `config set --dry-run --json`: emit machine-readable dry-run output (checks, operations, errors). +- `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode. - `config unset `: remove a value. - `config file`: print the active config file path. - `config validate`: validate the current config against the schema without starting the gateway. diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts new file mode 100644 index 00000000000..1224d56c220 --- /dev/null +++ b/src/cli/config-cli.integration.test.ts @@ -0,0 +1,186 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import JSON5 from "json5"; +import { describe, expect, it } from "vitest"; +import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; +import { captureEnv } from "../test-utils/env.js"; +import { runConfigSet } from "./config-cli.js"; + +function createTestRuntime() { + const logs: string[] = []; + const errors: string[] = []; + return { + logs, + errors, + runtime: { + log: (...args: unknown[]) => logs.push(args.map((arg) => String(arg)).join(" ")), + error: (...args: unknown[]) => errors.push(args.map((arg) => String(arg)).join(" ")), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }, + }; +} + +describe("config cli integration", () => { + it("supports batch-file dry-run and then writes real config changes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const envSnapshot = captureEnv([ + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_TEST_FAST", + "DISCORD_BOT_TOKEN", + ]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify( + [ + { + path: "secrets.providers.default", + provider: { source: "env" }, + }, + { + path: "channels.discord.token", + ref: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + }, + ], + null, + 2, + )}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + process.env.DISCORD_BOT_TOKEN = "test-token"; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + }, + runtime: runtime.runtime, + }); + const afterDryRun = fs.readFileSync(configPath, "utf8"); + expect(afterDryRun).toBe(before); + expect(runtime.errors).toEqual([]); + expect(runtime.logs.some((line) => line.includes("Dry run successful: 2 update(s)"))).toBe( + true, + ); + + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + }, + runtime: runtime.runtime, + }); + const afterWrite = JSON5.parse(fs.readFileSync(configPath, "utf8")); + expect(afterWrite.secrets?.providers?.default).toEqual({ + source: "env", + }); + expect(afterWrite.channels?.discord?.token).toEqual({ + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps file unchanged when real-file dry-run fails and reports JSON error payload", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-fail-")); + const configPath = path.join(tempDir, "openclaw.json"); + const envSnapshot = captureEnv([ + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_TEST_FAST", + "MISSING_TEST_SECRET", + ]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + delete process.env.MISSING_TEST_SECRET; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await expect( + runConfigSet({ + path: "channels.discord.token", + cliOptions: { + refProvider: "default", + refSource: "env", + refId: "MISSING_TEST_SECRET", + dryRun: true, + json: true, + }, + runtime: runtime.runtime, + }), + ).rejects.toThrow("__exit__:1"); + const after = fs.readFileSync(configPath, "utf8"); + expect(after).toBe(before); + expect(runtime.errors).toEqual([]); + const raw = runtime.logs.at(-1); + expect(raw).toBeTruthy(); + const payload = JSON.parse(raw ?? "{}") as { + ok?: boolean; + checks?: { schema?: boolean; resolvability?: boolean }; + errors?: Array<{ kind?: string; ref?: string }>; + }; + expect(payload.ok).toBe(false); + expect(payload.checks?.resolvability).toBe(true); + expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); + expect(payload.errors?.some((entry) => entry.ref?.includes("MISSING_TEST_SECRET"))).toBe( + true, + ); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 8ee785df189..69ba866534e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; @@ -12,6 +15,7 @@ const mockReadConfigFileSnapshot = vi.fn<() => Promise>(); const mockWriteConfigFile = vi.fn< (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise >(async () => {}); +const mockResolveSecretRefValue = vi.fn(); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: () => mockReadConfigFileSnapshot(), @@ -19,6 +23,10 @@ vi.mock("../config/config.js", () => ({ mockWriteConfigFile(cfg, options), })); +vi.mock("../secrets/resolve.js", () => ({ + resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args), +})); + const mockLog = vi.fn(); const mockError = vi.fn(); const mockExit = vi.fn((code: number) => { @@ -123,6 +131,7 @@ describe("config cli", () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveSecretRefValue.mockResolvedValue("resolved-secret"); }); describe("config set - issue #6070", () => { @@ -345,6 +354,23 @@ describe("config cli", () => { expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); }); + it("accepts --strict-json with batch mode and applies batch payload", async () => { + const resolved: OpenClawConfig = { gateway: { port: 18789 } }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"gateway.auth.mode","value":"token"}]', + "--strict-json", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.gateway?.auth).toEqual({ mode: "token" }); + }); + it("shows --strict-json and keeps --json as a legacy alias in help", async () => { const program = new Command(); registerConfigCli(program); @@ -356,6 +382,485 @@ describe("config cli", () => { expect(helpText).toContain("--strict-json"); expect(helpText).toContain("--json"); expect(helpText).toContain("Legacy alias for --strict-json"); + expect(helpText).toContain("--ref-provider"); + expect(helpText).toContain("--provider-source"); + expect(helpText).toContain("--batch-json"); + expect(helpText).toContain("--dry-run"); + expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json"); + expect(helpText).toContain( + "openclaw config set channels.discord.token --ref-provider default --ref-source", + ); + expect(helpText).toContain("--ref-id DISCORD_BOT_TOKEN"); + expect(helpText).toContain( + "openclaw config set --batch-file ./config-set.batch.json --dry-run", + ); + }); + }); + + describe("config set builders and dry-run", () => { + it("supports SecretRef builder mode without requiring a value argument", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.channels?.discord?.token).toEqual({ + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }); + }); + + it("supports provider builder mode under secrets.providers.", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "secrets.providers.vaultfile", + "--provider-source", + "file", + "--provider-path", + "/tmp/vault.json", + "--provider-mode", + "json", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.secrets?.providers?.vaultfile).toEqual({ + source: "file", + path: "/tmp/vault.json", + mode: "json", + }); + }); + + it("runs resolvability checks in builder dry-run mode without writing", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + expect.objectContaining({ + env: expect.any(Object), + }), + ); + }); + + it("requires schema validation in JSON dry-run mode", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "gateway.port", + '"not-a-number"', + "--strict-json", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: config schema validation failed."), + ); + }); + + it("supports batch mode for refs/providers in dry-run", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]', + "--dry-run", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + }); + + it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + channels: { + googlechat: { + enabled: true, + } as never, + } as never, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.googlechat.serviceAccount", + "--ref-provider", + "vaultfile", + "--ref-source", + "file", + "--ref-id", + "/providers/googlechat/serviceAccount", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.channels?.googlechat?.serviceAccountRef).toEqual({ + source: "file", + provider: "vaultfile", + id: "/providers/googlechat/serviceAccount", + }); + expect(written.channels?.googlechat?.serviceAccount).toBeUndefined(); + }); + + it("rejects mixing ref-builder and provider-builder flags", async () => { + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--provider-source", + "env", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("config set mode error: choose exactly one mode"), + ); + }); + + it("rejects mixing batch mode with builder flags", async () => { + await expect( + runConfigCommand([ + "config", + "set", + "--batch-json", + "[]", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + "config set mode error: batch mode (--batch-json/--batch-file) cannot be combined", + ), + ); + }); + + it("supports batch-file mode", async () => { + const resolved: OpenClawConfig = { gateway: { port: 18789 } }; + setSnapshot(resolved, resolved); + + const pathname = path.join( + os.tmpdir(), + `openclaw-config-batch-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync(pathname, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8"); + try { + await runConfigCommand(["config", "set", "--batch-file", pathname]); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.gateway?.auth).toEqual({ mode: "token" }); + }); + + it("rejects malformed batch-file payloads", async () => { + const pathname = path.join( + os.tmpdir(), + `openclaw-config-batch-invalid-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync(pathname, '{"path":"gateway.auth.mode","value":"token"}', "utf8"); + try { + await expect(runConfigCommand(["config", "set", "--batch-file", pathname])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("--batch-file must be a JSON array."), + ); + }); + + it("rejects malformed batch entries with mixed operation keys", async () => { + await expect( + runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"channels.discord.token","value":"x","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]', + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("must include exactly one of: value, ref, provider"), + ); + }); + + it("fails dry-run when a builder-assigned SecretRef is unresolved", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var")); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."), + ); + }); + + it("emits structured JSON for --dry-run --json success", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + "--json", + ]); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + checks: { schema: boolean; resolvability: boolean }; + refsChecked: number; + operations: number; + }; + expect(payload.ok).toBe(true); + expect(payload.operations).toBe(1); + expect(payload.refsChecked).toBe(1); + expect(payload.checks).toEqual({ + schema: false, + resolvability: true, + }); + }); + + it("emits structured JSON for --dry-run --json failure", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var")); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + "--json", + ]), + ).rejects.toThrow("__exit__:1"); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + errors?: Array<{ kind: string; message: string; ref?: string }>; + }; + expect(payload.ok).toBe(false); + expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); + expect( + payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), + ).toBe(true); + }); + + it("aggregates schema and resolvability failures in --dry-run --json mode", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var")); + + await expect( + runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"gateway.port","value":"not-a-number"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]', + "--dry-run", + "--json", + ]), + ).rejects.toThrow("__exit__:1"); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + errors?: Array<{ kind: string; message: string; ref?: string }>; + }; + expect(payload.ok).toBe(false); + expect(payload.errors?.some((entry) => entry.kind === "schema")).toBe(true); + expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); + expect( + payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), + ).toBe(true); + }); + + it("fails dry-run when provider updates make existing refs unresolvable", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + tools: { + web: { + search: { + enabled: true, + apiKey: { + source: "file", + provider: "vaultfile", + id: "/providers/search/apiKey", + }, + }, + }, + } as never, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockImplementationOnce(async () => { + throw new Error("provider mismatch"); + }); + + await expect( + runConfigCommand([ + "config", + "set", + "secrets.providers.vaultfile", + "--provider-source", + "env", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."), + ); + expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch")); }); }); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 5167658040a..f7efaf1c865 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,26 +1,100 @@ import type { Command } from "commander"; import JSON5 from "json5"; import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; +import type { OpenClawConfig } from "../config/config.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; +import { + coerceSecretRef, + isValidEnvSecretRefId, + resolveSecretInputRef, + type SecretProviderConfig, + type SecretRef, + type SecretRefSource, +} from "../config/types.secrets.js"; +import { validateConfigObjectRaw } from "../config/validation.js"; +import { SecretProviderSchema } from "../config/zod-schema.core.js"; import { danger, info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidFileSecretRefId, + isValidSecretProviderAlias, + secretRefKey, + validateExecSecretRefId, +} from "../secrets/ref-contract.js"; +import { resolveSecretRefValue } from "../secrets/resolve.js"; +import { + discoverConfigSecretTargets, + resolveConfigSecretTargetByPath, +} from "../secrets/target-registry.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import type { + ConfigSetDryRunError, + ConfigSetDryRunInputMode, + ConfigSetDryRunResult, +} from "./config-set-dryrun.js"; +import { + hasProviderBuilderOptions, + hasRefBuilderOptions, + parseBatchSource, + type ConfigSetBatchEntry, + type ConfigSetOptions, +} from "./config-set-input.js"; +import { resolveConfigSetMode } from "./config-set-parser.js"; type PathSegment = string; type ConfigSetParseOpts = { strictJson?: boolean; }; +type ConfigSetInputMode = ConfigSetDryRunInputMode; +type ConfigSetOperation = { + inputMode: ConfigSetInputMode; + requestedPath: PathSegment[]; + setPath: PathSegment[]; + value: unknown; + touchedSecretTargetPath?: string; + touchedProviderAlias?: string; + assignedRef?: SecretRef; +}; const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; +const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"]; +const CONFIG_SET_EXAMPLE_VALUE = formatCliCommand( + "openclaw config set gateway.port 19001 --strict-json", +); +const CONFIG_SET_EXAMPLE_REF = formatCliCommand( + "openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN", +); +const CONFIG_SET_EXAMPLE_PROVIDER = formatCliCommand( + "openclaw config set secrets.providers.vault --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json", +); +const CONFIG_SET_EXAMPLE_BATCH = formatCliCommand( + "openclaw config set --batch-file ./config-set.batch.json --dry-run", +); +const CONFIG_SET_DESCRIPTION = [ + "Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).", + "Examples:", + CONFIG_SET_EXAMPLE_VALUE, + CONFIG_SET_EXAMPLE_REF, + CONFIG_SET_EXAMPLE_PROVIDER, + CONFIG_SET_EXAMPLE_BATCH, +].join("\n"); + +class ConfigSetDryRunValidationError extends Error { + constructor(readonly result: ConfigSetDryRunResult) { + super("config set dry-run validation failed"); + this.name = "ConfigSetDryRunValidationError"; + } +} function isIndexSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); @@ -276,6 +350,628 @@ function ensureValidOllamaProviderForApiKeySet( }); } +function toDotPath(path: PathSegment[]): string { + return path.join("."); +} + +function parseSecretRefSource(raw: string, label: string): SecretRefSource { + const source = raw.trim(); + if (source === "env" || source === "file" || source === "exec") { + return source; + } + throw new Error(`${label} must be one of: env, file, exec.`); +} + +function parseSecretRefBuilder(params: { + provider: string; + source: string; + id: string; + fieldPrefix: string; +}): SecretRef { + const provider = params.provider.trim(); + if (!provider) { + throw new Error(`${params.fieldPrefix}.provider is required.`); + } + if (!isValidSecretProviderAlias(provider)) { + throw new Error( + `${params.fieldPrefix}.provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`, + ); + } + + const source = parseSecretRefSource(params.source, `${params.fieldPrefix}.source`); + const id = params.id.trim(); + if (!id) { + throw new Error(`${params.fieldPrefix}.id is required.`); + } + if (source === "env" && !isValidEnvSecretRefId(id)) { + throw new Error(`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for env refs.`); + } + if (source === "file" && !isValidFileSecretRefId(id)) { + throw new Error( + `${params.fieldPrefix}.id must be an absolute JSON pointer (or "value" for singleValue mode).`, + ); + } + if (source === "exec") { + const validated = validateExecSecretRefId(id); + if (!validated.ok) { + throw new Error(formatExecSecretRefIdValidationMessage()); + } + } + return { source, provider, id }; +} + +function parseOptionalPositiveInteger(raw: string | undefined, flag: string): number | undefined { + if (raw === undefined) { + return undefined; + } + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error(`${flag} must not be empty.`); + } + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${flag} must be a positive integer.`); + } + return parsed; +} + +function parseProviderEnvEntries( + entries: string[] | undefined, +): Record | undefined { + if (!entries || entries.length === 0) { + return undefined; + } + const env: Record = {}; + for (const entry of entries) { + const separator = entry.indexOf("="); + if (separator <= 0) { + throw new Error(`--provider-env expects KEY=VALUE entries (received: "${entry}").`); + } + const key = entry.slice(0, separator).trim(); + if (!key) { + throw new Error(`--provider-env key must not be empty (received: "${entry}").`); + } + env[key] = entry.slice(separator + 1); + } + return Object.keys(env).length > 0 ? env : undefined; +} + +function parseProviderAliasPath(path: PathSegment[]): string { + const expectedPrefixMatches = + path.length === 3 && + path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && + path[1] === SECRET_PROVIDER_PATH_PREFIX[1]; + if (!expectedPrefixMatches) { + throw new Error( + 'Provider builder mode requires path "secrets.providers." (example: secrets.providers.vault).', + ); + } + const alias = path[2] ?? ""; + if (!isValidSecretProviderAlias(alias)) { + throw new Error( + `Provider alias "${alias}" must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`, + ); + } + return alias; +} + +function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig { + const sourceRaw = opts.providerSource?.trim(); + if (!sourceRaw) { + throw new Error("--provider-source is required in provider builder mode."); + } + const source = parseSecretRefSource(sourceRaw, "--provider-source"); + const timeoutMs = parseOptionalPositiveInteger(opts.providerTimeoutMs, "--provider-timeout-ms"); + const maxBytes = parseOptionalPositiveInteger(opts.providerMaxBytes, "--provider-max-bytes"); + const noOutputTimeoutMs = parseOptionalPositiveInteger( + opts.providerNoOutputTimeoutMs, + "--provider-no-output-timeout-ms", + ); + const maxOutputBytes = parseOptionalPositiveInteger( + opts.providerMaxOutputBytes, + "--provider-max-output-bytes", + ); + const providerEnv = parseProviderEnvEntries(opts.providerEnv); + + let provider: SecretProviderConfig; + if (source === "env") { + const allowlist = (opts.providerAllowlist ?? []).map((entry) => entry.trim()).filter(Boolean); + for (const envName of allowlist) { + if (!isValidEnvSecretRefId(envName)) { + throw new Error( + `--provider-allowlist entry "${envName}" must match /^[A-Z][A-Z0-9_]{0,127}$/.`, + ); + } + } + provider = { + source: "env", + ...(allowlist.length > 0 ? { allowlist } : {}), + }; + } else if (source === "file") { + const filePath = opts.providerPath?.trim(); + if (!filePath) { + throw new Error("--provider-path is required when --provider-source file is used."); + } + const modeRaw = opts.providerMode?.trim(); + if (modeRaw && modeRaw !== "singleValue" && modeRaw !== "json") { + throw new Error("--provider-mode must be one of: singleValue, json."); + } + const mode = modeRaw === "singleValue" || modeRaw === "json" ? modeRaw : undefined; + provider = { + source: "file", + path: filePath, + ...(mode ? { mode } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(maxBytes !== undefined ? { maxBytes } : {}), + }; + } else { + const command = opts.providerCommand?.trim(); + if (!command) { + throw new Error("--provider-command is required when --provider-source exec is used."); + } + provider = { + source: "exec", + command, + ...(opts.providerArg && opts.providerArg.length > 0 + ? { args: opts.providerArg.map((entry) => entry.trim()) } + : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}), + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + ...(opts.providerJsonOnly ? { jsonOnly: true } : {}), + ...(providerEnv ? { env: providerEnv } : {}), + ...(opts.providerPassEnv && opts.providerPassEnv.length > 0 + ? { passEnv: opts.providerPassEnv.map((entry) => entry.trim()).filter(Boolean) } + : {}), + ...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0 + ? { trustedDirs: opts.providerTrustedDir.map((entry) => entry.trim()).filter(Boolean) } + : {}), + ...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}), + ...(opts.providerAllowSymlinkCommand ? { allowSymlinkCommand: true } : {}), + }; + } + + const validated = SecretProviderSchema.safeParse(provider); + if (!validated.success) { + const issue = validated.error.issues[0]; + const issuePath = issue?.path?.join(".") ?? ""; + const issueMessage = issue?.message ?? "Invalid provider config."; + throw new Error(`Provider builder config invalid at ${issuePath}: ${issueMessage}`); + } + return validated.data; +} + +function parseSecretRefFromUnknown(value: unknown, label: string): SecretRef { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object with source/provider/id.`); + } + const candidate = value as Record; + if ( + typeof candidate.provider !== "string" || + typeof candidate.source !== "string" || + typeof candidate.id !== "string" + ) { + throw new Error(`${label} must include string fields: source, provider, id.`); + } + return parseSecretRefBuilder({ + provider: candidate.provider, + source: candidate.source, + id: candidate.id, + fieldPrefix: label, + }); +} + +function buildRefAssignmentOperation(params: { + requestedPath: PathSegment[]; + ref: SecretRef; + inputMode: ConfigSetInputMode; +}): ConfigSetOperation { + const resolved = resolveConfigSecretTargetByPath(params.requestedPath); + if (resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments) { + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: resolved.refPathSegments, + value: params.ref, + touchedSecretTargetPath: toDotPath(resolved.pathSegments), + assignedRef: params.ref, + ...(resolved.providerId ? { touchedProviderAlias: resolved.providerId } : {}), + }; + } + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: params.requestedPath, + value: params.ref, + touchedSecretTargetPath: resolved + ? toDotPath(resolved.pathSegments) + : toDotPath(params.requestedPath), + assignedRef: params.ref, + ...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}), + }; +} + +function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null { + if ( + path.length === 3 && + path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && + path[1] === SECRET_PROVIDER_PATH_PREFIX[1] + ) { + return path[2] ?? null; + } + return null; +} + +function buildValueAssignmentOperation(params: { + requestedPath: PathSegment[]; + value: unknown; + inputMode: ConfigSetInputMode; +}): ConfigSetOperation { + const resolved = resolveConfigSecretTargetByPath(params.requestedPath); + const providerAlias = parseProviderAliasFromTargetPath(params.requestedPath); + const coercedRef = coerceSecretRef(params.value); + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: params.requestedPath, + value: params.value, + ...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}), + ...(providerAlias ? { touchedProviderAlias: providerAlias } : {}), + ...(coercedRef ? { assignedRef: coercedRef } : {}), + }; +} + +function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperation[] { + const operations: ConfigSetOperation[] = []; + for (const [index, entry] of entries.entries()) { + const path = parseRequiredPath(entry.path); + if (entry.ref !== undefined) { + const ref = parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`); + operations.push( + buildRefAssignmentOperation({ + requestedPath: path, + ref, + inputMode: "json", + }), + ); + continue; + } + if (entry.provider !== undefined) { + const alias = parseProviderAliasPath(path); + const validated = SecretProviderSchema.safeParse(entry.provider); + if (!validated.success) { + const issue = validated.error.issues[0]; + const issuePath = issue?.path?.join(".") ?? ""; + throw new Error( + `batch[${index}].provider invalid at ${issuePath}: ${issue?.message ?? ""}`, + ); + } + operations.push({ + inputMode: "json", + requestedPath: path, + setPath: path, + value: validated.data, + touchedProviderAlias: alias, + }); + continue; + } + operations.push( + buildValueAssignmentOperation({ + requestedPath: path, + value: entry.value, + inputMode: "json", + }), + ); + } + return operations; +} + +function modeError(message: string): Error { + return new Error(`config set mode error: ${message}`); +} + +function buildSingleSetOperations(params: { + path?: string; + value?: string; + opts: ConfigSetOptions; +}): ConfigSetOperation[] { + const pathProvided = typeof params.path === "string" && params.path.trim().length > 0; + const parsedPath = pathProvided ? parseRequiredPath(params.path as string) : null; + const strictJson = Boolean(params.opts.strictJson || params.opts.json); + const modeResolution = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: hasRefBuilderOptions(params.opts), + hasProviderBuilderOptions: hasProviderBuilderOptions(params.opts), + strictJson, + }); + if (!modeResolution.ok) { + throw modeError(modeResolution.error); + } + + if (modeResolution.mode === "ref_builder") { + if (!pathProvided || !parsedPath) { + throw modeError("ref builder mode requires ."); + } + if (params.value !== undefined) { + throw modeError("ref builder mode does not accept ."); + } + if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) { + throw modeError( + "ref builder mode requires --ref-provider , --ref-source , and --ref-id .", + ); + } + const ref = parseSecretRefBuilder({ + provider: params.opts.refProvider, + source: params.opts.refSource, + id: params.opts.refId, + fieldPrefix: "ref", + }); + return [ + buildRefAssignmentOperation({ + requestedPath: parsedPath, + ref, + inputMode: "builder", + }), + ]; + } + + if (modeResolution.mode === "provider_builder") { + if (!pathProvided || !parsedPath) { + throw modeError("provider builder mode requires ."); + } + if (params.value !== undefined) { + throw modeError("provider builder mode does not accept ."); + } + const alias = parseProviderAliasPath(parsedPath); + const provider = buildProviderFromBuilder(params.opts); + return [ + { + inputMode: "builder", + requestedPath: parsedPath, + setPath: parsedPath, + value: provider, + touchedProviderAlias: alias, + }, + ]; + } + + if (!pathProvided || !parsedPath) { + throw modeError("value/json mode requires when batch mode is not used."); + } + if (params.value === undefined) { + throw modeError("value/json mode requires ."); + } + const parsedValue = parseValue(params.value, { strictJson }); + return [ + buildValueAssignmentOperation({ + requestedPath: parsedPath, + value: parsedValue, + inputMode: modeResolution.mode === "json" ? "json" : "value", + }), + ]; +} + +function collectDryRunRefs(params: { + config: OpenClawConfig; + operations: ConfigSetOperation[]; +}): SecretRef[] { + const refsByKey = new Map(); + const targetPaths = new Set(); + const providerAliases = new Set(); + + for (const operation of params.operations) { + if (operation.assignedRef) { + refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef); + } + if (operation.touchedSecretTargetPath) { + targetPaths.add(operation.touchedSecretTargetPath); + } + if (operation.touchedProviderAlias) { + providerAliases.add(operation.touchedProviderAlias); + } + } + + if (targetPaths.size === 0 && providerAliases.size === 0) { + return [...refsByKey.values()]; + } + + const defaults = params.config.secrets?.defaults; + for (const target of discoverConfigSecretTargets(params.config)) { + const { ref } = resolveSecretInputRef({ + value: target.value, + refValue: target.refValue, + defaults, + }); + if (!ref) { + continue; + } + if (targetPaths.has(target.path) || providerAliases.has(ref.provider)) { + refsByKey.set(secretRefKey(ref), ref); + } + } + return [...refsByKey.values()]; +} + +async function collectDryRunResolvabilityErrors(params: { + refs: SecretRef[]; + config: OpenClawConfig; +}): Promise { + const failures: ConfigSetDryRunError[] = []; + for (const ref of params.refs) { + try { + await resolveSecretRefValue(ref, { + config: params.config, + env: process.env, + }); + } catch (err) { + failures.push({ + kind: "resolvability", + message: String(err), + ref: `${ref.source}:${ref.provider}:${ref.id}`, + }); + } + } + return failures; +} + +function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { + const validated = validateConfigObjectRaw(config); + if (validated.ok) { + return []; + } + return formatConfigIssueLines(validated.issues, "-", { normalizeRoot: true }).map((message) => ({ + kind: "schema", + message, + })); +} + +function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string { + const schemaErrors = errors.filter((error) => error.kind === "schema"); + const resolveErrors = errors.filter((error) => error.kind === "resolvability"); + const lines: string[] = []; + if (schemaErrors.length > 0) { + lines.push("Dry run failed: config schema validation failed."); + lines.push(...schemaErrors.map((error) => `- ${error.message}`)); + } + if (resolveErrors.length > 0) { + lines.push( + `Dry run failed: ${resolveErrors.length} SecretRef assignment(s) could not be resolved.`, + ); + lines.push( + ...resolveErrors + .slice(0, 5) + .map((error) => `- ${error.ref ?? ""} -> ${error.message}`), + ); + if (resolveErrors.length > 5) { + lines.push(`- ... ${resolveErrors.length - 5} more`); + } + } + return lines.join("\n"); +} + +export async function runConfigSet(opts: { + path?: string; + value?: string; + cliOptions: ConfigSetOptions; + runtime?: RuntimeEnv; +}) { + const runtime = opts.runtime ?? defaultRuntime; + try { + const hasBatchMode = Boolean( + (opts.cliOptions.batchJson && opts.cliOptions.batchJson.trim().length > 0) || + (opts.cliOptions.batchFile && opts.cliOptions.batchFile.trim().length > 0), + ); + const modeResolution = resolveConfigSetMode({ + hasBatchMode, + hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions), + hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions), + strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json), + }); + if (!modeResolution.ok) { + throw modeError(modeResolution.error); + } + + const batchEntries = parseBatchSource(opts.cliOptions); + if (batchEntries) { + if (opts.path !== undefined || opts.value !== undefined) { + throw modeError("batch mode does not accept or arguments."); + } + } + const operations = batchEntries + ? parseBatchOperations(batchEntries) + : buildSingleSetOperations({ + path: opts.path, + value: opts.value, + opts: opts.cliOptions, + }); + const snapshot = await loadValidConfig(runtime); + // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) + // instead of snapshot.config (runtime-merged with defaults). + // This prevents runtime defaults from leaking into the written config file (issue #6070) + const next = structuredClone(snapshot.resolved) as Record; + for (const operation of operations) { + ensureValidOllamaProviderForApiKeySet(next, operation.setPath); + setAtPath(next, operation.setPath, operation.value); + } + const nextConfig = next as OpenClawConfig; + + if (opts.cliOptions.dryRun) { + const hasJsonMode = operations.some((operation) => operation.inputMode === "json"); + const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder"); + const refs = + hasJsonMode || hasBuilderMode + ? collectDryRunRefs({ + config: nextConfig, + operations, + }) + : []; + const errors: ConfigSetDryRunError[] = []; + if (hasJsonMode) { + errors.push(...collectDryRunSchemaErrors(nextConfig)); + } + if (hasJsonMode || hasBuilderMode) { + errors.push( + ...(await collectDryRunResolvabilityErrors({ + refs, + config: nextConfig, + })), + ); + } + const dryRunResult: ConfigSetDryRunResult = { + ok: errors.length === 0, + operations: operations.length, + configPath: shortenHomePath(snapshot.path), + inputModes: [...new Set(operations.map((operation) => operation.inputMode))], + checks: { + schema: hasJsonMode, + resolvability: hasJsonMode || hasBuilderMode, + }, + refsChecked: refs.length, + ...(errors.length > 0 ? { errors } : {}), + }; + if (errors.length > 0) { + if (opts.cliOptions.json) { + throw new ConfigSetDryRunValidationError(dryRunResult); + } + throw new Error(formatDryRunFailureMessage(errors)); + } + if (opts.cliOptions.json) { + runtime.log(JSON.stringify(dryRunResult, null, 2)); + } else { + runtime.log( + info( + `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, + ), + ); + } + return; + } + + await writeConfigFile(next); + if (operations.length === 1) { + runtime.log( + info( + `Updated ${toDotPath(operations[0]?.requestedPath ?? [])}. Restart the gateway to apply.`, + ), + ); + return; + } + runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`)); + } catch (err) { + if ( + opts.cliOptions.dryRun && + opts.cliOptions.json && + err instanceof ConfigSetDryRunValidationError + ) { + runtime.log(JSON.stringify(err.result, null, 2)); + runtime.exit(1); + return; + } + runtime.error(danger(String(err))); + runtime.exit(1); + } +} + export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { @@ -425,30 +1121,72 @@ export function registerConfigCli(program: Command) { cmd .command("set") - .description("Set a config value by dot path") - .argument("", "Config path (dot or bracket notation)") - .argument("", "Value (JSON5 or raw string)") + .description(CONFIG_SET_DESCRIPTION) + .argument("[path]", "Config path (dot or bracket notation)") + .argument("[value]", "Value (JSON5 or raw string)") .option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false) .option("--json", "Legacy alias for --strict-json", false) - .action(async (path: string, value: string, opts) => { - try { - const parsedPath = parseRequiredPath(path); - const parsedValue = parseValue(value, { - strictJson: Boolean(opts.strictJson || opts.json), - }); - const snapshot = await loadValidConfig(); - // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) - // instead of snapshot.config (runtime-merged with defaults). - // This prevents runtime defaults from leaking into the written config file (issue #6070) - const next = structuredClone(snapshot.resolved) as Record; - ensureValidOllamaProviderForApiKeySet(next, parsedPath); - setAtPath(next, parsedPath, parsedValue); - await writeConfigFile(next); - defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } + .option("--dry-run", "Validate changes without writing openclaw.json", false) + .option("--ref-provider ", "SecretRef builder: provider alias") + .option("--ref-source ", "SecretRef builder: source (env|file|exec)") + .option("--ref-id ", "SecretRef builder: ref id") + .option("--provider-source ", "Provider builder: source (env|file|exec)") + .option( + "--provider-allowlist ", + "Provider builder (env): allowlist entry (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option("--provider-path ", "Provider builder (file): path") + .option("--provider-mode ", "Provider builder (file): mode (singleValue|json)") + .option("--provider-timeout-ms ", "Provider builder (file|exec): timeout ms") + .option("--provider-max-bytes ", "Provider builder (file): max bytes") + .option("--provider-command ", "Provider builder (exec): absolute command path") + .option( + "--provider-arg ", + "Provider builder (exec): command arg (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option("--provider-no-output-timeout-ms ", "Provider builder (exec): no-output timeout ms") + .option("--provider-max-output-bytes ", "Provider builder (exec): max output bytes") + .option("--provider-json-only", "Provider builder (exec): require JSON output", false) + .option( + "--provider-env ", + "Provider builder (exec): env assignment (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-pass-env ", + "Provider builder (exec): pass host env var (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-trusted-dir ", + "Provider builder (exec): trusted directory (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-allow-insecure-path", + "Provider builder (exec): bypass strict path permission checks", + false, + ) + .option( + "--provider-allow-symlink-command", + "Provider builder (exec): allow command symlink path", + false, + ) + .option("--batch-json ", "Batch mode: JSON array of set operations") + .option("--batch-file ", "Batch mode: read JSON array of set operations from file") + .action(async (path: string | undefined, value: string | undefined, opts: ConfigSetOptions) => { + await runConfigSet({ + path, + value, + cliOptions: opts, + }); }); cmd diff --git a/src/cli/config-set-dryrun.ts b/src/cli/config-set-dryrun.ts new file mode 100644 index 00000000000..c122a47b33f --- /dev/null +++ b/src/cli/config-set-dryrun.ts @@ -0,0 +1,20 @@ +export type ConfigSetDryRunInputMode = "value" | "json" | "builder"; + +export type ConfigSetDryRunError = { + kind: "schema" | "resolvability"; + message: string; + ref?: string; +}; + +export type ConfigSetDryRunResult = { + ok: boolean; + operations: number; + configPath: string; + inputModes: ConfigSetDryRunInputMode[]; + checks: { + schema: boolean; + resolvability: boolean; + }; + refsChecked: number; + errors?: ConfigSetDryRunError[]; +}; diff --git a/src/cli/config-set-input.test.ts b/src/cli/config-set-input.test.ts new file mode 100644 index 00000000000..fd13aaea46b --- /dev/null +++ b/src/cli/config-set-input.test.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseBatchSource } from "./config-set-input.js"; + +describe("config set input parsing", () => { + it("returns null when no batch options are provided", () => { + expect(parseBatchSource({})).toBeNull(); + }); + + it("rejects using both --batch-json and --batch-file", () => { + expect(() => + parseBatchSource({ + batchJson: "[]", + batchFile: "/tmp/batch.json", + }), + ).toThrow("Use either --batch-json or --batch-file, not both."); + }); + + it("parses valid --batch-json payloads", () => { + const parsed = parseBatchSource({ + batchJson: + '[{"path":"gateway.auth.mode","value":"token"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]', + }); + expect(parsed).toEqual([ + { + path: "gateway.auth.mode", + value: "token", + }, + { + path: "channels.discord.token", + ref: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + }, + { + path: "secrets.providers.default", + provider: { + source: "env", + }, + }, + ]); + }); + + it("rejects malformed --batch-json payloads", () => { + expect(() => + parseBatchSource({ + batchJson: "{", + }), + ).toThrow("Failed to parse --batch-json:"); + }); + + it("rejects --batch-json payloads that are not arrays", () => { + expect(() => + parseBatchSource({ + batchJson: '{"path":"gateway.auth.mode","value":"token"}', + }), + ).toThrow("--batch-json must be a JSON array."); + }); + + it("rejects batch entries without path", () => { + expect(() => + parseBatchSource({ + batchJson: '[{"value":"token"}]', + }), + ).toThrow("--batch-json[0].path is required."); + }); + + it("rejects batch entries that do not contain exactly one mode key", () => { + expect(() => + parseBatchSource({ + batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]', + }), + ).toThrow("--batch-json[0] must include exactly one of: value, ref, provider."); + }); + + it("parses valid --batch-file payloads", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-")); + const batchPath = path.join(tempDir, "batch.json"); + fs.writeFileSync(batchPath, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8"); + try { + const parsed = parseBatchSource({ + batchFile: batchPath, + }); + expect(parsed).toEqual([ + { + path: "gateway.auth.mode", + value: "token", + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects malformed --batch-file payloads", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-invalid-")); + const batchPath = path.join(tempDir, "batch.json"); + fs.writeFileSync(batchPath, "{}", "utf8"); + try { + expect(() => + parseBatchSource({ + batchFile: batchPath, + }), + ).toThrow("--batch-file must be a JSON array."); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/config-set-input.ts b/src/cli/config-set-input.ts new file mode 100644 index 00000000000..b5de984fcdd --- /dev/null +++ b/src/cli/config-set-input.ts @@ -0,0 +1,130 @@ +import fs from "node:fs"; +import JSON5 from "json5"; + +export type ConfigSetOptions = { + strictJson?: boolean; + json?: boolean; + dryRun?: boolean; + refProvider?: string; + refSource?: string; + refId?: string; + providerSource?: string; + providerAllowlist?: string[]; + providerPath?: string; + providerMode?: string; + providerTimeoutMs?: string; + providerMaxBytes?: string; + providerCommand?: string; + providerArg?: string[]; + providerNoOutputTimeoutMs?: string; + providerMaxOutputBytes?: string; + providerJsonOnly?: boolean; + providerEnv?: string[]; + providerPassEnv?: string[]; + providerTrustedDir?: string[]; + providerAllowInsecurePath?: boolean; + providerAllowSymlinkCommand?: boolean; + batchJson?: string; + batchFile?: string; +}; + +export type ConfigSetBatchEntry = { + path: string; + value?: unknown; + ref?: unknown; + provider?: unknown; +}; + +export function hasBatchMode(opts: ConfigSetOptions): boolean { + return Boolean( + (opts.batchJson && opts.batchJson.trim().length > 0) || + (opts.batchFile && opts.batchFile.trim().length > 0), + ); +} + +export function hasRefBuilderOptions(opts: ConfigSetOptions): boolean { + return Boolean(opts.refProvider || opts.refSource || opts.refId); +} + +export function hasProviderBuilderOptions(opts: ConfigSetOptions): boolean { + return Boolean( + opts.providerSource || + opts.providerAllowlist?.length || + opts.providerPath || + opts.providerMode || + opts.providerTimeoutMs || + opts.providerMaxBytes || + opts.providerCommand || + opts.providerArg?.length || + opts.providerNoOutputTimeoutMs || + opts.providerMaxOutputBytes || + opts.providerJsonOnly || + opts.providerEnv?.length || + opts.providerPassEnv?.length || + opts.providerTrustedDir?.length || + opts.providerAllowInsecurePath || + opts.providerAllowSymlinkCommand, + ); +} + +function parseJson5Raw(raw: string, label: string): unknown { + try { + return JSON5.parse(raw); + } catch (err) { + throw new Error(`Failed to parse ${label}: ${String(err)}`, { cause: err }); + } +} + +function parseBatchEntries(raw: string, sourceLabel: string): ConfigSetBatchEntry[] { + const parsed = parseJson5Raw(raw, sourceLabel); + if (!Array.isArray(parsed)) { + throw new Error(`${sourceLabel} must be a JSON array.`); + } + const out: ConfigSetBatchEntry[] = []; + for (const [index, entry] of parsed.entries()) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`${sourceLabel}[${index}] must be an object.`); + } + const typed = entry as Record; + const path = typeof typed.path === "string" ? typed.path.trim() : ""; + if (!path) { + throw new Error(`${sourceLabel}[${index}].path is required.`); + } + const hasValue = Object.prototype.hasOwnProperty.call(typed, "value"); + const hasRef = Object.prototype.hasOwnProperty.call(typed, "ref"); + const hasProvider = Object.prototype.hasOwnProperty.call(typed, "provider"); + const modeCount = Number(hasValue) + Number(hasRef) + Number(hasProvider); + if (modeCount !== 1) { + throw new Error( + `${sourceLabel}[${index}] must include exactly one of: value, ref, provider.`, + ); + } + out.push({ + path, + ...(hasValue ? { value: typed.value } : {}), + ...(hasRef ? { ref: typed.ref } : {}), + ...(hasProvider ? { provider: typed.provider } : {}), + }); + } + return out; +} + +export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[] | null { + const hasInline = Boolean(opts.batchJson && opts.batchJson.trim().length > 0); + const hasFile = Boolean(opts.batchFile && opts.batchFile.trim().length > 0); + if (!hasInline && !hasFile) { + return null; + } + if (hasInline && hasFile) { + throw new Error("Use either --batch-json or --batch-file, not both."); + } + if (hasInline) { + return parseBatchEntries(opts.batchJson as string, "--batch-json"); + } + const pathname = (opts.batchFile as string).trim(); + if (!pathname) { + throw new Error("--batch-file must not be empty."); + } + const raw = fs.readFileSync(pathname, "utf8"); + return parseBatchEntries(raw, "--batch-file"); +} diff --git a/src/cli/config-set-mode.test.ts b/src/cli/config-set-mode.test.ts new file mode 100644 index 00000000000..062f8f2e9aa --- /dev/null +++ b/src/cli/config-set-mode.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { resolveConfigSetMode } from "./config-set-parser.js"; + +describe("resolveConfigSetMode", () => { + it("selects value mode by default", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "value" }); + }); + + it("selects json mode when strict parsing is enabled", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: false, + strictJson: true, + }); + expect(result).toEqual({ ok: true, mode: "json" }); + }); + + it("selects ref-builder mode when ref flags are present", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: true, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "ref_builder" }); + }); + + it("selects provider-builder mode when provider flags are present", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: true, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "provider_builder" }); + }); + + it("returns batch mode when batch flags are present", () => { + const result = resolveConfigSetMode({ + hasBatchMode: true, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "batch" }); + }); + + it("rejects ref-builder and provider-builder collisions", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: true, + hasProviderBuilderOptions: true, + strictJson: false, + }); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ + error: expect.stringContaining("choose exactly one mode"), + }); + }); + + it("rejects mixing batch mode with builder flags", () => { + const result = resolveConfigSetMode({ + hasBatchMode: true, + hasRefBuilderOptions: true, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ + error: expect.stringContaining("batch mode (--batch-json/--batch-file) cannot be combined"), + }); + }); +}); diff --git a/src/cli/config-set-parser.ts b/src/cli/config-set-parser.ts new file mode 100644 index 00000000000..a3cac0217bc --- /dev/null +++ b/src/cli/config-set-parser.ts @@ -0,0 +1,43 @@ +export type ConfigSetMode = "value" | "json" | "ref_builder" | "provider_builder" | "batch"; + +export type ConfigSetModeResolution = + | { + ok: true; + mode: ConfigSetMode; + } + | { + ok: false; + error: string; + }; + +export function resolveConfigSetMode(params: { + hasBatchMode: boolean; + hasRefBuilderOptions: boolean; + hasProviderBuilderOptions: boolean; + strictJson: boolean; +}): ConfigSetModeResolution { + if (params.hasBatchMode) { + if (params.hasRefBuilderOptions || params.hasProviderBuilderOptions) { + return { + ok: false, + error: + "batch mode (--batch-json/--batch-file) cannot be combined with ref builder (--ref-*) or provider builder (--provider-*) flags.", + }; + } + return { ok: true, mode: "batch" }; + } + if (params.hasRefBuilderOptions && params.hasProviderBuilderOptions) { + return { + ok: false, + error: + "choose exactly one mode: ref builder (--ref-provider/--ref-source/--ref-id) or provider builder (--provider-*), not both.", + }; + } + if (params.hasRefBuilderOptions) { + return { ok: true, mode: "ref_builder" }; + } + if (params.hasProviderBuilderOptions) { + return { ok: true, mode: "provider_builder" }; + } + return { ok: true, mode: params.strictJson ? "json" : "value" }; +} diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index fcfdc694f85..230b68f0180 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -239,6 +239,24 @@ export function resolvePlanTargetAgainstRegistry(candidate: { return null; } +export function resolveConfigSecretTargetByPath(pathSegments: string[]): ResolvedPlanTarget | null { + for (const entry of OPENCLAW_COMPILED_SECRET_TARGETS) { + if (!entry.includeInPlan) { + continue; + } + const matched = matchPathTokens(pathSegments, entry.pathTokens); + if (!matched) { + continue; + } + const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures); + if (!resolved) { + continue; + } + return resolved; + } + return null; +} + export function discoverConfigSecretTargets( config: OpenClawConfig, ): DiscoveredConfigSecretTarget[] { diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index cc536fd2eb3..78e9e5f1cfe 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { buildSecretRefCredentialMatrix } from "./credential-matrix.js"; -import { discoverConfigSecretTargetsByIds } from "./target-registry.js"; +import { + discoverConfigSecretTargetsByIds, + resolveConfigSecretTargetByPath, +} from "./target-registry.js"; describe("secret target registry", () => { it("stays in sync with docs/reference/secretref-user-supplied-credentials-matrix.json", () => { @@ -96,4 +99,15 @@ describe("secret target registry", () => { expect(targets[0]?.entry.id).toBe("talk.apiKey"); expect(targets[0]?.path).toBe("talk.apiKey"); }); + + it("resolves config targets by exact path including sibling ref metadata", () => { + const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); + expect(target).not.toBeNull(); + expect(target?.entry.id).toBe("channels.googlechat.serviceAccount"); + expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); + }); + + it("returns null when no config target path matches", () => { + expect(resolveConfigSecretTargetByPath(["gateway", "auth", "mode"])).toBeNull(); + }); }); From 0e4c072f37da76ea328265ed8b0eb5924d3813df Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 16:21:39 -0700 Subject: [PATCH 146/393] Models: add native GPT-5.4 mini and nano support (#49289) * Models: add GPT-5.4 mini and nano support * Tests: cover OpenAI GPT-5.4 mini and nano extension support --- CHANGELOG.md | 1 + extensions/openai/openai-provider.test.ts | 108 ++++++++++++++++++ extensions/openai/openai-provider.ts | 103 ++++++++++++++--- src/agents/model-catalog.test.ts | 30 +++++ src/agents/model-compat.test.ts | 5 +- src/agents/pi-embedded-runner/model.test.ts | 64 +++++++++++ ...ng-mixed-messages-acks-immediately.test.ts | 9 +- .../contracts/runtime.contract.test.ts | 32 ++++++ src/plugins/provider-catalog-metadata.ts | 24 ++++ src/plugins/provider-runtime.test-support.ts | 4 + src/plugins/provider-runtime.test.ts | 4 + 11 files changed, 363 insertions(+), 21 deletions(-) create mode 100644 extensions/openai/openai-provider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8930840332c..f70d7c2ffb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Browser/existing-session: support `browser.profiles..userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark. - Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese. +- Models/OpenAI: add native forward-compat support for `gpt-5.4-mini` and `gpt-5.4-nano` in the OpenAI provider catalog, runtime resolution, and reasoning capability gates. Thanks @vincentkoc. - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. - Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts new file mode 100644 index 00000000000..04ef3700fb3 --- /dev/null +++ b/extensions/openai/openai-provider.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { buildOpenAIProvider } from "./openai-provider.js"; + +describe("buildOpenAIProvider", () => { + it("resolves gpt-5.4 mini and nano from GPT-5 small-model templates", () => { + const provider = buildOpenAIProvider(); + const registry = { + find(providerId: string, id: string) { + if (providerId !== "openai") { + return null; + } + if (id === "gpt-5-mini") { + return { + id, + name: "GPT-5 mini", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + } + if (id === "gpt-5-nano") { + return { + id, + name: "GPT-5 nano", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + }; + } + return null; + }, + }; + + const mini = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4-mini", + modelRegistry: registry as never, + }); + const nano = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4-nano", + modelRegistry: registry as never, + }); + + expect(mini).toMatchObject({ + provider: "openai", + id: "gpt-5.4-mini", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 400_000, + maxTokens: 128_000, + }); + expect(nano).toMatchObject({ + provider: "openai", + id: "gpt-5.4-nano", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 200_000, + maxTokens: 64_000, + }); + }); + + it("surfaces gpt-5.4 mini and nano in xhigh and augmented catalog metadata", () => { + const provider = buildOpenAIProvider(); + + expect( + provider.supportsXHighThinking?.({ + provider: "openai", + modelId: "gpt-5.4-mini", + } as never), + ).toBe(true); + expect( + provider.supportsXHighThinking?.({ + provider: "openai", + modelId: "gpt-5.4-nano", + } as never), + ).toBe(true); + + const entries = provider.augmentModelCatalog?.({ + env: process.env, + entries: [ + { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, + { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, + ], + } as never); + + expect(entries).toContainEqual({ + provider: "openai", + id: "gpt-5.4-mini", + name: "gpt-5.4-mini", + }); + expect(entries).toContainEqual({ + provider: "openai", + id: "gpt-5.4-nano", + name: "gpt-5.4-nano", + }); + }); +}); diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 8e97b56573f..17053e29e69 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -5,6 +5,7 @@ import { import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyOpenAIConfig, + DEFAULT_CONTEXT_TOKENS, normalizeModelCompat, normalizeProviderId, OPENAI_DEFAULT_MODEL, @@ -20,12 +21,29 @@ import { const PROVIDER_ID = "openai"; const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; +const OPENAI_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini"; +const OPENAI_GPT_54_NANO_MODEL_ID = "gpt-5.4-nano"; const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; const OPENAI_GPT_54_MAX_TOKENS = 128_000; const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; -const OPENAI_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2"] as const; -const OPENAI_MODERN_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2", "gpt-5.0"] as const; +const OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS = ["gpt-5-mini"] as const; +const OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS = ["gpt-5-nano", "gpt-5-mini"] as const; +const OPENAI_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.2", +] as const; +const OPENAI_MODERN_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.2", + "gpt-5.0", +] as const; const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); @@ -49,10 +67,47 @@ function resolveOpenAIGpt54ForwardCompatModel( const trimmedModelId = ctx.modelId.trim(); const lower = trimmedModelId.toLowerCase(); let templateIds: readonly string[]; + let patch: Partial; if (lower === OPENAI_GPT_54_MODEL_ID) { templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }; } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }; + } else if (lower === OPENAI_GPT_54_MINI_MODEL_ID) { + templateIds = OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + }; + } else if (lower === OPENAI_GPT_54_NANO_MODEL_ID) { + templateIds = OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + }; } else { return undefined; } @@ -63,27 +118,15 @@ function resolveOpenAIGpt54ForwardCompatModel( modelId: trimmedModelId, templateIds, ctx, - patch: { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }, + patch, }) ?? normalizeModelCompat({ id: trimmedModelId, name: trimmedModelId, - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], + ...patch, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, + contextWindow: patch.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_TOKENS, } as ProviderRuntimeModel) ); } @@ -157,6 +200,16 @@ export function buildOpenAIProvider(): ProviderPlugin { providerId: PROVIDER_ID, templateIds: OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS, }); + const openAiGpt54MiniTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS, + }); + const openAiGpt54NanoTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS, + }); return [ openAiGpt54Template ? { @@ -172,6 +225,20 @@ export function buildOpenAIProvider(): ProviderPlugin { name: OPENAI_GPT_54_PRO_MODEL_ID, } : undefined, + openAiGpt54MiniTemplate + ? { + ...openAiGpt54MiniTemplate, + id: OPENAI_GPT_54_MINI_MODEL_ID, + name: OPENAI_GPT_54_MINI_MODEL_ID, + } + : undefined, + openAiGpt54NanoTemplate + ? { + ...openAiGpt54NanoTemplate, + id: OPENAI_GPT_54_NANO_MODEL_ID, + name: OPENAI_GPT_54_NANO_MODEL_ID, + } + : undefined, ].filter((entry): entry is NonNullable => entry !== undefined); }, }; diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index cf7d6e444f2..8d56da2389a 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -181,6 +181,22 @@ describe("loadModelCatalog", () => { contextWindow: 1_050_000, input: ["text", "image"], }, + { + id: "gpt-5-mini", + provider: "openai", + name: "GPT-5 mini", + reasoning: true, + contextWindow: 400_000, + input: ["text", "image"], + }, + { + id: "gpt-5-nano", + provider: "openai", + name: "GPT-5 nano", + reasoning: true, + contextWindow: 400_000, + input: ["text", "image"], + }, { id: "gpt-5.3-codex", provider: "openai-codex", @@ -207,6 +223,20 @@ describe("loadModelCatalog", () => { name: "gpt-5.4-pro", }), ); + expect(result).toContainEqual( + expect.objectContaining({ + provider: "openai", + id: "gpt-5.4-mini", + name: "gpt-5.4-mini", + }), + ); + expect(result).toContainEqual( + expect.objectContaining({ + provider: "openai", + id: "gpt-5.4-nano", + name: "gpt-5.4-nano", + }), + ); expect(result).toContainEqual( expect.objectContaining({ provider: "openai-codex", diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 4c35f87dd62..e576bc621b3 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -347,7 +347,8 @@ describe("isModernModelRef", () => { it("includes plugin-advertised modern models", () => { providerRuntimeMocks.resolveProviderModernModelRef.mockImplementation(({ provider, context }) => - provider === "openai" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId) + provider === "openai" && + ["gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano"].includes(context.modelId) ? true : provider === "openai-codex" && context.modelId === "gpt-5.4" ? true @@ -360,6 +361,8 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "openai", id: "gpt-5.4" })).toBe(true); expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-pro" })).toBe(true); + expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-mini" })).toBe(true); + expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-nano" })).toBe(true); expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4" })).toBe(true); expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true); expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index a66cb697cb4..42044f8a7d3 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -757,6 +757,70 @@ describe("resolveModel", () => { }); }); + it("builds an openai fallback for gpt-5.4 mini from the gpt-5-mini template", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5-mini", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5-mini", + name: "GPT-5 mini", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }), + }); + + const result = resolveModel("openai", "gpt-5.4-mini", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4-mini", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }); + }); + + it("builds an openai fallback for gpt-5.4 nano from the gpt-5-nano template", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5-nano", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5-nano", + name: "GPT-5 nano", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }), + }); + + const result = resolveModel("openai", "gpt-5.4-nano", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4-nano", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }); + }); + it("normalizes stale native openai gpt-5.4 completions transport to responses", () => { mockDiscoveredModel({ provider: "openai", diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts index f5cd484fba4..c3c1c073e24 100644 --- a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts @@ -232,14 +232,19 @@ describe("directive behavior", () => { expect(text).toContain("Current thinking level: high"); expect(text).toContain("Options: off, minimal, low, medium, high, adaptive."); - for (const model of ["openai-codex/gpt-5.2-codex", "openai/gpt-5.2"]) { + for (const model of [ + "openai-codex/gpt-5.2-codex", + "openai/gpt-5.2", + "openai/gpt-5.4-mini", + "openai/gpt-5.4-nano", + ]) { const texts = await runThinkingDirective(home, model); expect(texts).toContain("Thinking level set to xhigh."); } const unsupportedModelTexts = await runThinkingDirective(home, "openai/gpt-4.1-mini"); expect(unsupportedModelTexts).toContain( - 'Thinking level "xhigh" is only supported for openai/gpt-5.4, openai/gpt-5.4-pro, openai/gpt-5.2, openai-codex/gpt-5.4, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', + 'Thinking level "xhigh" is only supported for openai/gpt-5.4, openai/gpt-5.4-pro, openai/gpt-5.4-mini, openai/gpt-5.4-nano, openai/gpt-5.2, openai-codex/gpt-5.4, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', ); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 15adc59e130..4009d31886a 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -330,6 +330,38 @@ describe("provider runtime contract", () => { }); }); + it("owns openai gpt-5.4 mini forward-compat resolution", () => { + const provider = requireProviderContractProvider("openai"); + const model = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4-mini", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gpt-5-mini" + ? createModel({ + id, + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + reasoning: true, + contextWindow: 400_000, + maxTokens: 128_000, + }) + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gpt-5.4-mini", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 400_000, + maxTokens: 128_000, + }); + }); + it("owns direct openai transport normalization", () => { const provider = requireProviderContractProvider("openai"); expect( diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts index 5714861b219..1347fe00629 100644 --- a/src/plugins/provider-catalog-metadata.ts +++ b/src/plugins/provider-catalog-metadata.ts @@ -38,6 +38,16 @@ export function augmentBundledProviderCatalog( providerId: OPENAI_PROVIDER_ID, templateIds: ["gpt-5.2-pro", "gpt-5.2"], }); + const openAiGpt54MiniTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5-mini"], + }); + const openAiGpt54NanoTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5-nano", "gpt-5-mini"], + }); const openAiCodexGpt54Template = findCatalogTemplate({ entries: context.entries, providerId: OPENAI_CODEX_PROVIDER_ID, @@ -64,6 +74,20 @@ export function augmentBundledProviderCatalog( name: "gpt-5.4-pro", } : undefined, + openAiGpt54MiniTemplate + ? { + ...openAiGpt54MiniTemplate, + id: "gpt-5.4-mini", + name: "gpt-5.4-mini", + } + : undefined, + openAiGpt54NanoTemplate + ? { + ...openAiGpt54NanoTemplate, + id: "gpt-5.4-nano", + name: "gpt-5.4-nano", + } + : undefined, openAiCodexGpt54Template ? { ...openAiCodexGpt54Template, diff --git a/src/plugins/provider-runtime.test-support.ts b/src/plugins/provider-runtime.test-support.ts index 818ad364cbd..9e9fb0bb877 100644 --- a/src/plugins/provider-runtime.test-support.ts +++ b/src/plugins/provider-runtime.test-support.ts @@ -3,12 +3,16 @@ import { expect } from "vitest"; export const openaiCodexCatalogEntries = [ { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, + { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, ]; export const expectedAugmentedOpenaiCodexCatalogEntries = [ { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai", id: "gpt-5.4-mini", name: "gpt-5.4-mini" }, + { provider: "openai", id: "gpt-5.4-nano", name: "gpt-5.4-nano" }, { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, { provider: "openai-codex", diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index d0e57c9216b..2c1cc1e2d57 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -471,6 +471,8 @@ describe("provider-runtime", () => { entries: [ { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, + { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, ], }, @@ -478,6 +480,8 @@ describe("provider-runtime", () => { ).resolves.toEqual([ { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai", id: "gpt-5.4-mini", name: "gpt-5.4-mini" }, + { provider: "openai", id: "gpt-5.4-nano", name: "gpt-5.4-nano" }, { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, { provider: "openai-codex", From f118191182a5d93f8028aac2883d1a43c93f5e89 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:21:10 +0000 Subject: [PATCH 147/393] Plugin SDK: break line and nostr export cycles --- extensions/line/src/setup-core.ts | 10 +++------- extensions/line/src/setup-surface.ts | 2 +- src/plugin-sdk/line.ts | 4 ++-- src/plugin-sdk/nostr.ts | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 3fd00dcdbc3..95554e0a835 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,11 +1,7 @@ import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; -import { - listLineAccountIds, - normalizeAccountId, - resolveLineAccount, - type LineConfig, -} from "../api.js"; +import { normalizeAccountId, resolveLineAccount } from "../../../src/line/accounts.js"; +import type { LineConfig } from "../../../src/line/types.js"; const channel = "line" as const; @@ -158,4 +154,4 @@ export const lineSetupAdapter: ChannelSetupAdapter = { }, }; -export { listLineAccountIds }; +export { listLineAccountIds } from "../../../src/line/accounts.js"; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 24afb238b6a..1d994ebb128 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -7,7 +7,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; -import { resolveLineAccount } from "../api.js"; +import { resolveLineAccount } from "../../../src/line/accounts.js"; import { isLineConfigured, listLineAccountIds, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index 9592fe7f12e..b6617199472 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -32,8 +32,8 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; -export { lineSetupAdapter } from "../../extensions/line/api.js"; -export { lineSetupWizard } from "../../extensions/line/api.js"; +export { lineSetupAdapter } from "../../extensions/line/src/setup-core.js"; +export { lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 362344810fa..a2997c5702c 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -19,4 +19,4 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; -export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/api.js"; +export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js"; From ffe24955c81f283f4a9f95afad91b7ac1a6752a0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:25:40 +0000 Subject: [PATCH 148/393] Plugins: fix pnpm check regressions --- extensions/discord/src/channel.setup.ts | 7 +------ extensions/discord/src/channel.ts | 3 --- extensions/feishu/index.ts | 1 - extensions/imessage/src/channel.setup.ts | 7 +------ extensions/imessage/src/channel.ts | 3 --- extensions/signal/src/channel.setup.ts | 7 +------ extensions/signal/src/channel.ts | 3 --- extensions/slack/src/channel.setup.ts | 7 +------ extensions/slack/src/channel.ts | 3 --- extensions/telegram/src/channel.setup.ts | 7 +------ extensions/telegram/src/channel.ts | 3 --- extensions/whatsapp/src/channel.setup.ts | 15 +-------------- extensions/whatsapp/src/channel.ts | 11 ----------- src/agents/pi-embedded-runner/model.test.ts | 3 ++- src/channels/plugins/contracts/registry.ts | 2 +- src/cli/plugins-cli.ts | 2 +- src/plugins/status.ts | 6 +++--- 17 files changed, 13 insertions(+), 77 deletions(-) diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index efec8990442..c45ed85fb0b 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,15 +1,10 @@ -import { - buildChannelConfigSchema, - DiscordConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/discord"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/discord"; import { type ResolvedDiscordAccount } from "./accounts.js"; import { discordSetupAdapter } from "./setup-core.js"; import { createDiscordPluginBase } from "./shared.js"; export const discordSetupPlugin: ChannelPlugin = { ...createDiscordPluginBase({ - configSchema: buildChannelConfigSchema(DiscordConfigSchema), setup: discordSetupAdapter, }), }; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c4ff4827038..29568ed58dc 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -13,10 +13,8 @@ import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, - DiscordConfigSchema, getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, @@ -278,7 +276,6 @@ function resolveDiscordOutboundSessionRoute(params: { export const discordPlugin: ChannelPlugin = { ...createDiscordPluginBase({ - configSchema: buildChannelConfigSchema(DiscordConfigSchema), setup: discordSetupAdapter, }), pairing: { diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 837ffa28671..1e18c0eea12 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -45,7 +45,6 @@ export { buildMentionedCardContent, type MentionTarget, } from "./src/mention.js"; -export { feishuPlugin } from "./src/channel.js"; export default defineChannelPluginEntry({ id: "feishu", diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index a6f2f90d9f0..4f715cab88c 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,15 +1,10 @@ -import { - buildChannelConfigSchema, - IMessageConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/imessage"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; import { type ResolvedIMessageAccount } from "./accounts.js"; import { imessageSetupAdapter } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; export const imessageSetupPlugin: ChannelPlugin = { ...createIMessagePluginBase({ - configSchema: buildChannelConfigSchema(IMessageConfigSchema), setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, }), diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index fe20327e463..3c34cea1be7 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -6,11 +6,9 @@ import { import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { - buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, formatTrimmedAllowFromEntries, - IMessageConfigSchema, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveIMessageGroupRequireMention, @@ -100,7 +98,6 @@ function resolveIMessageOutboundSessionRoute(params: { export const imessagePlugin: ChannelPlugin = { ...createIMessagePluginBase({ - configSchema: buildChannelConfigSchema(IMessageConfigSchema), setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, }), diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 752fcfcc241..6fa8add4405 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,15 +1,10 @@ -import { - buildChannelConfigSchema, - SignalConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/signal"; import { type ResolvedSignalAccount } from "./accounts.js"; import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { ...createSignalPluginBase({ - configSchema: buildChannelConfigSchema(SignalConfigSchema), setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 0a58c29bfe7..17b97c96f25 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -11,7 +11,6 @@ import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, - buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, @@ -20,7 +19,6 @@ import { normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, } from "openclaw/plugin-sdk/signal"; @@ -279,7 +277,6 @@ async function sendFormattedSignalMedia(ctx: { export const signalPlugin: ChannelPlugin = { ...createSignalPluginBase({ - configSchema: buildChannelConfigSchema(SignalConfigSchema), setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 519f6eabe7b..854e1782315 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,8 +1,4 @@ -import { - buildChannelConfigSchema, - SlackConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/slack"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; import { slackSetupAdapter } from "./setup-core.js"; import { slackSetupWizard } from "./setup-surface.js"; @@ -10,7 +6,6 @@ import { createSlackPluginBase } from "./shared.js"; export const slackSetupPlugin: ChannelPlugin = { ...createSlackPluginBase({ - configSchema: buildChannelConfigSchema(SlackConfigSchema), setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 8a82a3577b8..5e25f0187b1 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -11,7 +11,6 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { - buildChannelConfigSchema, buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, listSlackDirectoryGroupsFromConfig, @@ -23,7 +22,6 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, - SlackConfigSchema, createSlackActions, type ChannelPlugin, type OpenClawConfig, @@ -309,7 +307,6 @@ async function resolveSlackAllowlistNames(params: { export const slackPlugin: ChannelPlugin = { ...createSlackPluginBase({ - configSchema: buildChannelConfigSchema(SlackConfigSchema), setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index bdee67aa41d..4879ef96c09 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,8 +1,4 @@ -import { - buildChannelConfigSchema, - TelegramConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/telegram"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; import { type ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; @@ -11,7 +7,6 @@ import { createTelegramPluginBase } from "./shared.js"; export const telegramSetupPlugin: ChannelPlugin = { ...createTelegramPluginBase({ - configSchema: buildChannelConfigSchema(TelegramConfigSchema), setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0e2ce964b95..d89d74da289 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -12,7 +12,6 @@ import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra- import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram"; import { - buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, @@ -21,7 +20,6 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, - TelegramConfigSchema, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, type ChannelPlugin, @@ -298,7 +296,6 @@ function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { export const telegramPlugin: ChannelPlugin = { ...createTelegramPluginBase({ - configSchema: buildChannelConfigSchema(TelegramConfigSchema), setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 3b4ecacce26..ebe4deb5789 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,11 +1,4 @@ -import { - buildChannelConfigSchema, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, - WhatsAppConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; import { whatsappSetupAdapter } from "./setup-core.js"; @@ -13,12 +6,6 @@ import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js" export const whatsappSetupPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, isConfigured: async (account) => await webAuthExists(account.authDir), diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d69dd480a4a..e7f79ad5f2a 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,6 +1,5 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildChannelConfigSchema, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, @@ -8,13 +7,9 @@ import { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, readStringParam, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, resolveWhatsAppOutboundTarget, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, - WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp"; @@ -49,12 +44,6 @@ function parseWhatsAppExplicitTarget(raw: string) { export const whatsappPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, isConfigured: async (account) => diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 42044f8a7d3..b733e3a3f5f 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -43,6 +43,7 @@ function buildForwardCompatTemplate(params: { provider: string; api: "anthropic-messages" | "google-gemini-cli" | "openai-completions" | "openai-responses"; baseUrl: string; + reasoning?: boolean; input?: readonly ["text"] | readonly ["text", "image"]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; @@ -54,7 +55,7 @@ function buildForwardCompatTemplate(params: { provider: params.provider, api: params.api, baseUrl: params.baseUrl, - reasoning: true, + reasoning: params.reasoning ?? true, input: params.input ?? (["text", "image"] as const), cost: params.cost ?? { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, contextWindow: params.contextWindow ?? 200000, diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index d651b6ef012..fd2d84e8b70 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -4,7 +4,7 @@ import { createThreadBindingManager as createDiscordThreadBindingManager, } from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; -import { setMatrixRuntime } from "../../../../extensions/matrix/api.js"; +import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index c91f65c04c7..412e45a6639 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -143,7 +143,7 @@ function formatInspectSection(title: string, lines: string[]): string[] { if (lines.length === 0) { return []; } - return ["", `${theme.muted(`${title}:`)}`, ...lines]; + return ["", theme.muted(`${title}:`), ...lines]; } function formatCapabilityKinds( diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 09a75e02516..5588d6f5874 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -159,14 +159,14 @@ export function buildPluginInspectReport(params: { name: entry.hookName, priority: entry.priority, })) - .sort((a, b) => a.name.localeCompare(b.name)); + .toSorted((a, b) => a.name.localeCompare(b.name)); const customHooks = report.hooks .filter((entry) => entry.pluginId === plugin.id) .map((entry) => ({ name: entry.entry.hook.name, - events: [...entry.events].sort(), + events: [...entry.events].toSorted(), })) - .sort((a, b) => a.name.localeCompare(b.name)); + .toSorted((a, b) => a.name.localeCompare(b.name)); const tools = report.tools .filter((entry) => entry.pluginId === plugin.id) .map((entry) => ({ From ab5aec137c9ed9e85277a471a2acc1f1098bbf1d Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:28:46 -0500 Subject: [PATCH 149/393] CLI: fix config set dry-run coverage gaps --- src/cli/config-cli.test.ts | 70 ++++++++++++++++++++++++++++++++++++++ src/cli/config-cli.ts | 23 +++++++++---- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 69ba866534e..582cd9fd2d3 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -513,6 +513,26 @@ describe("config cli", () => { ); }); + it("logs a dry-run note when value mode performs no validation checks", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "gateway.port", "19001", "--dry-run"]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Dry run note: value mode does not run schema/resolvability checks.", + ), + ); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining("Dry run successful: 1 update(s) validated"), + ); + }); + it("supports batch mode for refs/providers in dry-run", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 }, @@ -862,6 +882,56 @@ describe("config cli", () => { ); expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch")); }); + + it("fails dry-run for nested provider edits that make existing refs unresolvable", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + tools: { + web: { + search: { + enabled: true, + apiKey: { + source: "file", + provider: "vaultfile", + id: "/providers/search/apiKey", + }, + }, + }, + } as never, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockImplementationOnce(async () => { + throw new Error("provider mismatch"); + }); + + await expect( + runConfigCommand([ + "config", + "set", + "secrets.providers.vaultfile.path", + '"/tmp/other-secrets.json"', + "--strict-json", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "vaultfile", + id: "/providers/search/apiKey", + }), + expect.any(Object), + ); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."), + ); + expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch")); + }); }); describe("path hardening", () => { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index f7efaf1c865..0da785a2fd8 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -42,6 +42,7 @@ import type { ConfigSetDryRunResult, } from "./config-set-dryrun.js"; import { + hasBatchMode, hasProviderBuilderOptions, hasRefBuilderOptions, parseBatchSource, @@ -593,7 +594,7 @@ function buildRefAssignmentOperation(params: { function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null { if ( - path.length === 3 && + path.length >= 3 && path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && path[1] === SECRET_PROVIDER_PATH_PREFIX[1] ) { @@ -857,12 +858,9 @@ export async function runConfigSet(opts: { }) { const runtime = opts.runtime ?? defaultRuntime; try { - const hasBatchMode = Boolean( - (opts.cliOptions.batchJson && opts.cliOptions.batchJson.trim().length > 0) || - (opts.cliOptions.batchFile && opts.cliOptions.batchFile.trim().length > 0), - ); + const isBatchMode = hasBatchMode(opts.cliOptions); const modeResolution = resolveConfigSetMode({ - hasBatchMode, + hasBatchMode: isBatchMode, hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions), hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions), strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json), @@ -938,6 +936,13 @@ export async function runConfigSet(opts: { if (opts.cliOptions.json) { runtime.log(JSON.stringify(dryRunResult, null, 2)); } else { + if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) { + runtime.log( + info( + "Dry run note: value mode does not run schema/resolvability checks. Use --strict-json, builder flags, or batch mode to enable validation checks.", + ), + ); + } runtime.log( info( `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, @@ -1126,7 +1131,11 @@ export function registerConfigCli(program: Command) { .argument("[value]", "Value (JSON5 or raw string)") .option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false) .option("--json", "Legacy alias for --strict-json", false) - .option("--dry-run", "Validate changes without writing openclaw.json", false) + .option( + "--dry-run", + "Validate changes without writing openclaw.json (checks run in builder/json/batch modes)", + false, + ) .option("--ref-provider ", "SecretRef builder: provider alias") .option("--ref-source ", "SecretRef builder: source (env|file|exec)") .option("--ref-id ", "SecretRef builder: ref id") From 79f7dbfd6ecd0c3a607ed9516eca07e5224c3a72 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:32:55 -0500 Subject: [PATCH 150/393] Changelog: add config set expansion entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70d7c2ffb9..cf095c0ebf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. - Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. +- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. ### Breaking From 4e912bffd899784f41ad45647625c7adf897bafc Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Tue, 17 Mar 2026 16:40:20 -0700 Subject: [PATCH 151/393] Agents: improve prompt cache hit rate and add prompt composition regression tests (#49237) Merged via squash. Prepared head SHA: 978b0cd6c79064f4c67e5f347655263a6d1cfbdf Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Reviewed-by: @scoootscooob --- CHANGELOG.md | 1 + src/agents/bootstrap-budget.test.ts | 19 +- src/agents/bootstrap-budget.ts | 7 +- .../pi-embedded-runner/run/attempt.test.ts | 38 + src/agents/prompt-composition-scenarios.ts | 652 ++++++++++++++++++ src/agents/prompt-composition.test.ts | 66 ++ 6 files changed, 773 insertions(+), 10 deletions(-) create mode 100644 src/agents/prompt-composition-scenarios.ts create mode 100644 src/agents/prompt-composition.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf095c0ebf5..662d3fc6d13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,7 @@ Docs: https://docs.openclaw.ai - ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime. - Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing. - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. +- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. ## 2026.3.13 diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts index a4d65cc964c..1d52e47437b 100644 --- a/src/agents/bootstrap-budget.test.ts +++ b/src/agents/bootstrap-budget.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + appendBootstrapPromptWarning, analyzeBootstrapBudget, buildBootstrapInjectionStats, buildBootstrapPromptWarning, @@ -106,29 +107,31 @@ describe("analyzeBootstrapBudget", () => { }); describe("bootstrap prompt warnings", () => { - it("prepends warning details to the turn prompt instead of mutating the system prompt", () => { - const prompt = prependBootstrapPromptWarning("Please continue.", [ + it("appends warning details to the turn prompt instead of mutating the system prompt", () => { + const prompt = appendBootstrapPromptWarning("Please continue.", [ "AGENTS.md: 200 raw -> 0 injected", ]); + expect(prompt.startsWith("Please continue.")).toBe(true); expect(prompt).toContain("[Bootstrap truncation warning]"); expect(prompt).toContain("Treat Project Context as partial"); expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected"); - expect(prompt).toContain("Please continue."); + expect(prompt.endsWith("- AGENTS.md: 200 raw -> 0 injected")).toBe(true); }); - it("preserves raw prompt whitespace when prepending warning details", () => { - const prompt = prependBootstrapPromptWarning(" indented\nkeep tail ", [ + it("preserves raw prompt whitespace when appending warning details", () => { + const prompt = appendBootstrapPromptWarning(" indented\nkeep tail ", [ "AGENTS.md: 200 raw -> 0 injected", ]); - expect(prompt.endsWith(" indented\nkeep tail ")).toBe(true); + expect(prompt).toContain(" indented\nkeep tail "); + expect(prompt.indexOf(" indented\nkeep tail ")).toBe(0); }); - it("preserves exact heartbeat prompts without warning prefixes", () => { + it("preserves exact heartbeat prompts without warning suffixes", () => { const heartbeatPrompt = "Read HEARTBEAT.md. Reply HEARTBEAT_OK."; expect( - prependBootstrapPromptWarning(heartbeatPrompt, ["AGENTS.md: 200 raw -> 0 injected"], { + appendBootstrapPromptWarning(heartbeatPrompt, ["AGENTS.md: 200 raw -> 0 injected"], { preserveExactPrompt: heartbeatPrompt, }), ).toBe(heartbeatPrompt); diff --git a/src/agents/bootstrap-budget.ts b/src/agents/bootstrap-budget.ts index 4d5c3ff6f58..060541925b3 100644 --- a/src/agents/bootstrap-budget.ts +++ b/src/agents/bootstrap-budget.ts @@ -330,7 +330,7 @@ export function buildBootstrapPromptWarning(params: { }; } -export function prependBootstrapPromptWarning( +export function appendBootstrapPromptWarning( prompt: string, warningLines?: string[], options?: { @@ -350,9 +350,12 @@ export function prependBootstrapPromptWarning( "Treat Project Context as partial and read the relevant files directly if details seem missing.", ...normalizedLines.map((line) => `- ${line}`), ].join("\n"); - return prompt ? `${warningBlock}\n\n${prompt}` : warningBlock; + return prompt ? `${prompt}\n\n${warningBlock}` : warningBlock; } +// Backward-compatible alias while older callers still import the prepend name. +export const prependBootstrapPromptWarning = appendBootstrapPromptWarning; + export function buildBootstrapTruncationReportMeta(params: { analysis: BootstrapBudgetAnalysis; warningMode: BootstrapPromptWarningMode; diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index ec85037aefb..d18966af421 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; +import { appendBootstrapPromptWarning } from "../../bootstrap-budget.js"; import { resolveOllamaBaseUrlForRun } from "../../ollama-stream.js"; +import { buildAgentSystemPrompt } from "../../system-prompt.js"; import { buildAfterTurnRuntimeContext, composeSystemPromptWithHookContext, @@ -162,6 +164,42 @@ describe("composeSystemPromptWithHookContext", () => { }), ).toBe("append only"); }); + + it("keeps hook-composed system prompt stable when bootstrap warnings only change the user prompt", () => { + const baseSystemPrompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + contextFiles: [{ path: "AGENTS.md", content: "Follow AGENTS guidance." }], + toolNames: ["read"], + }); + const composedSystemPrompt = composeSystemPromptWithHookContext({ + baseSystemPrompt, + appendSystemContext: "hook system context", + }); + const turns = [ + { + systemPrompt: composedSystemPrompt, + prompt: appendBootstrapPromptWarning("hello", ["AGENTS.md: 200 raw -> 0 injected"]), + }, + { + systemPrompt: composedSystemPrompt, + prompt: appendBootstrapPromptWarning("hello again", []), + }, + { + systemPrompt: composedSystemPrompt, + prompt: appendBootstrapPromptWarning("hello once more", [ + "AGENTS.md: 200 raw -> 0 injected", + ]), + }, + ]; + + expect(turns[0]?.systemPrompt).toBe(turns[1]?.systemPrompt); + expect(turns[1]?.systemPrompt).toBe(turns[2]?.systemPrompt); + expect(turns[0]?.prompt.startsWith("hello")).toBe(true); + expect(turns[1]?.prompt).toBe("hello again"); + expect(turns[2]?.prompt.startsWith("hello once more")).toBe(true); + expect(turns[0]?.prompt).toContain("[Bootstrap truncation warning]"); + expect(turns[2]?.prompt).toContain("[Bootstrap truncation warning]"); + }); }); describe("resolvePromptModeForSession", () => { diff --git a/src/agents/prompt-composition-scenarios.ts b/src/agents/prompt-composition-scenarios.ts new file mode 100644 index 00000000000..052811d6614 --- /dev/null +++ b/src/agents/prompt-composition-scenarios.ts @@ -0,0 +1,652 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + buildInboundMetaSystemPrompt, + buildInboundUserContextPrefix, +} from "../auto-reply/reply/inbound-meta.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; +import { + appendBootstrapPromptWarning, + analyzeBootstrapBudget, + buildBootstrapPromptWarning, + type BootstrapBudgetAnalysis, +} from "./bootstrap-budget.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; +import { buildToolSummaryMap } from "./tool-summaries.js"; + +export type PromptScenarioTurn = { + id: string; + label: string; + systemPrompt: string; + bodyPrompt: string; + notes: string[]; +}; + +export type PromptScenario = { + scenario: string; + focus: string; + expectedStableSystemAfterTurnIds: string[]; + turns: PromptScenarioTurn[]; +}; + +type TemplateCtx = { + Provider: string; + Surface?: string; + OriginatingChannel?: string; + OriginatingTo?: string; + AccountId?: string; + ChatType?: string; + GroupSubject?: string; + GroupChannel?: string; + GroupSpace?: string; + SenderId?: string; + SenderName?: string; + SenderUsername?: string; + SenderE164?: string; + MessageSid?: string; + ReplyToId?: string; + ReplyToBody?: string; + WasMentioned?: boolean; + InboundHistory?: Array<{ sender: string; timestamp: number; body: string }>; + Body?: string; + BodyStripped?: string; +}; + +type BootstrapInjectionStat = { + name: string; + path: string; + missing: boolean; + rawChars: number; + injectedChars: number; + truncated: boolean; +}; + +function buildCommonSystemParams(workspaceDir: string) { + const toolNames = [ + "bash", + "read", + "edit", + "grep", + "glob", + "message", + "memory_search", + "memory_get", + "web_search", + "web_fetch", + ]; + const toolSummaries = buildToolSummaryMap( + toolNames.map((name) => ({ name, description: `${name} tool` }) as never), + ); + return { + runtimeInfo: { + agentId: "main", + host: "cache-lab", + repoRoot: workspaceDir, + os: "Darwin 24.0.0", + arch: "arm64", + node: process.version, + model: "anthropic/claude-sonnet-4-5", + defaultModel: "anthropic/claude-sonnet-4-5", + shell: "zsh", + }, + userTimezone: "America/Los_Angeles", + userTime: "Monday, March 16th, 2026 — 9:00 PM", + userTimeFormat: "12" as const, + toolNames, + toolSummaries, + }; +} + +function buildSystemPrompt(params: { + workspaceDir: string; + extraSystemPrompt?: string; + skillsPrompt?: string; + reactionGuidance?: { level: "minimal" | "extensive"; channel: string }; + contextFiles?: Array<{ path: string; content: string }>; +}) { + const { runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames, toolSummaries } = + buildCommonSystemParams(params.workspaceDir); + return buildAgentSystemPrompt({ + workspaceDir: params.workspaceDir, + extraSystemPrompt: params.extraSystemPrompt, + runtimeInfo, + userTimezone, + userTime, + userTimeFormat, + toolNames, + toolSummaries, + modelAliasLines: [], + promptMode: "full", + acpEnabled: true, + skillsPrompt: params.skillsPrompt, + reactionGuidance: params.reactionGuidance, + contextFiles: params.contextFiles, + }); +} + +function buildAutoReplyBody(params: { ctx: TemplateCtx; body: string; eventLine?: string }) { + return [params.eventLine, buildInboundUserContextPrefix(params.ctx as never), params.body] + .filter(Boolean) + .join("\n\n"); +} + +function createDirectScenario(workspaceDir: string): PromptScenario { + const baseCtx: TemplateCtx = { + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "D123", + AccountId: "A1", + ChatType: "direct", + SenderId: "U1", + SenderName: "Alice", + Body: "hi", + BodyStripped: "hi", + }; + return { + scenario: "auto-reply-direct", + focus: + "Normal direct-chat turns with ids, reply context, think hint, and runtime event body injection", + expectedStableSystemAfterTurnIds: ["t2", "t3", "t4"], + turns: [ + { + id: "t1", + label: "Direct turn with reply context", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m1", + ReplyToId: "r1", + ReplyToBody: "prior message", + WasMentioned: true, + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m1", + ReplyToId: "r1", + ReplyToBody: "prior message", + WasMentioned: true, + }, + body: "Please summarize yesterday's decision.", + }), + notes: ["Direct chat baseline", "Per-message ids and reply context change in body only"], + }, + { + id: "t2", + label: "Direct turn with new message id", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m2", + ReplyToId: "r2", + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m2", + ReplyToId: "r2", + }, + body: "Now open the read tool and inspect AGENTS.md.", + }), + notes: ["Steady-state direct turn", "No runtime event"], + }, + { + id: "t3", + label: "Direct turn with runtime event and think hint", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m3", + ReplyToId: "r3", + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m3", + ReplyToId: "r3", + }, + eventLine: "System: [t] Model switched.", + body: "low use tools if needed and tell me which file controls startup behavior", + }), + notes: ["Touches runtime event body path", "Touches think-hint parsing path"], + }, + { + id: "t4", + label: "Direct turn after runtime event", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m4", + ReplyToId: "r4", + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m4", + ReplyToId: "r4", + }, + body: "Repeat the startup file path only.", + }), + notes: ["Checks steady-state after event turn"], + }, + ], + }; +} + +function createGroupScenario(workspaceDir: string): PromptScenario { + const baseCtx: TemplateCtx = { + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "C123", + AccountId: "A1", + ChatType: "group", + GroupSubject: "ops", + GroupChannel: "#ops", + SenderId: "U2", + SenderName: "Bob", + Body: "hi", + BodyStripped: "hi", + }; + const inbound1 = buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "g1", + WasMentioned: true, + InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }], + } as never); + const inboundLater = buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "g2", + WasMentioned: false, + InboundHistory: [ + { sender: "Cara", timestamp: 1, body: "status?" }, + { sender: "Dan", timestamp: 2, body: "please help" }, + ], + } as never); + return { + scenario: "auto-reply-group", + focus: "Group chat bootstrap, steady state, and runtime event turns", + expectedStableSystemAfterTurnIds: ["t3"], + turns: [ + { + id: "t1", + label: "First group turn with one-time intro", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: [inbound1, "GROUP_INTRO: You were just activated in this room."].join( + "\n\n", + ), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "g1", + WasMentioned: true, + InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }], + }, + body: "Can you investigate this issue?", + }), + notes: ["Expected first-turn bootstrap churn", "Not steady-state"], + }, + { + id: "t2", + label: "Steady-state group turn", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: inboundLater, + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "g2", + WasMentioned: false, + InboundHistory: [ + { sender: "Cara", timestamp: 1, body: "status?" }, + { sender: "Dan", timestamp: 2, body: "please help" }, + ], + }, + body: "Give a short update.", + }), + notes: ["One-time intro gone", "Should settle afterward"], + }, + { + id: "t3", + label: "Group turn with runtime event", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: inboundLater, + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "g3", + WasMentioned: true, + InboundHistory: [ + { sender: "Cara", timestamp: 1, body: "status?" }, + { sender: "Dan", timestamp: 2, body: "please help" }, + { sender: "Eve", timestamp: 3, body: "what changed?" }, + ], + }, + eventLine: "System: [t] Node connected.", + body: "Tell the room whether tools are available.", + }), + notes: ["Runtime event lands in body", "System prompt should stay stable vs t2"], + }, + ], + }; +} + +async function createToolRichScenario(workspaceDir: string): Promise { + const skillsPrompt = [ + "", + "checksRun checks before landing changes./skills/checks/SKILL.md", + "releaseRelease OpenClaw safely./skills/release/SKILL.md", + "", + ].join("\n"); + const contextFiles = [ + { + path: "AGENTS.md", + content: await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf-8"), + }, + { + path: "TOOLS.md", + content: await fs.readFile(path.join(workspaceDir, "TOOLS.md"), "utf-8"), + }, + { + path: "SOUL.md", + content: await fs.readFile(path.join(workspaceDir, "SOUL.md"), "utf-8"), + }, + ]; + const systemPrompt = buildSystemPrompt({ + workspaceDir, + skillsPrompt, + reactionGuidance: { level: "extensive", channel: "Telegram" }, + contextFiles, + }); + return { + scenario: "tool-rich-agent-run", + focus: + "Tool-enabled system prompt with skills, reactions, workspace bootstrap, and a follow-up after fictional tool calls", + expectedStableSystemAfterTurnIds: ["t2"], + turns: [ + { + id: "t1", + label: "Tool-rich turn asking for search, read, and file edits", + systemPrompt, + bodyPrompt: [ + "Conversation info (untrusted metadata):", + "```json", + JSON.stringify({ message_id: "tool-1", sender_id: "U9", was_mentioned: true }, null, 2), + "```", + "", + "high Search the workspace, read AGENTS.md, inspect the failing test, and propose a patch.", + ].join("\n"), + notes: ["Touches tool list in system prompt", "Touches high-thinking hint in body"], + }, + { + id: "t2", + label: "Follow-up after a fictional tool call", + systemPrompt, + bodyPrompt: [ + "Conversation info (untrusted metadata):", + "```json", + JSON.stringify({ message_id: "tool-2", sender_id: "U9" }, null, 2), + "```", + "", + "Tool transcript summary (untrusted, for context):", + "```json", + JSON.stringify( + [ + { role: "assistant", action: "tool_use", name: "read", target: "AGENTS.md" }, + { role: "tool", name: "read", result: "Loaded AGENTS.md" }, + { role: "assistant", action: "tool_use", name: "grep", target: "failing test" }, + { role: "tool", name: "grep", result: "Matched src/foo.ts:42" }, + ], + null, + 2, + ), + "```", + "", + "Continue and explain the root cause.", + ].join("\n"), + notes: ["Simulates tool-call-heavy conversation", "System prompt should stay stable"], + }, + ], + }; +} + +async function createBootstrapWarningScenario(workspaceDir: string): Promise { + const largeAgents = "# AGENTS.md\n\n" + "Rules.\n".repeat(5_000); + const largeTools = "# TOOLS.md\n\n" + "Notes.\n".repeat(3_000); + await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: largeAgents }); + await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: largeTools }); + const contextFiles = [ + { + path: "AGENTS.md", + content: await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf-8"), + }, + { + path: "TOOLS.md", + content: await fs.readFile(path.join(workspaceDir, "TOOLS.md"), "utf-8"), + }, + ]; + const bootstrapStats: BootstrapInjectionStat[] = contextFiles.map((file, index) => ({ + name: path.basename(file.path), + path: file.path, + missing: false, + rawChars: file.content.length, + injectedChars: index === 0 ? 1500 : 700, + truncated: true, + })); + const analysis = analyzeBootstrapBudget({ + files: bootstrapStats, + bootstrapMaxChars: 1500, + bootstrapTotalMaxChars: 2200, + }); + const warningFirst = buildBootstrapPromptWarning({ + analysis, + mode: "once", + seenSignatures: [], + }); + const warningSeen = buildBootstrapPromptWarning({ + analysis, + mode: "once", + seenSignatures: warningFirst.warningSignaturesSeen, + previousSignature: warningFirst.signature, + }); + const warningAlways = buildBootstrapPromptWarning({ + analysis, + mode: "always", + seenSignatures: warningFirst.warningSignaturesSeen, + previousSignature: warningFirst.signature, + }); + return { + scenario: "bootstrap-warning", + focus: "Workspace bootstrap truncation warnings inside # Project Context", + expectedStableSystemAfterTurnIds: ["t2", "t3"], + turns: [ + { + id: "t1", + label: "First warning emission", + systemPrompt: buildSystemPrompt({ + workspaceDir, + contextFiles, + }), + bodyPrompt: appendBootstrapPromptWarning("hello", warningFirst.lines), + notes: ["Warning is appended to the turn body", "System prompt should stay stable"], + }, + { + id: "t2", + label: "Same truncation signature after once-mode dedupe", + systemPrompt: buildSystemPrompt({ + workspaceDir, + contextFiles, + }), + bodyPrompt: appendBootstrapPromptWarning("hello again", warningSeen.lines), + notes: ["Once-mode removes warning lines", "Only the body tail changes now"], + }, + { + id: "t3", + label: "Always-mode warning", + systemPrompt: buildSystemPrompt({ + workspaceDir, + contextFiles, + }), + bodyPrompt: appendBootstrapPromptWarning("one more turn", warningAlways.lines), + notes: [ + "Always-mode keeps warning in the body prompt tail", + "System prompt remains stable", + ], + }, + ], + }; +} + +async function createMaintenanceScenario(workspaceDir: string): Promise { + await writeWorkspaceFile({ + dir: workspaceDir, + name: "AGENTS.md", + content: [ + "## Session Startup", + "Read AGENTS.md and MEMORY.md before responding.", + "", + "## Red Lines", + "Do not delete production data.", + "", + "## Safety", + "Never reveal secrets.", + ].join("\n"), + }); + const memoryFlushPrompt = [ + "Pre-compaction memory flush.", + "Store durable memories only in memory/2026-03-15.md (create memory/ if needed).", + "Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.", + "If nothing to store, reply with NO_REPLY.", + "Current time: Sunday, March 15th, 2026 — 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC", + ].join("\n"); + const memoryFlushSystemPrompt = buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: [ + "Pre-compaction memory flush turn.", + "The session is near auto-compaction; capture durable memories to disk.", + "Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).", + "You may reply, but usually NO_REPLY is correct.", + ].join(" "), + }); + const postCompaction = [ + "[Post-compaction context refresh]", + "", + "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence.", + "", + "Critical rules from AGENTS.md:", + "", + "## Session Startup", + "Read AGENTS.md and MEMORY.md before responding.", + "", + "## Red Lines", + "Do not delete production data.", + "", + "Current time: Sunday, March 15th, 2026 — 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC", + ].join("\n"); + const postCompactionSystemPrompt = buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "D123", + AccountId: "A1", + ChatType: "direct", + } as never), + }); + return { + scenario: "maintenance-prompts", + focus: "Memory flush and post-compaction maintenance prompts", + expectedStableSystemAfterTurnIds: [], + turns: [ + { + id: "t1", + label: "Pre-compaction memory flush run", + systemPrompt: memoryFlushSystemPrompt, + bodyPrompt: memoryFlushPrompt, + notes: [ + "Writes to memory/2026-03-15.md", + "Separate maintenance run; expected to differ from normal user turns", + ], + }, + { + id: "t2", + label: "Post-compaction refresh context run", + systemPrompt: postCompactionSystemPrompt, + bodyPrompt: postCompaction, + notes: [ + "Separate maintenance context payload", + "Expected to differ from normal user turns", + ], + }, + ], + }; +} + +export async function createWorkspaceWithPromptCompositionFiles(): Promise { + const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-"); + await writeWorkspaceFile({ + dir: workspaceDir, + name: "AGENTS.md", + content: [ + "# AGENTS.md", + "", + "## Session Startup", + "Read AGENTS.md and TOOLS.md before making changes.", + "", + "## Red Lines", + "Do not rewrite user commits.", + ].join("\n"), + }); + await writeWorkspaceFile({ + dir: workspaceDir, + name: "TOOLS.md", + content: "# TOOLS.md\n\nUse rg before grep.\n", + }); + await writeWorkspaceFile({ + dir: workspaceDir, + name: "SOUL.md", + content: "# SOUL.md\n\nBe concise but kind.\n", + }); + return workspaceDir; +} + +export async function createPromptCompositionScenarios(): Promise<{ + workspaceDir: string; + warningWorkspaceDir: string; + scenarios: PromptScenario[]; + cleanup: () => Promise; +}> { + const workspaceDir = await createWorkspaceWithPromptCompositionFiles(); + const warningWorkspaceDir = await makeTempWorkspace("openclaw-prompt-cache-warning-"); + const scenarios = [ + createDirectScenario(workspaceDir), + createGroupScenario(workspaceDir), + await createToolRichScenario(workspaceDir), + await createBootstrapWarningScenario(warningWorkspaceDir), + await createMaintenanceScenario(workspaceDir), + ]; + return { + workspaceDir, + warningWorkspaceDir, + scenarios, + cleanup: async () => { + await fs.rm(workspaceDir, { recursive: true, force: true }); + await fs.rm(warningWorkspaceDir, { recursive: true, force: true }); + }, + }; +} diff --git a/src/agents/prompt-composition.test.ts b/src/agents/prompt-composition.test.ts new file mode 100644 index 00000000000..ee0a3fa4655 --- /dev/null +++ b/src/agents/prompt-composition.test.ts @@ -0,0 +1,66 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createPromptCompositionScenarios, + type PromptScenario, +} from "./prompt-composition-scenarios.js"; + +type ScenarioFixture = Awaited>; + +function getTurn(scenario: PromptScenario, id: string) { + const turn = scenario.turns.find((entry) => entry.id === id); + expect(turn, `${scenario.scenario}:${id}`).toBeDefined(); + return turn!; +} + +describe("prompt composition invariants", () => { + let fixture: ScenarioFixture; + + beforeAll(async () => { + fixture = await createPromptCompositionScenarios(); + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + it("keeps the system prompt stable after warmup for normal user-turn scenarios", () => { + for (const scenario of fixture.scenarios) { + if (scenario.expectedStableSystemAfterTurnIds.length === 0) { + continue; + } + for (const turnId of scenario.expectedStableSystemAfterTurnIds) { + const current = getTurn(scenario, turnId); + const index = scenario.turns.findIndex((entry) => entry.id === turnId); + const previous = scenario.turns[index - 1]; + expect(previous, `${scenario.scenario}:${turnId}:previous`).toBeDefined(); + expect(current.systemPrompt, `${scenario.scenario}:${turnId}`).toBe(previous.systemPrompt); + } + } + }); + + it("keeps bootstrap warnings out of the system prompt and preserves the original user prompt prefix", () => { + const scenario = fixture.scenarios.find((entry) => entry.scenario === "bootstrap-warning"); + expect(scenario).toBeDefined(); + const first = getTurn(scenario!, "t1"); + const deduped = getTurn(scenario!, "t2"); + const always = getTurn(scenario!, "t3"); + + expect(first.systemPrompt).not.toContain("[Bootstrap truncation warning]"); + expect(first.bodyPrompt.startsWith("hello")).toBe(true); + expect(first.bodyPrompt).toContain("[Bootstrap truncation warning]"); + + expect(deduped.bodyPrompt).toBe("hello again"); + expect(always.bodyPrompt.startsWith("one more turn")).toBe(true); + expect(always.bodyPrompt).toContain("[Bootstrap truncation warning]"); + }); + + it("documents the intentional global exceptions so future churn is explicit", () => { + const groupScenario = fixture.scenarios.find((entry) => entry.scenario === "auto-reply-group"); + const maintenanceScenario = fixture.scenarios.find( + (entry) => entry.scenario === "maintenance-prompts", + ); + + expect(groupScenario?.expectedStableSystemAfterTurnIds).toEqual(["t3"]); + expect(maintenanceScenario?.expectedStableSystemAfterTurnIds).toEqual([]); + }); +}); From a14ad01d66f8c2ed62dad23e9de6f443c0027968 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:47:45 +0000 Subject: [PATCH 152/393] Plugin SDK: centralize message tool discovery and context --- src/agents/channel-tools.ts | 46 ++- src/agents/openclaw-tools.ts | 1 + src/agents/pi-embedded-runner/compact.ts | 12 +- src/agents/pi-embedded-runner/run/attempt.ts | 7 + src/agents/tools/message-tool.test.ts | 177 +++++++- src/agents/tools/message-tool.ts | 379 +++++------------- src/channels/plugins/message-actions.ts | 145 ++++++- src/channels/plugins/message-tool-schema.ts | 161 ++++++++ src/channels/plugins/types.core.ts | 52 ++- src/channels/plugins/types.ts | 2 + ...sage-action-runner.plugin-dispatch.test.ts | 44 ++ src/infra/outbound/message-action-runner.ts | 7 +- src/plugin-sdk/channel-runtime.ts | 1 + 13 files changed, 737 insertions(+), 297 deletions(-) create mode 100644 src/channels/plugins/message-tool-schema.ts diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 242cce868c1..4e2d028e91a 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -15,6 +15,14 @@ import { defaultRuntime } from "../runtime.js"; export function listChannelSupportedActions(params: { cfg?: OpenClawConfig; channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageActionName[] { if (!params.channel) { return []; @@ -24,7 +32,18 @@ export function listChannelSupportedActions(params: { return []; } const cfg = params.cfg ?? ({} as OpenClawConfig); - return runPluginListActions(plugin, cfg); + return runPluginListActions(plugin, { + cfg, + currentChannelId: params.currentChannelId, + currentChannelProvider: params.channel, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }); } /** @@ -32,6 +51,14 @@ export function listChannelSupportedActions(params: { */ export function listAllChannelSupportedActions(params: { cfg?: OpenClawConfig; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageActionName[] { const actions = new Set(); for (const plugin of listChannelPlugins()) { @@ -39,7 +66,18 @@ export function listAllChannelSupportedActions(params: { continue; } const cfg = params.cfg ?? ({} as OpenClawConfig); - const channelActions = runPluginListActions(plugin, cfg); + const channelActions = runPluginListActions(plugin, { + cfg, + currentChannelId: params.currentChannelId, + currentChannelProvider: plugin.id, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }); for (const action of channelActions) { actions.add(action); } @@ -86,13 +124,13 @@ const loggedListActionErrors = new Set(); function runPluginListActions( plugin: ChannelPlugin, - cfg: OpenClawConfig, + context: Parameters["listActions"]>>[0], ): ChannelMessageActionName[] { if (!plugin.actions?.listActions) { return []; } try { - const listed = plugin.actions.listActions({ cfg }); + const listed = plugin.actions.listActions(context); return Array.isArray(listed) ? listed : []; } catch (err) { logListActionsError(plugin.id, err); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 6f4929d288a..de5e91fdf0c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -135,6 +135,7 @@ export function createOpenClawTools( : createMessageTool({ agentAccountId: options?.agentAccountId, agentSessionKey: options?.agentSessionKey, + sessionId: options?.sessionId, config: options?.config, currentChannelId: options?.currentChannelId, currentChannelProvider: options?.agentChannel, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 4e967730667..7893f51b70c 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -649,11 +649,19 @@ export async function compactEmbeddedPiSessionDirect( return undefined; })() : undefined; + const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel ? listChannelSupportedActions({ cfg: params.config, channel: runtimeChannel, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, }) : undefined; const messageToolHints = runtimeChannel @@ -680,10 +688,6 @@ export async function compactEmbeddedPiSessionDirect( const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); - const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.config, - }); const isDefaultAgent = sessionAgentId === defaultAgentId; const promptMode = isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 73b7d0fbff6..0fa03797a60 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1623,6 +1623,13 @@ export async function runEmbeddedAttempt( ? listChannelSupportedActions({ cfg: params.config, channel: runtimeChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, }) : undefined; const messageToolHints = runtimeChannel diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 88062eacaa7..2693e7fdf19 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1,10 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; +import { + createDiscordMessageToolComponentsSchema, + createMessageToolButtonsSchema, + createSlackMessageToolBlocksSchema, + createTelegramPollExtraToolSchemas, +} from "../../channels/plugins/message-tool-schema.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { createMessageTool } from "./message-tool.js"; +type CreateMessageTool = typeof import("./message-tool.js").createMessageTool; +type SetActivePluginRegistry = typeof import("../../plugins/runtime.js").setActivePluginRegistry; +type CreateTestRegistry = typeof import("../../test-utils/channel-plugins.js").createTestRegistry; + +let createMessageTool: CreateMessageTool; +let setActivePluginRegistry: SetActivePluginRegistry; +let createTestRegistry: CreateTestRegistry; const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), @@ -50,7 +60,7 @@ function mockSendResult(overrides: { channel?: string; to?: string } = {}) { } satisfies MessageActionRunResult); } -function getToolProperties(tool: ReturnType) { +function getToolProperties(tool: ReturnType) { return (tool.parameters as { properties?: Record }).properties ?? {}; } @@ -58,13 +68,17 @@ function getActionEnum(properties: Record) { return (properties.action as { enum?: string[] } | undefined)?.enum ?? []; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); mocks.runMessageAction.mockReset(); mocks.loadConfig.mockReset().mockReturnValue({}); mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ resolvedConfig: config, diagnostics: [], })); + ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); + ({ createTestRegistry } = await import("../../test-utils/channel-plugins.js")); + ({ createMessageTool } = await import("./message-tool.js")); }); function createChannelPlugin(params: { @@ -75,6 +89,7 @@ function createChannelPlugin(params: { actions?: ChannelMessageActionName[]; listActions?: NonNullable["listActions"]>; capabilities?: readonly ChannelMessageCapability[]; + toolSchema?: NonNullable["getToolSchema"]>; messaging?: ChannelPlugin["messaging"]; }): ChannelPlugin { const actionCapabilities = params.capabilities; @@ -102,6 +117,7 @@ function createChannelPlugin(params: { ...(actionCapabilities ? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities } : {}), + ...(params.toolSchema ? { getToolSchema: params.toolSchema } : {}), }, }; } @@ -219,6 +235,17 @@ describe("message tool schema scoping", () => { blurb: "Telegram test plugin.", actions: ["send", "react", "poll"], capabilities: ["interactive", "buttons"], + toolSchema: () => [ + { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }, + { + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured", + }, + ], }); const discordPlugin = createChannelPlugin({ @@ -228,6 +255,11 @@ describe("message tool schema scoping", () => { blurb: "Discord test plugin.", actions: ["send", "poll", "poll-vote"], capabilities: ["interactive", "components"], + toolSchema: () => ({ + properties: { + components: createDiscordMessageToolComponentsSchema(), + }, + }), }); const slackPlugin = createChannelPlugin({ @@ -237,6 +269,11 @@ describe("message tool schema scoping", () => { blurb: "Slack test plugin.", actions: ["send", "react"], capabilities: ["interactive", "blocks"], + toolSchema: () => ({ + properties: { + blocks: createSlackMessageToolBlocksSchema(), + }, + }), }); afterEach(() => { @@ -365,6 +402,25 @@ describe("message tool schema scoping", () => { return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"]; }, capabilities: ["interactive", "buttons"], + toolSchema: ({ cfg }) => { + const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } }) + .channels?.telegram; + return [ + { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }, + ...(telegramCfg?.actions?.poll === false + ? [] + : [ + { + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured" as const, + }, + ]), + ]; + }, }); setActivePluginRegistry( @@ -393,6 +449,95 @@ describe("message tool schema scoping", () => { expect(properties.pollAnonymous).toBeUndefined(); expect(properties.pollPublic).toBeUndefined(); }); + + it("uses discovery account scope for capability-gated shared fields", () => { + const scopedInteractivePlugin = createChannelPlugin({ + id: "telegram", + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + actions: ["send"], + toolSchema: () => null, + }); + scopedInteractivePlugin.actions = { + ...scopedInteractivePlugin.actions, + getCapabilities: ({ accountId }) => (accountId === "ops" ? ["interactive"] : []), + }; + + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "telegram", source: "test", plugin: scopedInteractivePlugin }, + ]), + ); + + const scopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + agentAccountId: "ops", + }); + const unscopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + }); + + expect(getToolProperties(scopedTool).interactive).toBeDefined(); + expect(getToolProperties(unscopedTool).interactive).toBeUndefined(); + }); + + it("routes full discovery context into plugin action discovery", () => { + const seenContexts: Record[] = []; + const contextPlugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord context plugin.", + listActions: (ctx) => { + seenContexts.push({ phase: "listActions", ...ctx }); + return ["send", "react"]; + }, + toolSchema: (ctx) => { + seenContexts.push({ phase: "getToolSchema", ...ctx }); + return null; + }, + }); + contextPlugin.actions = { + ...contextPlugin.actions, + getCapabilities: (ctx) => { + seenContexts.push({ phase: "getCapabilities", ...ctx }); + return ["interactive"]; + }, + }; + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: contextPlugin }]), + ); + + createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + currentChannelId: "channel:123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + agentAccountId: "ops", + agentSessionKey: "agent:alpha:main", + sessionId: "session-123", + requesterSenderId: "user-42", + }); + + expect(seenContexts).toContainEqual( + expect.objectContaining({ + currentChannelProvider: "discord", + currentChannelId: "channel:123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + accountId: "ops", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + requesterSenderId: "user-42", + }), + ); + }); }); describe("message tool description", () => { @@ -405,7 +550,27 @@ describe("message tool description", () => { label: "BlueBubbles", docsPath: "/channels/bluebubbles", blurb: "BlueBubbles test plugin.", - actions: ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"], + listActions: ({ currentChannelId }) => { + const all: ChannelMessageActionName[] = [ + "react", + "renameGroup", + "addParticipant", + "removeParticipant", + "leaveGroup", + ]; + const lowered = currentChannelId?.toLowerCase() ?? ""; + const isDmTarget = + lowered.includes("chat_guid:imessage;-;") || lowered.includes("chat_guid:sms;-;"); + return isDmTarget + ? all.filter( + (action) => + action !== "renameGroup" && + action !== "addParticipant" && + action !== "removeParticipant" && + action !== "leaveGroup", + ) + : all; + }, messaging: { normalizeTarget: (raw) => { const trimmed = raw.trim().replace(/^bluebubbles:/i, ""); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 1dcaf04e1f0..f5428519f81 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,10 +1,10 @@ import { Type } from "@sinclair/typebox"; -import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { channelSupportsMessageCapability, channelSupportsMessageCapabilityForChannel, listChannelMessageActions, + resolveChannelMessageToolSchemaProperties, } from "../../channels/plugins/message-actions.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import { @@ -18,7 +18,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; -import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; @@ -53,116 +52,6 @@ function buildRoutingSchema() { }; } -const discordComponentEmojiSchema = Type.Object({ - name: Type.String(), - id: Type.Optional(Type.String()), - animated: Type.Optional(Type.Boolean()), -}); - -const discordComponentOptionSchema = Type.Object({ - label: Type.String(), - value: Type.String(), - description: Type.Optional(Type.String()), - emoji: Type.Optional(discordComponentEmojiSchema), - default: Type.Optional(Type.Boolean()), -}); - -const discordComponentButtonSchema = Type.Object({ - label: Type.String(), - style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), - url: Type.Optional(Type.String()), - emoji: Type.Optional(discordComponentEmojiSchema), - disabled: Type.Optional(Type.Boolean()), - allowedUsers: Type.Optional( - Type.Array( - Type.String({ - description: "Discord user ids or names allowed to interact with this button.", - }), - ), - ), -}); - -const discordComponentSelectSchema = Type.Object({ - type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), - placeholder: Type.Optional(Type.String()), - minValues: Type.Optional(Type.Number()), - maxValues: Type.Optional(Type.Number()), - options: Type.Optional(Type.Array(discordComponentOptionSchema)), -}); - -const discordComponentBlockSchema = Type.Object({ - type: Type.String(), - text: Type.Optional(Type.String()), - texts: Type.Optional(Type.Array(Type.String())), - accessory: Type.Optional( - Type.Object({ - type: Type.String(), - url: Type.Optional(Type.String()), - button: Type.Optional(discordComponentButtonSchema), - }), - ), - spacing: Type.Optional(stringEnum(["small", "large"])), - divider: Type.Optional(Type.Boolean()), - buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), - select: Type.Optional(discordComponentSelectSchema), - items: Type.Optional( - Type.Array( - Type.Object({ - url: Type.String(), - description: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), - }), - ), - ), - file: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), -}); - -const discordComponentModalFieldSchema = Type.Object({ - type: Type.String(), - name: Type.Optional(Type.String()), - label: Type.String(), - description: Type.Optional(Type.String()), - placeholder: Type.Optional(Type.String()), - required: Type.Optional(Type.Boolean()), - options: Type.Optional(Type.Array(discordComponentOptionSchema)), - minValues: Type.Optional(Type.Number()), - maxValues: Type.Optional(Type.Number()), - minLength: Type.Optional(Type.Number()), - maxLength: Type.Optional(Type.Number()), - style: Type.Optional(stringEnum(["short", "paragraph"])), -}); - -const discordComponentModalSchema = Type.Object({ - title: Type.String(), - triggerLabel: Type.Optional(Type.String()), - triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), - fields: Type.Array(discordComponentModalFieldSchema), -}); - -const discordComponentMessageSchema = Type.Object( - { - text: Type.Optional(Type.String()), - reusable: Type.Optional( - Type.Boolean({ - description: "Allow components to be used multiple times until they expire.", - }), - ), - container: Type.Optional( - Type.Object({ - accentColor: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), - }), - ), - blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), - modal: Type.Optional(discordComponentModalSchema), - }, - { - description: - "Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.", - }, -); - const interactiveOptionSchema = Type.Object({ label: Type.String(), value: Type.String(), @@ -192,13 +81,7 @@ const interactiveMessageSchema = Type.Object( }, ); -function buildSendSchema(options: { - includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; -}) { +function buildSendSchema(options: { includeInteractive: boolean }) { const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( @@ -240,57 +123,10 @@ function buildSendSchema(options: { }), ), interactive: Type.Optional(interactiveMessageSchema), - buttons: Type.Optional( - Type.Array( - Type.Array( - Type.Object({ - text: Type.String(), - callback_data: Type.String(), - style: Type.Optional(stringEnum(["danger", "success", "primary"])), - }), - ), - { - description: "Telegram inline keyboard buttons (array of button rows)", - }, - ), - ), - card: Type.Optional( - Type.Object( - {}, - { - additionalProperties: true, - description: "Adaptive Card JSON object (when supported by the channel)", - }, - ), - ), - components: Type.Optional(discordComponentMessageSchema), - blocks: Type.Optional( - Type.Array( - Type.Object( - {}, - { - additionalProperties: true, - description: "Slack Block Kit payload blocks (Slack only).", - }, - ), - ), - ), }; - if (!options.includeButtons) { - delete props.buttons; - } if (!options.includeInteractive) { delete props.interactive; } - if (!options.includeCards) { - delete props.card; - } - if (!options.includeComponents) { - delete props.components; - } - if (!options.includeBlocks) { - delete props.blocks; - } return props; } @@ -330,7 +166,7 @@ function buildFetchSchema() { }; } -function buildPollSchema(options?: { includeTelegramExtras?: boolean }) { +function buildPollSchema() { const props: Record = { pollId: Type.Optional(Type.String()), pollOptionId: Type.Optional( @@ -363,7 +199,7 @@ function buildPollSchema(options?: { includeTelegramExtras?: boolean }) { }; for (const name of POLL_CREATION_PARAM_NAMES) { const def = POLL_CREATION_PARAM_DEFS[name]; - if (def.telegramOnly && !options?.includeTelegramExtras) { + if (def.telegramOnly) { continue; } switch (def.kind) { @@ -510,18 +346,14 @@ function buildChannelManagementSchema() { function buildMessageToolSchemaProps(options: { includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; - includeTelegramPollExtras: boolean; + extraProperties?: Record; }) { return { ...buildRoutingSchema(), ...buildSendSchema(options), ...buildReactionSchema(), ...buildFetchSchema(), - ...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }), + ...buildPollSchema(), ...buildChannelTargetSchema(), ...buildStickerSchema(), ...buildThreadSchema(), @@ -530,6 +362,7 @@ function buildMessageToolSchemaProps(options: { ...buildGatewaySchema(), ...buildChannelManagementSchema(), ...buildPresenceSchema(), + ...options.extraProperties, }; } @@ -537,11 +370,7 @@ function buildMessageToolSchemaFromActions( actions: readonly string[], options: { includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; - includeTelegramPollExtras: boolean; + extraProperties?: Record; }, ) { const props = buildMessageToolSchemaProps(options); @@ -553,16 +382,12 @@ function buildMessageToolSchemaFromActions( const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includeInteractive: true, - includeButtons: true, - includeCards: true, - includeComponents: true, - includeBlocks: true, - includeTelegramPollExtras: true, }); type MessageToolOptions = { agentAccountId?: string; agentSessionKey?: string; + sessionId?: string; config?: OpenClawConfig; currentChannelId?: string; currentChannelProvider?: string; @@ -579,16 +404,27 @@ function resolveMessageToolSchemaActions(params: { cfg: OpenClawConfig; currentChannelProvider?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): string[] { const currentChannel = normalizeMessageChannel(params.currentChannelProvider); if (currentChannel) { - const scopedActions = filterActionsForContext({ - actions: listChannelSupportedActions({ - cfg: params.cfg, - channel: currentChannel, - }), + const scopedActions = listChannelSupportedActions({ + cfg: params.cfg, channel: currentChannel, currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, }); const allActions = new Set(["send", ...scopedActions]); // Include actions from other configured channels so isolated/cron agents @@ -611,6 +447,14 @@ function resolveIncludeCapability( params: { cfg: OpenClawConfig; currentChannelProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }, capability: ChannelMessageCapability, ): boolean { @@ -620,6 +464,14 @@ function resolveIncludeCapability( { cfg: params.cfg, channel: currentChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, }, capability, ); @@ -627,70 +479,50 @@ function resolveIncludeCapability( return channelSupportsMessageCapability(params.cfg, capability); } -function resolveIncludeComponents(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "components"); -} - function resolveIncludeInteractive(params: { cfg: OpenClawConfig; currentChannelProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): boolean { return resolveIncludeCapability(params, "interactive"); } -function resolveIncludeButtons(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "buttons"); -} - -function resolveIncludeCards(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "cards"); -} - -function resolveIncludeBlocks(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "blocks"); -} - -function resolveIncludeTelegramPollExtras(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return listChannelSupportedActions({ - cfg: params.cfg, - channel: "telegram", - }).includes("poll"); -} - function buildMessageToolSchema(params: { cfg: OpenClawConfig; currentChannelProvider?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }) { const actions = resolveMessageToolSchemaActions(params); const includeInteractive = resolveIncludeInteractive(params); - const includeButtons = resolveIncludeButtons(params); - const includeCards = resolveIncludeCards(params); - const includeComponents = resolveIncludeComponents(params); - const includeBlocks = resolveIncludeBlocks(params); - const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params); + const extraProperties = resolveChannelMessageToolSchemaProperties({ + cfg: params.cfg, + channel: normalizeMessageChannel(params.currentChannelProvider), + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }); return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includeInteractive, - includeButtons, - includeCards, - includeComponents, - includeBlocks, - includeTelegramPollExtras, + extraProperties, }); } @@ -702,49 +534,33 @@ function resolveAgentAccountId(value?: string): string | undefined { return normalizeAccountId(trimmed); } -function filterActionsForContext(params: { - actions: ChannelMessageActionName[]; - channel?: string; - currentChannelId?: string; -}): ChannelMessageActionName[] { - const channel = normalizeMessageChannel(params.channel); - if (!channel || channel !== "bluebubbles") { - return params.actions; - } - const currentChannelId = params.currentChannelId?.trim(); - if (!currentChannelId) { - return params.actions; - } - const normalizedTarget = - normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId; - const lowered = normalizedTarget.trim().toLowerCase(); - const isGroupTarget = - lowered.startsWith("chat_guid:") || - lowered.startsWith("chat_id:") || - lowered.startsWith("chat_identifier:") || - lowered.startsWith("group:"); - if (isGroupTarget) { - return params.actions; - } - return params.actions.filter((action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action)); -} - function buildMessageToolDescription(options?: { config?: OpenClawConfig; currentChannel?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): string { const baseDescription = "Send, delete, and manage messages via channel plugins."; // If we have a current channel, show its actions and list other configured channels if (options?.currentChannel) { - const channelActions = filterActionsForContext({ - actions: listChannelSupportedActions({ - cfg: options.config, - channel: options.currentChannel, - }), + const channelActions = listChannelSupportedActions({ + cfg: options.config, channel: options.currentChannel, currentChannelId: options.currentChannelId, + currentThreadTs: options.currentThreadTs, + currentMessageId: options.currentMessageId, + accountId: options.currentAccountId, + sessionKey: options.sessionKey, + sessionId: options.sessionId, + agentId: options.agentId, + requesterSenderId: options.requesterSenderId, }); if (channelActions.length > 0) { // Always include "send" as a base action @@ -785,17 +601,37 @@ function buildMessageToolDescription(options?: { export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); + const resolvedAgentId = options?.agentSessionKey + ? resolveSessionAgentId({ + sessionKey: options.agentSessionKey, + config: options?.config, + }) + : undefined; const schema = options?.config ? buildMessageToolSchema({ cfg: options.config, currentChannelProvider: options.currentChannelProvider, currentChannelId: options.currentChannelId, + currentThreadTs: options.currentThreadTs, + currentMessageId: options.currentMessageId, + currentAccountId: agentAccountId, + sessionKey: options.agentSessionKey, + sessionId: options.sessionId, + agentId: resolvedAgentId, + requesterSenderId: options.requesterSenderId, }) : MessageToolSchema; const description = buildMessageToolDescription({ config: options?.config, currentChannel: options?.currentChannelProvider, currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, + currentAccountId: agentAccountId, + sessionKey: options?.agentSessionKey, + sessionId: options?.sessionId, + agentId: resolvedAgentId, + requesterSenderId: options?.requesterSenderId, }); return { @@ -917,9 +753,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { gateway, toolContext, sessionKey: options?.agentSessionKey, - agentId: options?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) - : undefined, + sessionId: options?.sessionId, + agentId: resolvedAgentId, sandboxRoot: options?.sandboxRoot, abortSignal: signal, }); diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 07d08171582..3a7cdad7e66 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -1,9 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { TSchema } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { getChannelPlugin, listChannelPlugins } from "./index.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; -import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js"; +import type { + ChannelMessageActionContext, + ChannelMessageActionDiscoveryContext, + ChannelMessageActionName, + ChannelMessageToolSchemaContribution, +} from "./types.js"; type ChannelActions = NonNullable>["actions"]>; @@ -38,11 +44,11 @@ function logMessageActionError(params: { function runListActionsSafely(params: { pluginId: string; - cfg: OpenClawConfig; + context: ChannelMessageActionDiscoveryContext; listActions: NonNullable; }): ChannelMessageActionName[] { try { - const listed = params.listActions({ cfg: params.cfg }); + const listed = params.listActions(params.context); return Array.isArray(listed) ? listed : []; } catch (error) { logMessageActionError({ @@ -62,7 +68,7 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc } const list = runListActionsSafely({ pluginId: plugin.id, - cfg, + context: { cfg }, listActions: plugin.actions.listActions, }); for (const action of list) { @@ -75,10 +81,10 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc function listCapabilities(params: { pluginId: string; actions: ChannelActions; - cfg: OpenClawConfig; + context: ChannelMessageActionDiscoveryContext; }): readonly ChannelMessageCapability[] { try { - return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? []; + return params.actions.getCapabilities?.(params.context) ?? []; } catch (error) { logMessageActionError({ pluginId: params.pluginId, @@ -98,7 +104,7 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess for (const capability of listCapabilities({ pluginId: plugin.id, actions: plugin.actions, - cfg, + context: { cfg }, })) { capabilities.add(capability); } @@ -109,6 +115,14 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess export function listChannelMessageCapabilitiesForChannel(params: { cfg: OpenClawConfig; channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageCapability[] { if (!params.channel) { return []; @@ -119,12 +133,119 @@ export function listChannelMessageCapabilitiesForChannel(params: { listCapabilities({ pluginId: plugin.id, actions: plugin.actions, - cfg: params.cfg, + context: { + cfg: params.cfg, + currentChannelProvider: params.channel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }, }), ) : []; } +function logMessageActionSchemaError(params: { pluginId: string; error: unknown }) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:getToolSchema:${message}`; + if (loggedMessageActionErrors.has(key)) { + return; + } + loggedMessageActionErrors.add(key); + const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; + defaultRuntime.error?.( + `[message-actions] ${params.pluginId}.actions.getToolSchema failed: ${stack ?? message}`, + ); +} + +function normalizeToolSchemaContributions( + value: + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined, +): ChannelMessageToolSchemaContribution[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function mergeToolSchemaProperties( + target: Record, + source: Record | undefined, +) { + if (!source) { + return; + } + for (const [name, schema] of Object.entries(source)) { + if (!(name in target)) { + target[name] = schema; + } + } +} + +export function resolveChannelMessageToolSchemaProperties(params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}): Record { + const properties: Record = {}; + const plugins = listChannelPlugins(); + const currentChannel = params.channel?.trim() || undefined; + const discoveryBase: ChannelMessageActionDiscoveryContext = { + cfg: params.cfg, + currentChannelId: params.currentChannelId, + currentChannelProvider: currentChannel, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }; + + for (const plugin of plugins) { + const getToolSchema = plugin?.actions?.getToolSchema; + if (!plugin || !getToolSchema) { + continue; + } + try { + const contributions = normalizeToolSchemaContributions(getToolSchema(discoveryBase)); + for (const contribution of contributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (currentChannel) { + if (visibility === "all-configured" || plugin.id === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); + } + continue; + } + mergeToolSchemaProperties(properties, contribution.properties); + } + } catch (error) { + logMessageActionSchemaError({ + pluginId: plugin.id, + error, + }); + } + } + + return properties; +} + export function channelSupportsMessageCapability( cfg: OpenClawConfig, capability: ChannelMessageCapability, @@ -136,6 +257,14 @@ export function channelSupportsMessageCapabilityForChannel( params: { cfg: OpenClawConfig; channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }, capability: ChannelMessageCapability, ): boolean { diff --git a/src/channels/plugins/message-tool-schema.ts b/src/channels/plugins/message-tool-schema.ts new file mode 100644 index 00000000000..790b2118ee9 --- /dev/null +++ b/src/channels/plugins/message-tool-schema.ts @@ -0,0 +1,161 @@ +import { Type } from "@sinclair/typebox"; +import type { TSchema } from "@sinclair/typebox"; +import { stringEnum } from "../../agents/schema/typebox.js"; + +const discordComponentEmojiSchema = Type.Object({ + name: Type.String(), + id: Type.Optional(Type.String()), + animated: Type.Optional(Type.Boolean()), +}); + +const discordComponentOptionSchema = Type.Object({ + label: Type.String(), + value: Type.String(), + description: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + default: Type.Optional(Type.Boolean()), +}); + +const discordComponentButtonSchema = Type.Object({ + label: Type.String(), + style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + url: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + disabled: Type.Optional(Type.Boolean()), + allowedUsers: Type.Optional( + Type.Array( + Type.String({ + description: "Discord user ids or names allowed to interact with this button.", + }), + ), + ), +}); + +const discordComponentSelectSchema = Type.Object({ + type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), + placeholder: Type.Optional(Type.String()), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), +}); + +const discordComponentBlockSchema = Type.Object({ + type: Type.String(), + text: Type.Optional(Type.String()), + texts: Type.Optional(Type.Array(Type.String())), + accessory: Type.Optional( + Type.Object({ + type: Type.String(), + url: Type.Optional(Type.String()), + button: Type.Optional(discordComponentButtonSchema), + }), + ), + spacing: Type.Optional(stringEnum(["small", "large"])), + divider: Type.Optional(Type.Boolean()), + buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), + select: Type.Optional(discordComponentSelectSchema), + items: Type.Optional( + Type.Array( + Type.Object({ + url: Type.String(), + description: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + ), + file: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), +}); + +const discordComponentModalFieldSchema = Type.Object({ + type: Type.String(), + name: Type.Optional(Type.String()), + label: Type.String(), + description: Type.Optional(Type.String()), + placeholder: Type.Optional(Type.String()), + required: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + minLength: Type.Optional(Type.Number()), + maxLength: Type.Optional(Type.Number()), + style: Type.Optional(stringEnum(["short", "paragraph"])), +}); + +const discordComponentModalSchema = Type.Object({ + title: Type.String(), + triggerLabel: Type.Optional(Type.String()), + triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + fields: Type.Array(discordComponentModalFieldSchema), +}); + +export function createMessageToolButtonsSchema(): TSchema { + return Type.Array( + Type.Array( + Type.Object({ + text: Type.String(), + callback_data: Type.String(), + style: Type.Optional(stringEnum(["danger", "success", "primary"])), + }), + ), + { + description: "Button rows for channels that support button-style actions.", + }, + ); +} + +export function createMessageToolCardSchema(): TSchema { + return Type.Object( + {}, + { + additionalProperties: true, + description: "Structured card payload for channels that support card-style messages.", + }, + ); +} + +export function createDiscordMessageToolComponentsSchema(): TSchema { + return Type.Object( + { + text: Type.Optional(Type.String()), + reusable: Type.Optional( + Type.Boolean({ + description: "Allow components to be used multiple times until they expire.", + }), + ), + container: Type.Optional( + Type.Object({ + accentColor: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), + modal: Type.Optional(discordComponentModalSchema), + }, + { + description: + "Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.", + }, + ); +} + +export function createSlackMessageToolBlocksSchema(): TSchema { + return Type.Array( + Type.Object( + {}, + { + additionalProperties: true, + description: "Slack Block Kit payload blocks (Slack only).", + }, + ), + ); +} + +export function createTelegramPollExtraToolSchemas(): Record { + return { + pollDurationHours: Type.Optional(Type.Number()), + pollDurationSeconds: Type.Optional(Type.Number()), + pollAnonymous: Type.Optional(Type.Boolean()), + pollPublic: Type.Optional(Type.Boolean()), + }; +} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index a43dbb42876..573046bb04b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -21,6 +21,37 @@ export type ChannelAgentTool = AgentTool & { export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[]; +/** + * Discovery-time inputs passed to channel action adapters when the core is + * asking what an agent should be allowed to see. This is intentionally + * smaller than execution context: it carries routing/account scope, but no + * tool params or runtime handles. + */ +export type ChannelMessageActionDiscoveryContext = { + cfg: OpenClawConfig; + currentChannelId?: string | null; + currentChannelProvider?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}; + +/** + * Plugin-owned schema fragments for the shared `message` tool. + * `current-channel` means expose the fields only when that provider is the + * active runtime channel. `all-configured` keeps the fields visible even while + * another configured channel is active, which is useful for cross-channel + * sends from cron or isolated agents. + */ +export type ChannelMessageToolSchemaContribution = { + properties: Record; + visibility?: "current-channel" | "all-configured"; +}; + export type ChannelSetupInput = { name?: string; token?: string; @@ -424,6 +455,9 @@ export type ChannelMessageActionContext = { * never be sourced from tool/model-controlled params. */ requesterSenderId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; gateway?: { url?: string; token?: string; @@ -449,9 +483,23 @@ export type ChannelMessageActionAdapter = { * not inferred from `outbound.sendPoll`, so channels that want agents to * create polls should include `"poll"` here when enabled. */ - listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[]; + listActions?: (params: ChannelMessageActionDiscoveryContext) => ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; - getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[]; + getCapabilities?: ( + params: ChannelMessageActionDiscoveryContext, + ) => readonly ChannelMessageCapability[]; + /** + * Extend the shared `message` tool schema with channel-owned fields. + * Keep this aligned with `listActions` and `getCapabilities` so the exposed + * schema matches what the channel can actually execute in the current scope. + */ + getToolSchema?: ( + params: ChannelMessageActionDiscoveryContext, + ) => + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined; requiresTrustedRequesterSender?: (params: { action: ChannelMessageActionName; toolContext?: ChannelThreadingToolContext; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index 9784ab69813..dd02bb33131 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -56,9 +56,11 @@ export type { ChannelLogSink, ChannelMentionAdapter, ChannelMessageActionAdapter, + ChannelMessageActionDiscoveryContext, ChannelMessageActionContext, ChannelMessagingAdapter, ChannelMeta, + ChannelMessageToolSchemaContribution, ChannelOutboundTargetMode, ChannelPollContext, ChannelPollResult, 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 952bf16f51c..f875bb40487 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -112,6 +112,50 @@ describe("runMessageAction plugin dispatch", () => { }), ); }); + + it("routes execution context ids into plugin handleAction", async () => { + await runMessageAction({ + cfg: { + channels: { + feishu: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "pin", + params: { + channel: "feishu", + messageId: "om_123", + }, + defaultAccountId: "ops", + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + toolContext: { + currentChannelId: "chat:oc_123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + }, + dryRun: false, + }); + + expect(handleAction).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: "pin", + accountId: "ops", + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + toolContext: expect.objectContaining({ + currentChannelId: "chat:oc_123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + }), + }), + ); + }); }); describe("media caption behavior", () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 8480b962544..29afdadbdf3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -96,6 +96,7 @@ export type RunMessageActionParams = { params: Record; defaultAccountId?: string; requesterSenderId?: string | null; + sessionId?: string; toolContext?: ChannelThreadingToolContext; gateway?: MessageActionRunnerGateway; deps?: OutboundSendDeps; @@ -675,7 +676,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { - const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx; + const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal, agentId } = ctx; throwIfAborted(abortSignal); const action = input.action as Exclude; if (dryRun) { @@ -701,6 +702,9 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise Date: Tue, 17 Mar 2026 23:47:54 +0000 Subject: [PATCH 153/393] Slack: own message tool blocks schema --- src/channels/plugins/slack.actions.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 483b4db7df9..8ce30555065 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -7,6 +7,7 @@ import { resolveSlackChannelId, handleSlackMessageAction, } from "../../plugin-sdk/slack.js"; +import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; import type { ChannelMessageActionAdapter } from "./types.js"; type SlackActionInvoke = ( @@ -31,6 +32,14 @@ export function createSlackActions( } return Array.from(capabilities); }, + getToolSchema: ({ cfg }) => + listSlackMessageActions(cfg).includes("send") + ? { + properties: { + blocks: createSlackMessageToolBlocksSchema(), + }, + } + : null, extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ From 4b5e801d1bf62a27feb846aa7431ceb2d7824d03 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:48:00 +0000 Subject: [PATCH 154/393] BlueBubbles: scope group actions in message discovery --- extensions/bluebubbles/src/actions.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index c9d96cb29ee..78cffcd2414 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -15,7 +15,11 @@ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; import { normalizeSecretInputString } from "./secret-input.js"; -import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; +import { + normalizeBlueBubblesHandle, + normalizeBlueBubblesMessagingTarget, + parseBlueBubblesTarget, +} from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; const loadBlueBubblesActionsRuntime = createLazyRuntimeNamedExport( @@ -63,7 +67,7 @@ const PRIVATE_API_ACTIONS = new Set([ ]); export const bluebubblesMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + listActions: ({ cfg, currentChannelId }) => { const account = resolveBlueBubblesAccount({ cfg: cfg }); if (!account.enabled || !account.configured) { return []; @@ -87,6 +91,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { actions.add(action); } } + const normalizedTarget = currentChannelId + ? normalizeBlueBubblesMessagingTarget(currentChannelId) + : undefined; + const lowered = normalizedTarget?.trim().toLowerCase() ?? ""; + const isGroupTarget = + lowered.startsWith("chat_guid:") || + lowered.startsWith("chat_id:") || + lowered.startsWith("chat_identifier:") || + lowered.startsWith("group:"); + if (!isGroupTarget) { + for (const action of BLUEBUBBLES_ACTION_NAMES) { + if ("groupOnly" in BLUEBUBBLES_ACTIONS[action] && BLUEBUBBLES_ACTIONS[action].groupOnly) { + actions.delete(action); + } + } + } return Array.from(actions); }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), From 05634eed1692a428d7b7b9ae049980f3c971424e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:48:04 +0000 Subject: [PATCH 155/393] Discord: own message tool components schema --- extensions/discord/src/channel-actions.ts | 9 +++++++++ extensions/discord/src/channel.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 21f24fd9553..b77050d9c74 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,4 +1,5 @@ import { + createDiscordMessageToolComponentsSchema, createUnionActionGate, listTokenSourcedAccounts, } from "openclaw/plugin-sdk/channel-runtime"; @@ -110,6 +111,14 @@ export const discordMessageActions: ChannelMessageActionAdapter = { listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)).length > 0 ? (["interactive", "components"] as const) : [], + getToolSchema: ({ cfg }) => + listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)).length > 0 + ? { + properties: { + components: createDiscordMessageToolComponentsSchema(), + }, + } + : null, extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action === "sendMessage") { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 29568ed58dc..780c04eb2e6 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -81,6 +81,8 @@ const discordMessageActions: ChannelMessageActionAdapter = { getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], getCapabilities: (ctx) => getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [], + getToolSchema: (ctx) => + getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { From dbc367e50ad91b455f393a8caea9a89fa715e340 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:48:08 +0000 Subject: [PATCH 156/393] Telegram: own message tool schema and runtime seam --- .../telegram/src/channel-actions.test.ts | 16 +++--- extensions/telegram/src/channel-actions.ts | 55 ++++++++++++++++--- extensions/telegram/src/channel.ts | 2 + 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/extensions/telegram/src/channel-actions.test.ts b/extensions/telegram/src/channel-actions.test.ts index 0e5170431b1..0addd92af78 100644 --- a/extensions/telegram/src/channel-actions.test.ts +++ b/extensions/telegram/src/channel-actions.test.ts @@ -1,12 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { telegramMessageActions, telegramMessageActionRuntime } from "./channel-actions.js"; const handleTelegramActionMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../../src/agents/tools/telegram-actions.js", () => ({ - handleTelegramAction: (...args: unknown[]) => handleTelegramActionMock(...args), -})); - -import { telegramMessageActions } from "./channel-actions.js"; +const originalHandleTelegramAction = telegramMessageActionRuntime.handleTelegramAction; describe("telegramMessageActions", () => { beforeEach(() => { @@ -15,6 +11,12 @@ describe("telegramMessageActions", () => { content: [], details: {}, }); + telegramMessageActionRuntime.handleTelegramAction = (...args) => + handleTelegramActionMock(...args); + }); + + afterEach(() => { + telegramMessageActionRuntime.handleTelegramAction = originalHandleTelegramAction; }); it("allows interactive-only sends", async () => { diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 50c472ea600..c81b7e1aec6 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -8,6 +8,8 @@ import { handleTelegramAction } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import { + createMessageToolButtonsSchema, + createTelegramPollExtraToolSchemas, createUnionActionGate, listTokenSourcedAccounts, } from "openclaw/plugin-sdk/channel-runtime"; @@ -28,6 +30,10 @@ import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; const providerId = "telegram"; +export const telegramMessageActionRuntime = { + handleTelegramAction, +}; + function readTelegramSendParams(params: Record) { const to = readStringParam(params, "to", { required: true }); const mediaUrl = readStringParam(params, "media", { trim: false }); @@ -138,13 +144,44 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); return buttonsEnabled ? (["interactive", "buttons"] as const) : []; }, + getToolSchema: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); + if (accounts.length === 0) { + return null; + } + const buttonsEnabled = accounts.some((account) => + isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), + ); + const pollEnabledForAnyAccount = accounts.some((account) => { + const accountGate = createTelegramActionGate({ + cfg, + accountId: account.accountId, + }); + return resolveTelegramPollActionGateState(accountGate).enabled; + }); + const entries = []; + if (buttonsEnabled) { + entries.push({ + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }); + } + if (pollEnabledForAnyAccount) { + entries.push({ + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured" as const, + }); + } + return entries.length > 0 ? entries : null; + }, extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => { if (action === "send") { const sendParams = readTelegramSendParams(params); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "sendMessage", ...sendParams, @@ -159,7 +196,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const messageId = resolveReactionMessageId({ args: params, toolContext }); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const remove = readBooleanParam(params, "remove"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "react", chatId: readTelegramChatIdParam(params), @@ -192,7 +229,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const pollPublic = readBooleanParam(params, "pollPublic"); const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); const silent = readBooleanParam(params, "silent"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "poll", to, @@ -215,7 +252,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (action === "delete") { const chatId = readTelegramChatIdParam(params); const messageId = readTelegramMessageIdParam(params); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "deleteMessage", chatId, @@ -232,7 +269,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const messageId = readTelegramMessageIdParam(params); const message = readStringParam(params, "message", { required: true, allowEmpty: false }); const buttons = params.buttons; - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "editMessage", chatId, @@ -254,7 +291,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true }); const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); const messageThreadId = readNumberParam(params, "threadId", { integer: true }); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "sendSticker", to, @@ -271,7 +308,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (action === "sticker-search") { const query = readStringParam(params, "query", { required: true }); const limit = readNumberParam(params, "limit", { integer: true }); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "searchSticker", query, @@ -288,7 +325,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const name = readStringParam(params, "name", { required: true }); const iconColor = readNumberParam(params, "iconColor", { integer: true }); const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "createForumTopic", chatId, @@ -312,7 +349,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { } const name = readStringParam(params, "name"); const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "editForumTopic", chatId, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d89d74da289..3e120e567af 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -252,6 +252,8 @@ const telegramMessageActions: ChannelMessageActionAdapter = { getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], getCapabilities: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.getCapabilities?.(ctx) ?? [], + getToolSchema: (ctx) => + getTelegramRuntime().channel.telegram.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { From d95dc50e0a098c2b5ed2c923de796d7a1faf93c1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:48:11 +0000 Subject: [PATCH 157/393] Mattermost: own message tool button schema --- extensions/mattermost/src/channel.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 887a878c5e8..5bec217aa3b 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -4,6 +4,7 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, @@ -77,6 +78,18 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { .filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim()); return accounts.length > 0 ? (["buttons"] as const) : []; }, + getToolSchema: ({ cfg }) => { + const accounts = listMattermostAccountIds(cfg) + .map((id) => resolveMattermostAccount({ cfg, accountId: id })) + .filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim()); + return accounts.length > 0 + ? { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + } + : null; + }, handleAction: async ({ action, params, cfg, accountId }) => { if (action === "react") { // Check reactions gate: per-account config takes precedence over base config From 60d4c5a30bf6a88f1d1ae4a8900a7b6e948e0620 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:48:14 +0000 Subject: [PATCH 158/393] Feishu: own message tool card schema --- extensions/feishu/src/channel.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 6111eeabffa..7fe196ef6a7 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,6 +1,7 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { buildChannelConfigSchema, @@ -422,6 +423,15 @@ export const feishuPlugin: ChannelPlugin = { ? (["cards"] as const) : []; }, + getToolSchema: ({ cfg }) => + cfg.channels?.feishu?.enabled !== false && + Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)) + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, handleAction: async (ctx) => { const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined }); if ( From df284fec27fc9995ca29ad8a74e2e8128c866c3b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:48:17 +0000 Subject: [PATCH 159/393] Teams: own message tool card schema --- extensions/msteams/src/channel.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 00430996001..d8e3de68527 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,5 +1,6 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, @@ -383,6 +384,15 @@ export const msteamsPlugin: ChannelPlugin = { ? (["cards"] as const) : []; }, + getToolSchema: ({ cfg }) => + cfg.channels?.msteams?.enabled !== false && + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, handleAction: async (ctx) => { // Handle send action with card parameter if (ctx.action === "send" && ctx.params.card) { From a32c7e16d20442d5205ffcfdde1459a38f625cef Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:55:00 +0000 Subject: [PATCH 160/393] Plugin SDK: normalize and harden message action discovery --- src/agents/channel-tools.test.ts | 27 +++++++++++ src/agents/channel-tools.ts | 43 ++++++----------- src/agents/tools/message-tool.test.ts | 24 ++++++++++ src/agents/tools/message-tool.ts | 47 ++++++++++--------- .../plugins/message-action-discovery.ts | 46 ++++++++++++++++++ src/channels/plugins/message-actions.test.ts | 45 ++++++++++++++---- src/channels/plugins/message-actions.ts | 38 +++++---------- 7 files changed, 186 insertions(+), 84 deletions(-) create mode 100644 src/channels/plugins/message-action-discovery.ts diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index 26552f81f9f..8e5e4266e10 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -84,4 +84,31 @@ describe("channel tools", () => { expect(listChannelSupportedActions({ cfg, channel: "polltest" })).toEqual([]); expect(listAllChannelSupportedActions({ cfg })).toEqual([]); }); + + it("normalizes channel aliases before listing supported actions", () => { + const plugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "telegram plugin", + aliases: ["tg"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => ["react"], + }, + }; + + setActivePluginRegistry(createTestRegistry([{ pluginId: "telegram", source: "test", plugin }])); + + const cfg = {} as OpenClawConfig; + expect(listChannelSupportedActions({ cfg, channel: "tg" })).toEqual(["react"]); + }); }); diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 4e2d028e91a..49cbc5c0efe 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,4 +1,8 @@ import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import { + createMessageActionDiscoveryContext, + resolveMessageActionDiscoveryChannelId, +} from "../channels/plugins/message-action-discovery.js"; import type { ChannelAgentTool, ChannelMessageActionName, @@ -24,26 +28,15 @@ export function listChannelSupportedActions(params: { agentId?: string | null; requesterSenderId?: string | null; }): ChannelMessageActionName[] { - if (!params.channel) { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { return []; } - const plugin = getChannelPlugin(params.channel as Parameters[0]); + const plugin = getChannelPlugin(channelId as Parameters[0]); if (!plugin?.actions?.listActions) { return []; } - const cfg = params.cfg ?? ({} as OpenClawConfig); - return runPluginListActions(plugin, { - cfg, - currentChannelId: params.currentChannelId, - currentChannelProvider: params.channel, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }); + return runPluginListActions(plugin, createMessageActionDiscoveryContext(params)); } /** @@ -65,19 +58,13 @@ export function listAllChannelSupportedActions(params: { if (!plugin.actions?.listActions) { continue; } - const cfg = params.cfg ?? ({} as OpenClawConfig); - const channelActions = runPluginListActions(plugin, { - cfg, - currentChannelId: params.currentChannelId, - currentChannelProvider: plugin.id, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }); + const channelActions = runPluginListActions( + plugin, + createMessageActionDiscoveryContext({ + ...params, + currentChannelProvider: plugin.id, + }), + ); for (const action of channelActions) { actions.add(action); } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 2693e7fdf19..1e0965305d4 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -86,6 +86,7 @@ function createChannelPlugin(params: { label: string; docsPath: string; blurb: string; + aliases?: string[]; actions?: ChannelMessageActionName[]; listActions?: NonNullable["listActions"]>; capabilities?: readonly ChannelMessageCapability[]; @@ -101,6 +102,7 @@ function createChannelPlugin(params: { selectionLabel: params.label, docsPath: params.docsPath, blurb: params.blurb, + aliases: params.aliases, }, capabilities: { chatTypes: ["direct", "group"], media: true }, config: { @@ -641,6 +643,28 @@ describe("message tool description", () => { expect(tool.description).toContain("telegram (delete, edit, react, send, topic-create)"); }); + it("normalizes channel aliases before building the current channel description", () => { + const signalPlugin = createChannelPlugin({ + id: "signal", + label: "Signal", + docsPath: "/channels/signal", + blurb: "Signal test plugin.", + aliases: ["sig"], + actions: ["send", "react"], + }); + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]), + ); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "sig", + }); + + expect(tool.description).toContain("Current channel (signal) supports: react, send."); + }); + it("does not include 'Other configured channels' when only one channel is configured", () => { setActivePluginRegistry( createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]), diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index f5428519f81..bf4a4d4c8cf 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,4 +1,4 @@ -import { Type } from "@sinclair/typebox"; +import { Type, type TSchema } from "@sinclair/typebox"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { channelSupportsMessageCapability, @@ -82,7 +82,7 @@ const interactiveMessageSchema = Type.Object( ); function buildSendSchema(options: { includeInteractive: boolean }) { - const props: Record = { + const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( Type.String({ @@ -167,7 +167,7 @@ function buildFetchSchema() { } function buildPollSchema() { - const props: Record = { + const props: Record = { pollId: Type.Optional(Type.String()), pollOptionId: Type.Optional( Type.String({ @@ -346,7 +346,7 @@ function buildChannelManagementSchema() { function buildMessageToolSchemaProps(options: { includeInteractive: boolean; - extraProperties?: Record; + extraProperties?: Record; }) { return { ...buildRoutingSchema(), @@ -370,7 +370,7 @@ function buildMessageToolSchemaFromActions( actions: readonly string[], options: { includeInteractive: boolean; - extraProperties?: Record; + extraProperties?: Record; }, ) { const props = buildMessageToolSchemaProps(options); @@ -547,34 +547,39 @@ function buildMessageToolDescription(options?: { requesterSenderId?: string; }): string { const baseDescription = "Send, delete, and manage messages via channel plugins."; + const resolvedOptions = options ?? {}; + const currentChannel = normalizeMessageChannel(resolvedOptions.currentChannel); // If we have a current channel, show its actions and list other configured channels - if (options?.currentChannel) { + if (currentChannel) { const channelActions = listChannelSupportedActions({ - cfg: options.config, - channel: options.currentChannel, - currentChannelId: options.currentChannelId, - currentThreadTs: options.currentThreadTs, - currentMessageId: options.currentMessageId, - accountId: options.currentAccountId, - sessionKey: options.sessionKey, - sessionId: options.sessionId, - agentId: options.agentId, - requesterSenderId: options.requesterSenderId, + cfg: resolvedOptions.config, + channel: currentChannel, + currentChannelId: resolvedOptions.currentChannelId, + currentThreadTs: resolvedOptions.currentThreadTs, + currentMessageId: resolvedOptions.currentMessageId, + accountId: resolvedOptions.currentAccountId, + sessionKey: resolvedOptions.sessionKey, + sessionId: resolvedOptions.sessionId, + agentId: resolvedOptions.agentId, + requesterSenderId: resolvedOptions.requesterSenderId, }); if (channelActions.length > 0) { // Always include "send" as a base action const allActions = new Set(["send", ...channelActions]); const actionList = Array.from(allActions).toSorted().join(", "); - let desc = `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`; + let desc = `${baseDescription} Current channel (${currentChannel}) supports: ${actionList}.`; // Include other configured channels so cron/isolated agents can discover them const otherChannels: string[] = []; for (const plugin of listChannelPlugins()) { - if (plugin.id === options.currentChannel) { + if (plugin.id === currentChannel) { continue; } - const actions = listChannelSupportedActions({ cfg: options.config, channel: plugin.id }); + const actions = listChannelSupportedActions({ + cfg: resolvedOptions.config, + channel: plugin.id, + }); if (actions.length > 0) { const all = new Set(["send", ...actions]); otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`); @@ -589,8 +594,8 @@ function buildMessageToolDescription(options?: { } // Fallback to generic description with all configured actions - if (options?.config) { - const actions = listChannelMessageActions(options.config); + if (resolvedOptions.config) { + const actions = listChannelMessageActions(resolvedOptions.config); if (actions.length > 0) { return `${baseDescription} Supports actions: ${actions.join(", ")}.`; } diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts new file mode 100644 index 00000000000..5825219f6dc --- /dev/null +++ b/src/channels/plugins/message-action-discovery.ts @@ -0,0 +1,46 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeAnyChannelId } from "../registry.js"; +import type { ChannelMessageActionDiscoveryContext } from "./types.js"; + +export type ChannelMessageActionDiscoveryInput = { + cfg?: OpenClawConfig; + channel?: string | null; + currentChannelProvider?: string | null; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}; + +export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined { + const normalized = normalizeAnyChannelId(raw); + if (normalized) { + return normalized; + } + const trimmed = raw?.trim(); + return trimmed || undefined; +} + +export function createMessageActionDiscoveryContext( + params: ChannelMessageActionDiscoveryInput, +): ChannelMessageActionDiscoveryContext { + const currentChannelProvider = resolveMessageActionDiscoveryChannelId( + params.channel ?? params.currentChannelProvider, + ); + return { + cfg: params.cfg ?? ({} as OpenClawConfig), + currentChannelId: params.currentChannelId, + currentChannelProvider, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }; +} diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 17fdf8fe193..bee94a28b0f 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -22,16 +22,22 @@ const emptyRegistry = createTestRegistry([]); function createMessageActionsPlugin(params: { id: "discord" | "telegram"; capabilities: readonly ChannelMessageCapability[]; + aliases?: string[]; }): ChannelPlugin { + const base = createChannelTestPluginBase({ + id: params.id, + label: params.id === "discord" ? "Discord" : "Telegram", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }); return { - ...createChannelTestPluginBase({ - id: params.id, - label: params.id === "discord" ? "Discord" : "Telegram", - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - }, - }), + ...base, + meta: { + ...base.meta, + ...(params.aliases ? { aliases: params.aliases } : {}), + }, actions: { listActions: () => ["send"], getCapabilities: () => params.capabilities, @@ -130,6 +136,29 @@ describe("message action capability checks", () => { ); }); + it("normalizes channel aliases for per-channel capability checks", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createMessageActionsPlugin({ + id: "telegram", + aliases: ["tg"], + capabilities: ["cards"], + }), + }, + ]), + ); + + expect( + listChannelMessageCapabilitiesForChannel({ + cfg: {} as OpenClawConfig, + channel: "tg", + }), + ).toEqual(["cards"]); + }); + it("skips crashing action/capability discovery paths and logs once", () => { const crashingPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 3a7cdad7e66..19f24d4f8d2 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -3,6 +3,10 @@ import type { TSchema } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { getChannelPlugin, listChannelPlugins } from "./index.js"; +import { + createMessageActionDiscoveryContext, + resolveMessageActionDiscoveryChannelId, +} from "./message-action-discovery.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelMessageActionContext, @@ -124,27 +128,17 @@ export function listChannelMessageCapabilitiesForChannel(params: { agentId?: string | null; requesterSenderId?: string | null; }): ChannelMessageCapability[] { - if (!params.channel) { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { return []; } - const plugin = getChannelPlugin(params.channel as Parameters[0]); + const plugin = getChannelPlugin(channelId as Parameters[0]); return plugin?.actions ? Array.from( listCapabilities({ pluginId: plugin.id, actions: plugin.actions, - context: { - cfg: params.cfg, - currentChannelProvider: params.channel, - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }, + context: createMessageActionDiscoveryContext(params), }), ) : []; @@ -204,19 +198,9 @@ export function resolveChannelMessageToolSchemaProperties(params: { }): Record { const properties: Record = {}; const plugins = listChannelPlugins(); - const currentChannel = params.channel?.trim() || undefined; - const discoveryBase: ChannelMessageActionDiscoveryContext = { - cfg: params.cfg, - currentChannelId: params.currentChannelId, - currentChannelProvider: currentChannel, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }; + const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); + const discoveryBase: ChannelMessageActionDiscoveryContext = + createMessageActionDiscoveryContext(params); for (const plugin of plugins) { const getToolSchema = plugin?.actions?.getToolSchema; From 5ce3eb3ff3e462cf366f12522da10a73faee892a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:55:05 +0000 Subject: [PATCH 161/393] Telegram: dedupe message action discovery state --- extensions/telegram/src/channel-actions.ts | 93 +++++++++++----------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index c81b7e1aec6..5358c1d4dec 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -16,6 +16,7 @@ import { import type { ChannelMessageActionAdapter, ChannelMessageActionName, + ChannelMessageToolSchemaContribution, } from "openclaw/plugin-sdk/channel-runtime"; import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram"; @@ -34,6 +35,35 @@ export const telegramMessageActionRuntime = { handleTelegramAction, }; +function resolveTelegramActionDiscovery(cfg: Parameters[0]) { + const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); + if (accounts.length === 0) { + return null; + } + const unionGate = createUnionActionGate(accounts, (account) => + createTelegramActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const pollEnabled = accounts.some((account) => { + const accountGate = createTelegramActionGate({ + cfg, + accountId: account.accountId, + }); + return resolveTelegramPollActionGateState(accountGate).enabled; + }); + const buttonsEnabled = accounts.some((account) => + isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), + ); + return { + isEnabled: (key: keyof TelegramActionConfig, defaultValue = true) => + unionGate(key, defaultValue), + pollEnabled, + buttonsEnabled, + }; +} + function readTelegramSendParams(params: Record) { const to = readStringParam(params, "to", { required: true }); const mediaUrl = readStringParam(params, "media", { trim: false }); @@ -89,85 +119,56 @@ function readTelegramMessageIdParam(params: Record): number { export const telegramMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); - if (accounts.length === 0) { + const discovery = resolveTelegramActionDiscovery(cfg); + if (!discovery) { return []; } - // Union of all accounts' action gates (any account enabling an action makes it available) - const gate = createUnionActionGate(accounts, (account) => - createTelegramActionGate({ - cfg, - accountId: account.accountId, - }), - ); - const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => - gate(key, defaultValue); const actions = new Set(["send"]); - const pollEnabledForAnyAccount = accounts.some((account) => { - const accountGate = createTelegramActionGate({ - cfg, - accountId: account.accountId, - }); - return resolveTelegramPollActionGateState(accountGate).enabled; - }); - if (pollEnabledForAnyAccount) { + if (discovery.pollEnabled) { actions.add("poll"); } - if (isEnabled("reactions")) { + if (discovery.isEnabled("reactions")) { actions.add("react"); } - if (isEnabled("deleteMessage")) { + if (discovery.isEnabled("deleteMessage")) { actions.add("delete"); } - if (isEnabled("editMessage")) { + if (discovery.isEnabled("editMessage")) { actions.add("edit"); } - if (isEnabled("sticker", false)) { + if (discovery.isEnabled("sticker", false)) { actions.add("sticker"); actions.add("sticker-search"); } - if (isEnabled("createForumTopic")) { + if (discovery.isEnabled("createForumTopic")) { actions.add("topic-create"); } - if (isEnabled("editForumTopic")) { + if (discovery.isEnabled("editForumTopic")) { actions.add("topic-edit"); } return Array.from(actions); }, getCapabilities: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); - if (accounts.length === 0) { + const discovery = resolveTelegramActionDiscovery(cfg); + if (!discovery) { return []; } - const buttonsEnabled = accounts.some((account) => - isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), - ); - return buttonsEnabled ? (["interactive", "buttons"] as const) : []; + return discovery.buttonsEnabled ? (["interactive", "buttons"] as const) : []; }, getToolSchema: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); - if (accounts.length === 0) { + const discovery = resolveTelegramActionDiscovery(cfg); + if (!discovery) { return null; } - const buttonsEnabled = accounts.some((account) => - isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), - ); - const pollEnabledForAnyAccount = accounts.some((account) => { - const accountGate = createTelegramActionGate({ - cfg, - accountId: account.accountId, - }); - return resolveTelegramPollActionGateState(accountGate).enabled; - }); - const entries = []; - if (buttonsEnabled) { + const entries: ChannelMessageToolSchemaContribution[] = []; + if (discovery.buttonsEnabled) { entries.push({ properties: { buttons: createMessageToolButtonsSchema(), }, }); } - if (pollEnabledForAnyAccount) { + if (discovery.pollEnabled) { entries.push({ properties: createTelegramPollExtraToolSchemas(), visibility: "all-configured" as const, From 1c08455848f069b7bed7a67d4fe234c858998af7 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:55:08 +0000 Subject: [PATCH 162/393] Discord: dedupe message action discovery state --- extensions/discord/src/channel-actions.ts | 76 ++++++++++++----------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index b77050d9c74..89e5abf682d 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -11,77 +11,85 @@ import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; import { handleDiscordMessageAction } from "./actions/handle-action.js"; +function resolveDiscordActionDiscovery(cfg: Parameters[0]) { + const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); + if (accounts.length === 0) { + return null; + } + const unionGate = createUnionActionGate(accounts, (account) => + createDiscordActionGate({ + cfg, + accountId: account.accountId, + }), + ); + return { + isEnabled: (key: keyof DiscordActionConfig, defaultValue = true) => + unionGate(key, defaultValue), + }; +} + export const discordMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); - if (accounts.length === 0) { + const discovery = resolveDiscordActionDiscovery(cfg); + if (!discovery) { return []; } - // Union of all accounts' action gates (any account enabling an action makes it available) - const gate = createUnionActionGate(accounts, (account) => - createDiscordActionGate({ - cfg, - accountId: account.accountId, - }), - ); - const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) => - gate(key, defaultValue); const actions = new Set(["send"]); - if (isEnabled("polls")) { + if (discovery.isEnabled("polls")) { actions.add("poll"); } - if (isEnabled("reactions")) { + if (discovery.isEnabled("reactions")) { actions.add("react"); actions.add("reactions"); } - if (isEnabled("messages")) { + if (discovery.isEnabled("messages")) { actions.add("read"); actions.add("edit"); actions.add("delete"); } - if (isEnabled("pins")) { + if (discovery.isEnabled("pins")) { actions.add("pin"); actions.add("unpin"); actions.add("list-pins"); } - if (isEnabled("permissions")) { + if (discovery.isEnabled("permissions")) { actions.add("permissions"); } - if (isEnabled("threads")) { + if (discovery.isEnabled("threads")) { actions.add("thread-create"); actions.add("thread-list"); actions.add("thread-reply"); } - if (isEnabled("search")) { + if (discovery.isEnabled("search")) { actions.add("search"); } - if (isEnabled("stickers")) { + if (discovery.isEnabled("stickers")) { actions.add("sticker"); } - if (isEnabled("memberInfo")) { + if (discovery.isEnabled("memberInfo")) { actions.add("member-info"); } - if (isEnabled("roleInfo")) { + if (discovery.isEnabled("roleInfo")) { actions.add("role-info"); } - if (isEnabled("reactions")) { + if (discovery.isEnabled("reactions")) { actions.add("emoji-list"); } - if (isEnabled("emojiUploads")) { + if (discovery.isEnabled("emojiUploads")) { actions.add("emoji-upload"); } - if (isEnabled("stickerUploads")) { + if (discovery.isEnabled("stickerUploads")) { actions.add("sticker-upload"); } - if (isEnabled("roles", false)) { + if (discovery.isEnabled("roles", false)) { actions.add("role-add"); actions.add("role-remove"); } - if (isEnabled("channelInfo")) { + if (discovery.isEnabled("channelInfo")) { actions.add("channel-info"); actions.add("channel-list"); } - if (isEnabled("channels")) { + if (discovery.isEnabled("channels")) { actions.add("channel-create"); actions.add("channel-edit"); actions.add("channel-delete"); @@ -90,29 +98,27 @@ export const discordMessageActions: ChannelMessageActionAdapter = { actions.add("category-edit"); actions.add("category-delete"); } - if (isEnabled("voiceStatus")) { + if (discovery.isEnabled("voiceStatus")) { actions.add("voice-status"); } - if (isEnabled("events")) { + if (discovery.isEnabled("events")) { actions.add("event-list"); actions.add("event-create"); } - if (isEnabled("moderation", false)) { + if (discovery.isEnabled("moderation", false)) { actions.add("timeout"); actions.add("kick"); actions.add("ban"); } - if (isEnabled("presence", false)) { + if (discovery.isEnabled("presence", false)) { actions.add("set-presence"); } return Array.from(actions); }, getCapabilities: ({ cfg }) => - listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)).length > 0 - ? (["interactive", "components"] as const) - : [], + resolveDiscordActionDiscovery(cfg) ? (["interactive", "components"] as const) : [], getToolSchema: ({ cfg }) => - listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)).length > 0 + resolveDiscordActionDiscovery(cfg) ? { properties: { components: createDiscordMessageToolComponentsSchema(), From b1c03715fb75c96585f51d53b9925daae6f3caf0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:55:13 +0000 Subject: [PATCH 163/393] Agents: remove unused bootstrap imports --- src/agents/bootstrap-budget.test.ts | 1 - src/agents/prompt-composition-scenarios.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts index 1d52e47437b..17d693f2128 100644 --- a/src/agents/bootstrap-budget.test.ts +++ b/src/agents/bootstrap-budget.test.ts @@ -7,7 +7,6 @@ import { buildBootstrapTruncationReportMeta, buildBootstrapTruncationSignature, formatBootstrapTruncationWarningLines, - prependBootstrapPromptWarning, resolveBootstrapWarningSignaturesSeen, } from "./bootstrap-budget.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; diff --git a/src/agents/prompt-composition-scenarios.ts b/src/agents/prompt-composition-scenarios.ts index 052811d6614..dff66c2c2b5 100644 --- a/src/agents/prompt-composition-scenarios.ts +++ b/src/agents/prompt-composition-scenarios.ts @@ -9,7 +9,6 @@ import { appendBootstrapPromptWarning, analyzeBootstrapBudget, buildBootstrapPromptWarning, - type BootstrapBudgetAnalysis, } from "./bootstrap-budget.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; import { buildToolSummaryMap } from "./tool-summaries.js"; From 144b95ffcec65c2fd5b530d689b5ec299aa3eae3 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:58:52 +0000 Subject: [PATCH 164/393] Agents: scope cross-channel message discovery --- src/agents/tools/message-tool.test.ts | 43 +++++++++++++++++++++++++++ src/agents/tools/message-tool.ts | 21 ++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 1e0965305d4..d6c03cabf75 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -486,6 +486,49 @@ describe("message tool schema scoping", () => { expect(getToolProperties(unscopedTool).interactive).toBeUndefined(); }); + it("uses discovery account scope for other configured channel actions", () => { + const currentPlugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + actions: ["send"], + }); + const scopedOtherPlugin = createChannelPlugin({ + id: "telegram", + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + actions: ["send"], + }); + scopedOtherPlugin.actions = { + ...scopedOtherPlugin.actions, + listActions: ({ accountId }) => (accountId === "ops" ? ["react"] : []), + }; + + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "discord", source: "test", plugin: currentPlugin }, + { pluginId: "telegram", source: "test", plugin: scopedOtherPlugin }, + ]), + ); + + const scopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + agentAccountId: "ops", + }); + const unscopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + }); + + expect(getActionEnum(getToolProperties(scopedTool))).toContain("react"); + expect(getActionEnum(getToolProperties(unscopedTool))).not.toContain("react"); + expect(scopedTool.description).toContain("telegram (react, send)"); + expect(unscopedTool.description).not.toContain("telegram (react, send)"); + }); + it("routes full discovery context into plugin action discovery", () => { const seenContexts: Record[] = []; const contextPlugin = createChannelPlugin({ diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index bf4a4d4c8cf..7fff178b933 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -433,7 +433,18 @@ function resolveMessageToolSchemaActions(params: { if (plugin.id === currentChannel) { continue; } - for (const action of listChannelSupportedActions({ cfg: params.cfg, channel: plugin.id })) { + for (const action of listChannelSupportedActions({ + cfg: params.cfg, + channel: plugin.id, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + })) { allActions.add(action); } } @@ -579,6 +590,14 @@ function buildMessageToolDescription(options?: { const actions = listChannelSupportedActions({ cfg: resolvedOptions.config, channel: plugin.id, + currentChannelId: resolvedOptions.currentChannelId, + currentThreadTs: resolvedOptions.currentThreadTs, + currentMessageId: resolvedOptions.currentMessageId, + accountId: resolvedOptions.currentAccountId, + sessionKey: resolvedOptions.sessionKey, + sessionId: resolvedOptions.sessionId, + agentId: resolvedOptions.agentId, + requesterSenderId: resolvedOptions.requesterSenderId, }); if (actions.length > 0) { const all = new Set(["send", ...actions]); From bb365dba733341c3767a8960948daa25d4392a29 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:06:45 +0000 Subject: [PATCH 165/393] Plugin SDK: unify message tool discovery --- src/agents/channel-tools.test.ts | 33 +++ src/agents/channel-tools.ts | 63 ++---- src/channels/plugins/message-actions.test.ts | 53 +++++ src/channels/plugins/message-actions.ts | 224 +++++++++++++------ src/channels/plugins/types.core.ts | 16 ++ src/channels/plugins/types.ts | 1 + 6 files changed, 278 insertions(+), 112 deletions(-) diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index 8e5e4266e10..0dad6dc3a7c 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -111,4 +111,37 @@ describe("channel tools", () => { const cfg = {} as OpenClawConfig; expect(listChannelSupportedActions({ cfg, channel: "tg" })).toEqual(["react"]); }); + + it("uses unified message tool discovery when available", () => { + const listActions = vi.fn(() => { + throw new Error("legacy listActions should not run"); + }); + const plugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "telegram plugin", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + describeMessageTool: () => ({ + actions: ["react"], + }), + listActions, + }, + }; + + setActivePluginRegistry(createTestRegistry([{ pluginId: "telegram", source: "test", plugin }])); + + const cfg = {} as OpenClawConfig; + expect(listChannelSupportedActions({ cfg, channel: "telegram" })).toEqual(["react"]); + expect(listActions).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 49cbc5c0efe..8596d3e8471 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -3,14 +3,13 @@ import { createMessageActionDiscoveryContext, resolveMessageActionDiscoveryChannelId, } from "../channels/plugins/message-action-discovery.js"; -import type { - ChannelAgentTool, - ChannelMessageActionName, - ChannelPlugin, -} from "../channels/plugins/types.js"; +import { + __testing as messageActionTesting, + resolveMessageActionDiscoveryForPlugin, +} from "../channels/plugins/message-actions.js"; +import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; -import { defaultRuntime } from "../runtime.js"; /** * Get the list of supported message actions for a specific channel. @@ -36,7 +35,12 @@ export function listChannelSupportedActions(params: { if (!plugin?.actions?.listActions) { return []; } - return runPluginListActions(plugin, createMessageActionDiscoveryContext(params)); + return resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext(params), + includeActions: true, + }).actions; } /** @@ -55,16 +59,15 @@ export function listAllChannelSupportedActions(params: { }): ChannelMessageActionName[] { const actions = new Set(); for (const plugin of listChannelPlugins()) { - if (!plugin.actions?.listActions) { - continue; - } - const channelActions = runPluginListActions( - plugin, - createMessageActionDiscoveryContext({ + const channelActions = resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext({ ...params, currentChannelProvider: plugin.id, }), - ); + includeActions: true, + }).actions; for (const action of channelActions) { actions.add(action); } @@ -107,38 +110,8 @@ export function resolveChannelMessageToolHints(params: { .filter(Boolean); } -const loggedListActionErrors = new Set(); - -function runPluginListActions( - plugin: ChannelPlugin, - context: Parameters["listActions"]>>[0], -): ChannelMessageActionName[] { - if (!plugin.actions?.listActions) { - return []; - } - try { - const listed = plugin.actions.listActions(context); - return Array.isArray(listed) ? listed : []; - } catch (err) { - logListActionsError(plugin.id, err); - return []; - } -} - -function logListActionsError(pluginId: string, err: unknown) { - const message = err instanceof Error ? err.message : String(err); - const key = `${pluginId}:${message}`; - if (loggedListActionErrors.has(key)) { - return; - } - loggedListActionErrors.add(key); - const stack = err instanceof Error && err.stack ? err.stack : null; - const details = stack ?? message; - defaultRuntime.error?.(`[channel-tools] ${pluginId}.actions.listActions failed: ${details}`); -} - export const __testing = { resetLoggedListActionErrors() { - loggedListActionErrors.clear(); + messageActionTesting.resetLoggedMessageActionErrors(); }, }; diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index bee94a28b0f..13a28e098db 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -13,6 +14,7 @@ import { listChannelMessageActions, listChannelMessageCapabilities, listChannelMessageCapabilitiesForChannel, + resolveChannelMessageToolSchemaProperties, } from "./message-actions.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelPlugin } from "./types.js"; @@ -159,6 +161,57 @@ describe("message action capability checks", () => { ).toEqual(["cards"]); }); + it("prefers unified message tool discovery over legacy discovery methods", () => { + const legacyListActions = vi.fn(() => { + throw new Error("legacy listActions should not run"); + }); + const legacyCapabilities = vi.fn(() => { + throw new Error("legacy getCapabilities should not run"); + }); + const legacySchema = vi.fn(() => { + throw new Error("legacy getToolSchema should not run"); + }); + const unifiedPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + label: "Discord", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }), + actions: { + describeMessageTool: () => ({ + actions: ["react"], + capabilities: ["interactive"], + schema: { + properties: { + components: Type.Array(Type.String()), + }, + }, + }), + listActions: legacyListActions, + getCapabilities: legacyCapabilities, + getToolSchema: legacySchema, + }, + }; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: unifiedPlugin }]), + ); + + expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast", "react"]); + expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual(["interactive"]); + expect( + resolveChannelMessageToolSchemaProperties({ + cfg: {} as OpenClawConfig, + channel: "discord", + }), + ).toHaveProperty("components"); + expect(legacyListActions).not.toHaveBeenCalled(); + expect(legacyCapabilities).not.toHaveBeenCalled(); + expect(legacySchema).not.toHaveBeenCalled(); + }); + it("skips crashing action/capability discovery paths and logs once", () => { const crashingPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 19f24d4f8d2..8bf765ea81f 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -12,6 +12,7 @@ import type { ChannelMessageActionContext, ChannelMessageActionDiscoveryContext, ChannelMessageActionName, + ChannelMessageToolDiscovery, ChannelMessageToolSchemaContribution, } from "./types.js"; @@ -31,7 +32,7 @@ const loggedMessageActionErrors = new Set(); function logMessageActionError(params: { pluginId: string; - operation: "listActions" | "getCapabilities"; + operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; error: unknown; }) { const message = params.error instanceof Error ? params.error.message : String(params.error); @@ -64,22 +65,21 @@ function runListActionsSafely(params: { } } -export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { - const actions = new Set(["send", "broadcast"]); - for (const plugin of listChannelPlugins()) { - if (!plugin.actions?.listActions) { - continue; - } - const list = runListActionsSafely({ - pluginId: plugin.id, - context: { cfg }, - listActions: plugin.actions.listActions, +function describeMessageToolSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + describeMessageTool: NonNullable; +}): ChannelMessageToolDiscovery | null { + try { + return params.describeMessageTool(params.context) ?? null; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "describeMessageTool", + error, }); - for (const action of list) { - actions.add(action); - } + return null; } - return Array.from(actions); } function listCapabilities(params: { @@ -99,17 +99,136 @@ function listCapabilities(params: { } } -export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { - const capabilities = new Set(); +function normalizeToolSchemaContributions( + value: + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined, +): ChannelMessageToolSchemaContribution[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +type ResolvedChannelMessageActionDiscovery = { + actions: ChannelMessageActionName[]; + capabilities: readonly ChannelMessageCapability[]; + schemaContributions: ChannelMessageToolSchemaContribution[]; +}; + +export function resolveMessageActionDiscoveryForPlugin(params: { + pluginId: string; + actions?: ChannelActions; + context: ChannelMessageActionDiscoveryContext; + includeActions?: boolean; + includeCapabilities?: boolean; + includeSchema?: boolean; +}): ResolvedChannelMessageActionDiscovery { + const adapter = params.actions; + if (!adapter) { + return { + actions: [], + capabilities: [], + schemaContributions: [], + }; + } + + if (adapter.describeMessageTool) { + const described = describeMessageToolSafely({ + pluginId: params.pluginId, + context: params.context, + describeMessageTool: adapter.describeMessageTool, + }); + return { + actions: + params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [], + capabilities: + params.includeCapabilities && Array.isArray(described?.capabilities) + ? described.capabilities + : [], + schemaContributions: params.includeSchema + ? normalizeToolSchemaContributions(described?.schema) + : [], + }; + } + + return { + actions: + params.includeActions && adapter.listActions + ? runListActionsSafely({ + pluginId: params.pluginId, + context: params.context, + listActions: adapter.listActions, + }) + : [], + capabilities: + params.includeCapabilities && adapter.getCapabilities + ? listCapabilities({ + pluginId: params.pluginId, + actions: adapter, + context: params.context, + }) + : [], + schemaContributions: + params.includeSchema && adapter.getToolSchema + ? normalizeToolSchemaContributions( + runGetToolSchemaSafely({ + pluginId: params.pluginId, + context: params.context, + getToolSchema: adapter.getToolSchema, + }), + ) + : [], + }; +} + +export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { + const actions = new Set(["send", "broadcast"]); for (const plugin of listChannelPlugins()) { - if (!plugin.actions) { - continue; - } - for (const capability of listCapabilities({ + for (const action of resolveMessageActionDiscoveryForPlugin({ pluginId: plugin.id, actions: plugin.actions, context: { cfg }, - })) { + includeActions: true, + }).actions) { + actions.add(action); + } + } + return Array.from(actions); +} + +function runGetToolSchemaSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + getToolSchema: NonNullable; +}): + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined { + try { + return params.getToolSchema(params.context); + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getToolSchema", + error, + }); + return null; + } +} + +export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { + const capabilities = new Set(); + for (const plugin of listChannelPlugins()) { + for (const capability of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeCapabilities: true, + }).capabilities) { capabilities.add(capability); } } @@ -135,41 +254,16 @@ export function listChannelMessageCapabilitiesForChannel(params: { const plugin = getChannelPlugin(channelId as Parameters[0]); return plugin?.actions ? Array.from( - listCapabilities({ + resolveMessageActionDiscoveryForPlugin({ pluginId: plugin.id, actions: plugin.actions, context: createMessageActionDiscoveryContext(params), - }), + includeCapabilities: true, + }).capabilities, ) : []; } -function logMessageActionSchemaError(params: { pluginId: string; error: unknown }) { - const message = params.error instanceof Error ? params.error.message : String(params.error); - const key = `${params.pluginId}:getToolSchema:${message}`; - if (loggedMessageActionErrors.has(key)) { - return; - } - loggedMessageActionErrors.add(key); - const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; - defaultRuntime.error?.( - `[message-actions] ${params.pluginId}.actions.getToolSchema failed: ${stack ?? message}`, - ); -} - -function normalizeToolSchemaContributions( - value: - | ChannelMessageToolSchemaContribution - | ChannelMessageToolSchemaContribution[] - | null - | undefined, -): ChannelMessageToolSchemaContribution[] { - if (!value) { - return []; - } - return Array.isArray(value) ? value : [value]; -} - function mergeToolSchemaProperties( target: Record, source: Record | undefined, @@ -203,27 +297,23 @@ export function resolveChannelMessageToolSchemaProperties(params: { createMessageActionDiscoveryContext(params); for (const plugin of plugins) { - const getToolSchema = plugin?.actions?.getToolSchema; - if (!plugin || !getToolSchema) { + if (!plugin?.actions) { continue; } - try { - const contributions = normalizeToolSchemaContributions(getToolSchema(discoveryBase)); - for (const contribution of contributions) { - const visibility = contribution.visibility ?? "current-channel"; - if (currentChannel) { - if (visibility === "all-configured" || plugin.id === currentChannel) { - mergeToolSchemaProperties(properties, contribution.properties); - } - continue; + for (const contribution of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: discoveryBase, + includeSchema: true, + }).schemaContributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (currentChannel) { + if (visibility === "all-configured" || plugin.id === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); } - mergeToolSchemaProperties(properties, contribution.properties); + continue; } - } catch (error) { - logMessageActionSchemaError({ - pluginId: plugin.id, - error, - }); + mergeToolSchemaProperties(properties, contribution.properties); } } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 573046bb04b..1699b8024a5 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -52,6 +52,12 @@ export type ChannelMessageToolSchemaContribution = { visibility?: "current-channel" | "all-configured"; }; +export type ChannelMessageToolDiscovery = { + actions?: readonly ChannelMessageActionName[] | null; + capabilities?: readonly ChannelMessageCapability[] | null; + schema?: ChannelMessageToolSchemaContribution | ChannelMessageToolSchemaContribution[] | null; +}; + export type ChannelSetupInput = { name?: string; token?: string; @@ -477,8 +483,17 @@ export type ChannelToolSend = { }; export type ChannelMessageActionAdapter = { + /** + * Preferred unified discovery surface for the shared `message` tool. + * When provided, this is authoritative and should return the scoped actions, + * capabilities, and schema fragments together so they cannot drift. + */ + describeMessageTool?: ( + params: ChannelMessageActionDiscoveryContext, + ) => ChannelMessageToolDiscovery | null | undefined; /** * Advertise agent-discoverable actions for this channel. + * Legacy fallback used when `describeMessageTool` is not implemented. * Keep this aligned with any gated capability checks. Poll discovery is * not inferred from `outbound.sendPoll`, so channels that want agents to * create polls should include `"poll"` here when enabled. @@ -490,6 +505,7 @@ export type ChannelMessageActionAdapter = { ) => readonly ChannelMessageCapability[]; /** * Extend the shared `message` tool schema with channel-owned fields. + * Legacy fallback used when `describeMessageTool` is not implemented. * Keep this aligned with `listActions` and `getCapabilities` so the exposed * schema matches what the channel can actually execute in the current scope. */ diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index dd02bb33131..d17fd1c67bd 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -59,6 +59,7 @@ export type { ChannelMessageActionDiscoveryContext, ChannelMessageActionContext, ChannelMessagingAdapter, + ChannelMessageToolDiscovery, ChannelMeta, ChannelMessageToolSchemaContribution, ChannelOutboundTargetMode, From c9ba985839678659e002bb86d95b6893c2f1e9bf Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:06:50 +0000 Subject: [PATCH 166/393] Slack: consolidate message tool discovery --- src/channels/plugins/slack.actions.ts | 38 ++++++++++++++++----------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 8ce30555065..8920923bc46 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -8,7 +8,7 @@ import { handleSlackMessageAction, } from "../../plugin-sdk/slack.js"; import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; -import type { ChannelMessageActionAdapter } from "./types.js"; +import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js"; type SlackActionInvoke = ( action: Record, @@ -20,26 +20,34 @@ export function createSlackActions( providerId: string, options?: { invoke?: SlackActionInvoke }, ): ChannelMessageActionAdapter { - return { - listActions: ({ cfg }) => listSlackMessageActions(cfg), - getCapabilities: ({ cfg }) => { - const capabilities = new Set<"interactive" | "blocks">(); - if (listSlackMessageActions(cfg).includes("send")) { - capabilities.add("blocks"); - } - if (isSlackInteractiveRepliesEnabled({ cfg })) { - capabilities.add("interactive"); - } - return Array.from(capabilities); - }, - getToolSchema: ({ cfg }) => - listSlackMessageActions(cfg).includes("send") + function describeMessageTool({ + cfg, + }: Parameters< + NonNullable + >[0]): ChannelMessageToolDiscovery { + const actions = listSlackMessageActions(cfg); + const capabilities = new Set<"blocks" | "interactive">(); + if (actions.includes("send")) { + capabilities.add("blocks"); + } + if (isSlackInteractiveRepliesEnabled({ cfg })) { + capabilities.add("interactive"); + } + return { + actions, + capabilities: Array.from(capabilities), + schema: actions.includes("send") ? { properties: { blocks: createSlackMessageToolBlocksSchema(), }, } : null, + }; + } + + return { + describeMessageTool, extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ From 0a0ca804aa184816fdbaa5cde0dfae1f0e5160e4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:06:55 +0000 Subject: [PATCH 167/393] Discord: consolidate message tool discovery --- extensions/discord/src/channel-actions.ts | 199 +++++++++++----------- extensions/discord/src/channel.ts | 2 + 2 files changed, 105 insertions(+), 96 deletions(-) diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 89e5abf682d..c4be7728439 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -6,6 +6,7 @@ import { import type { ChannelMessageActionAdapter, ChannelMessageActionName, + ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; @@ -28,103 +29,109 @@ function resolveDiscordActionDiscovery(cfg: Parameters +>[0]): ChannelMessageToolDiscovery { + const discovery = resolveDiscordActionDiscovery(cfg); + if (!discovery) { + return { + actions: [], + capabilities: [], + schema: null, + }; + } + const actions = new Set(["send"]); + if (discovery.isEnabled("polls")) { + actions.add("poll"); + } + if (discovery.isEnabled("reactions")) { + actions.add("react"); + actions.add("reactions"); + actions.add("emoji-list"); + } + if (discovery.isEnabled("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (discovery.isEnabled("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (discovery.isEnabled("permissions")) { + actions.add("permissions"); + } + if (discovery.isEnabled("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (discovery.isEnabled("search")) { + actions.add("search"); + } + if (discovery.isEnabled("stickers")) { + actions.add("sticker"); + } + if (discovery.isEnabled("memberInfo")) { + actions.add("member-info"); + } + if (discovery.isEnabled("roleInfo")) { + actions.add("role-info"); + } + if (discovery.isEnabled("emojiUploads")) { + actions.add("emoji-upload"); + } + if (discovery.isEnabled("stickerUploads")) { + actions.add("sticker-upload"); + } + if (discovery.isEnabled("roles", false)) { + actions.add("role-add"); + actions.add("role-remove"); + } + if (discovery.isEnabled("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (discovery.isEnabled("channels")) { + actions.add("channel-create"); + actions.add("channel-edit"); + actions.add("channel-delete"); + actions.add("channel-move"); + actions.add("category-create"); + actions.add("category-edit"); + actions.add("category-delete"); + } + if (discovery.isEnabled("voiceStatus")) { + actions.add("voice-status"); + } + if (discovery.isEnabled("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (discovery.isEnabled("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + if (discovery.isEnabled("presence", false)) { + actions.add("set-presence"); + } + return { + actions: Array.from(actions), + capabilities: ["interactive", "components"], + schema: { + properties: { + components: createDiscordMessageToolComponentsSchema(), + }, + }, + }; +} + export const discordMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const discovery = resolveDiscordActionDiscovery(cfg); - if (!discovery) { - return []; - } - const actions = new Set(["send"]); - if (discovery.isEnabled("polls")) { - actions.add("poll"); - } - if (discovery.isEnabled("reactions")) { - actions.add("react"); - actions.add("reactions"); - } - if (discovery.isEnabled("messages")) { - actions.add("read"); - actions.add("edit"); - actions.add("delete"); - } - if (discovery.isEnabled("pins")) { - actions.add("pin"); - actions.add("unpin"); - actions.add("list-pins"); - } - if (discovery.isEnabled("permissions")) { - actions.add("permissions"); - } - if (discovery.isEnabled("threads")) { - actions.add("thread-create"); - actions.add("thread-list"); - actions.add("thread-reply"); - } - if (discovery.isEnabled("search")) { - actions.add("search"); - } - if (discovery.isEnabled("stickers")) { - actions.add("sticker"); - } - if (discovery.isEnabled("memberInfo")) { - actions.add("member-info"); - } - if (discovery.isEnabled("roleInfo")) { - actions.add("role-info"); - } - if (discovery.isEnabled("reactions")) { - actions.add("emoji-list"); - } - if (discovery.isEnabled("emojiUploads")) { - actions.add("emoji-upload"); - } - if (discovery.isEnabled("stickerUploads")) { - actions.add("sticker-upload"); - } - if (discovery.isEnabled("roles", false)) { - actions.add("role-add"); - actions.add("role-remove"); - } - if (discovery.isEnabled("channelInfo")) { - actions.add("channel-info"); - actions.add("channel-list"); - } - if (discovery.isEnabled("channels")) { - actions.add("channel-create"); - actions.add("channel-edit"); - actions.add("channel-delete"); - actions.add("channel-move"); - actions.add("category-create"); - actions.add("category-edit"); - actions.add("category-delete"); - } - if (discovery.isEnabled("voiceStatus")) { - actions.add("voice-status"); - } - if (discovery.isEnabled("events")) { - actions.add("event-list"); - actions.add("event-create"); - } - if (discovery.isEnabled("moderation", false)) { - actions.add("timeout"); - actions.add("kick"); - actions.add("ban"); - } - if (discovery.isEnabled("presence", false)) { - actions.add("set-presence"); - } - return Array.from(actions); - }, - getCapabilities: ({ cfg }) => - resolveDiscordActionDiscovery(cfg) ? (["interactive", "components"] as const) : [], - getToolSchema: ({ cfg }) => - resolveDiscordActionDiscovery(cfg) - ? { - properties: { - components: createDiscordMessageToolComponentsSchema(), - }, - } - : null, + describeMessageTool: describeDiscordMessageTool, extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action === "sendMessage") { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 780c04eb2e6..c555ff89382 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -77,6 +77,8 @@ function formatDiscordIntents(intents?: { } const discordMessageActions: ChannelMessageActionAdapter = { + describeMessageTool: (ctx) => + getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null, listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], getCapabilities: (ctx) => From 60104de428613b4cae65695a27b16c92b4552d5b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:06:58 +0000 Subject: [PATCH 168/393] Telegram: consolidate message tool discovery --- extensions/telegram/src/channel-actions.ts | 117 +++++++++++---------- extensions/telegram/src/channel.ts | 2 + 2 files changed, 61 insertions(+), 58 deletions(-) diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 5358c1d4dec..a23430f02da 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -16,6 +16,7 @@ import { import type { ChannelMessageActionAdapter, ChannelMessageActionName, + ChannelMessageToolDiscovery, ChannelMessageToolSchemaContribution, } from "openclaw/plugin-sdk/channel-runtime"; import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-runtime"; @@ -64,6 +65,63 @@ function resolveTelegramActionDiscovery(cfg: Parameters +>[0]): ChannelMessageToolDiscovery { + const discovery = resolveTelegramActionDiscovery(cfg); + if (!discovery) { + return { + actions: [], + capabilities: [], + schema: null, + }; + } + const actions = new Set(["send"]); + if (discovery.pollEnabled) { + actions.add("poll"); + } + if (discovery.isEnabled("reactions")) { + actions.add("react"); + } + if (discovery.isEnabled("deleteMessage")) { + actions.add("delete"); + } + if (discovery.isEnabled("editMessage")) { + actions.add("edit"); + } + if (discovery.isEnabled("sticker", false)) { + actions.add("sticker"); + actions.add("sticker-search"); + } + if (discovery.isEnabled("createForumTopic")) { + actions.add("topic-create"); + } + if (discovery.isEnabled("editForumTopic")) { + actions.add("topic-edit"); + } + const schema: ChannelMessageToolSchemaContribution[] = []; + if (discovery.buttonsEnabled) { + schema.push({ + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }); + } + if (discovery.pollEnabled) { + schema.push({ + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured", + }); + } + return { + actions: Array.from(actions), + capabilities: discovery.buttonsEnabled ? ["interactive", "buttons"] : [], + schema, + }; +} + function readTelegramSendParams(params: Record) { const to = readStringParam(params, "to", { required: true }); const mediaUrl = readStringParam(params, "media", { trim: false }); @@ -118,64 +176,7 @@ function readTelegramMessageIdParam(params: Record): number { } export const telegramMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const discovery = resolveTelegramActionDiscovery(cfg); - if (!discovery) { - return []; - } - const actions = new Set(["send"]); - if (discovery.pollEnabled) { - actions.add("poll"); - } - if (discovery.isEnabled("reactions")) { - actions.add("react"); - } - if (discovery.isEnabled("deleteMessage")) { - actions.add("delete"); - } - if (discovery.isEnabled("editMessage")) { - actions.add("edit"); - } - if (discovery.isEnabled("sticker", false)) { - actions.add("sticker"); - actions.add("sticker-search"); - } - if (discovery.isEnabled("createForumTopic")) { - actions.add("topic-create"); - } - if (discovery.isEnabled("editForumTopic")) { - actions.add("topic-edit"); - } - return Array.from(actions); - }, - getCapabilities: ({ cfg }) => { - const discovery = resolveTelegramActionDiscovery(cfg); - if (!discovery) { - return []; - } - return discovery.buttonsEnabled ? (["interactive", "buttons"] as const) : []; - }, - getToolSchema: ({ cfg }) => { - const discovery = resolveTelegramActionDiscovery(cfg); - if (!discovery) { - return null; - } - const entries: ChannelMessageToolSchemaContribution[] = []; - if (discovery.buttonsEnabled) { - entries.push({ - properties: { - buttons: createMessageToolButtonsSchema(), - }, - }); - } - if (discovery.pollEnabled) { - entries.push({ - properties: createTelegramPollExtraToolSchemas(), - visibility: "all-configured" as const, - }); - } - return entries.length > 0 ? entries : null; - }, + describeMessageTool: describeTelegramMessageTool, extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 3e120e567af..6d536fb8513 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -248,6 +248,8 @@ function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { } const telegramMessageActions: ChannelMessageActionAdapter = { + describeMessageTool: (ctx) => + getTelegramRuntime().channel.telegram.messageActions?.describeMessageTool?.(ctx) ?? null, listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], getCapabilities: (ctx) => From 28ab5061bf3dac078be2a4d0bd308c93e38339df Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:07:01 +0000 Subject: [PATCH 169/393] Mattermost: consolidate message tool discovery --- extensions/mattermost/src/channel.ts | 35 ++++++++++++---------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 5bec217aa3b..964310bcbdd 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -43,7 +43,9 @@ import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; const mattermostMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + describeMessageTool: ({ + cfg, + }: Parameters>[0]) => { const enabledAccounts = listMattermostAccountIds(cfg) .map((accountId) => resolveMattermostAccount({ cfg, accountId })) .filter((account) => account.enabled) @@ -67,29 +69,22 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { actions.push("react"); } - return actions; + return { + actions, + capabilities: enabledAccounts.length > 0 ? ["buttons"] : [], + schema: + enabledAccounts.length > 0 + ? { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + } + : null, + }; }, supportsAction: ({ action }) => { return action === "send" || action === "react"; }, - getCapabilities: ({ cfg }) => { - const accounts = listMattermostAccountIds(cfg) - .map((id) => resolveMattermostAccount({ cfg, accountId: id })) - .filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim()); - return accounts.length > 0 ? (["buttons"] as const) : []; - }, - getToolSchema: ({ cfg }) => { - const accounts = listMattermostAccountIds(cfg) - .map((id) => resolveMattermostAccount({ cfg, accountId: id })) - .filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim()); - return accounts.length > 0 - ? { - properties: { - buttons: createMessageToolButtonsSchema(), - }, - } - : null; - }, handleAction: async ({ action, params, cfg, accountId }) => { if (action === "react") { // Check reactions gate: per-account config takes precedence over base config From cac1c62208837240cd46496b3d41bd469b1fcdd8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:07:03 +0000 Subject: [PATCH 170/393] Feishu: consolidate message tool discovery --- extensions/feishu/src/channel.ts | 47 ++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 7fe196ef6a7..ded06f97f53 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -2,6 +2,7 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { buildChannelConfigSchema, @@ -395,9 +396,24 @@ export const feishuPlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), }, actions: { - listActions: ({ cfg }) => { + describeMessageTool: ({ + cfg, + }: Parameters>[0]) => { + const enabled = + cfg.channels?.feishu?.enabled !== false && + Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)); if (listEnabledFeishuAccounts(cfg).length === 0) { - return []; + return { + actions: [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; } const actions = new Set([ "send", @@ -415,23 +431,18 @@ export const feishuPlugin: ChannelPlugin = { actions.add("react"); actions.add("reactions"); } - return Array.from(actions); + return { + actions: Array.from(actions), + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; }, - getCapabilities: ({ cfg }) => { - return cfg.channels?.feishu?.enabled !== false && - Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)) - ? (["cards"] as const) - : []; - }, - getToolSchema: ({ cfg }) => - cfg.channels?.feishu?.enabled !== false && - Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)) - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, handleAction: async (ctx) => { const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined }); if ( From da948a8073b4eea86d8c000ba4e88d542c268411 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:07:06 +0000 Subject: [PATCH 171/393] Teams: consolidate message tool discovery --- extensions/msteams/src/channel.ts | 35 +++++++++++++------------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d8e3de68527..827507c24f2 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,6 +1,7 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, @@ -369,30 +370,24 @@ export const msteamsPlugin: ChannelPlugin = { }, }, actions: { - listActions: ({ cfg }) => { + describeMessageTool: ({ + cfg, + }: Parameters>[0]) => { const enabled = cfg.channels?.msteams?.enabled !== false && Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); - if (!enabled) { - return []; - } - return ["poll"] satisfies ChannelMessageActionName[]; + return { + actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; }, - getCapabilities: ({ cfg }) => { - return cfg.channels?.msteams?.enabled !== false && - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) - ? (["cards"] as const) - : []; - }, - getToolSchema: ({ cfg }) => - cfg.channels?.msteams?.enabled !== false && - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, handleAction: async (ctx) => { // Handle send action with card parameter if (ctx.action === "send" && ctx.params.card) { From 951f3f992b683689993c1c3055dcb371990f2af7 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:15:58 +0000 Subject: [PATCH 172/393] Plugins: split message discovery and dispatch --- src/agents/channel-tools.ts | 10 +- src/agents/tools/message-tool.ts | 2 +- .../plugins/message-action-discovery.ts | 334 +++++++++++++++- .../plugins/message-action-dispatch.ts | 31 ++ .../plugins/message-actions.security.test.ts | 2 +- src/channels/plugins/message-actions.test.ts | 2 +- src/channels/plugins/message-actions.ts | 370 ------------------ src/infra/outbound/message-action-runner.ts | 2 +- .../outbound/outbound-send-service.test.ts | 2 +- src/infra/outbound/outbound-send-service.ts | 2 +- 10 files changed, 374 insertions(+), 383 deletions(-) create mode 100644 src/channels/plugins/message-action-dispatch.ts delete mode 100644 src/channels/plugins/message-actions.ts diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 8596d3e8471..c94204e8802 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,12 +1,10 @@ import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import { createMessageActionDiscoveryContext, - resolveMessageActionDiscoveryChannelId, -} from "../channels/plugins/message-action-discovery.js"; -import { - __testing as messageActionTesting, resolveMessageActionDiscoveryForPlugin, -} from "../channels/plugins/message-actions.js"; + resolveMessageActionDiscoveryChannelId, + __testing as messageActionTesting, +} from "../channels/plugins/message-action-discovery.js"; import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -32,7 +30,7 @@ export function listChannelSupportedActions(params: { return []; } const plugin = getChannelPlugin(channelId as Parameters[0]); - if (!plugin?.actions?.listActions) { + if (!plugin?.actions) { return []; } return resolveMessageActionDiscoveryForPlugin({ diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 7fff178b933..77703d8ee75 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -5,7 +5,7 @@ import { channelSupportsMessageCapabilityForChannel, listChannelMessageActions, resolveChannelMessageToolSchemaProperties, -} from "../../channels/plugins/message-actions.js"; +} from "../../channels/plugins/message-action-discovery.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import { CHANNEL_MESSAGE_ACTION_NAMES, diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 5825219f6dc..d54aec45679 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -1,6 +1,15 @@ +import type { TSchema } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; import { normalizeAnyChannelId } from "../registry.js"; -import type { ChannelMessageActionDiscoveryContext } from "./types.js"; +import { getChannelPlugin, listChannelPlugins } from "./index.js"; +import type { ChannelMessageCapability } from "./message-capabilities.js"; +import type { + ChannelMessageActionDiscoveryContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, +} from "./types.js"; export type ChannelMessageActionDiscoveryInput = { cfg?: OpenClawConfig; @@ -16,6 +25,10 @@ export type ChannelMessageActionDiscoveryInput = { requesterSenderId?: string | null; }; +type ChannelActions = NonNullable>["actions"]>; + +const loggedMessageActionErrors = new Set(); + export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined { const normalized = normalizeAnyChannelId(raw); if (normalized) { @@ -44,3 +57,322 @@ export function createMessageActionDiscoveryContext( requesterSenderId: params.requesterSenderId, }; } + +function logMessageActionError(params: { + pluginId: string; + operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; + error: unknown; +}) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:${params.operation}:${message}`; + if (loggedMessageActionErrors.has(key)) { + return; + } + loggedMessageActionErrors.add(key); + const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; + defaultRuntime.error?.( + `[message-action-discovery] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`, + ); +} + +function runListActionsSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + listActions: NonNullable; +}): ChannelMessageActionName[] { + try { + const listed = params.listActions(params.context); + return Array.isArray(listed) ? listed : []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "listActions", + error, + }); + return []; + } +} + +function describeMessageToolSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + describeMessageTool: NonNullable; +}): ChannelMessageToolDiscovery | null { + try { + return params.describeMessageTool(params.context) ?? null; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "describeMessageTool", + error, + }); + return null; + } +} + +function listCapabilitiesSafely(params: { + pluginId: string; + actions: ChannelActions; + context: ChannelMessageActionDiscoveryContext; +}): readonly ChannelMessageCapability[] { + try { + return params.actions.getCapabilities?.(params.context) ?? []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getCapabilities", + error, + }); + return []; + } +} + +function runGetToolSchemaSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + getToolSchema: NonNullable; +}): + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined { + try { + return params.getToolSchema(params.context); + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getToolSchema", + error, + }); + return null; + } +} + +function normalizeToolSchemaContributions( + value: + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined, +): ChannelMessageToolSchemaContribution[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +type ResolvedChannelMessageActionDiscovery = { + actions: ChannelMessageActionName[]; + capabilities: readonly ChannelMessageCapability[]; + schemaContributions: ChannelMessageToolSchemaContribution[]; +}; + +export function resolveMessageActionDiscoveryForPlugin(params: { + pluginId: string; + actions?: ChannelActions; + context: ChannelMessageActionDiscoveryContext; + includeActions?: boolean; + includeCapabilities?: boolean; + includeSchema?: boolean; +}): ResolvedChannelMessageActionDiscovery { + const adapter = params.actions; + if (!adapter) { + return { + actions: [], + capabilities: [], + schemaContributions: [], + }; + } + + if (adapter.describeMessageTool) { + const described = describeMessageToolSafely({ + pluginId: params.pluginId, + context: params.context, + describeMessageTool: adapter.describeMessageTool, + }); + return { + actions: + params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [], + capabilities: + params.includeCapabilities && Array.isArray(described?.capabilities) + ? described.capabilities + : [], + schemaContributions: params.includeSchema + ? normalizeToolSchemaContributions(described?.schema) + : [], + }; + } + + return { + actions: + params.includeActions && adapter.listActions + ? runListActionsSafely({ + pluginId: params.pluginId, + context: params.context, + listActions: adapter.listActions, + }) + : [], + capabilities: + params.includeCapabilities && adapter.getCapabilities + ? listCapabilitiesSafely({ + pluginId: params.pluginId, + actions: adapter, + context: params.context, + }) + : [], + schemaContributions: + params.includeSchema && adapter.getToolSchema + ? normalizeToolSchemaContributions( + runGetToolSchemaSafely({ + pluginId: params.pluginId, + context: params.context, + getToolSchema: adapter.getToolSchema, + }), + ) + : [], + }; +} + +export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { + const actions = new Set(["send", "broadcast"]); + for (const plugin of listChannelPlugins()) { + for (const action of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeActions: true, + }).actions) { + actions.add(action); + } + } + return Array.from(actions); +} + +export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { + const capabilities = new Set(); + for (const plugin of listChannelPlugins()) { + for (const capability of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeCapabilities: true, + }).capabilities) { + capabilities.add(capability); + } + } + return Array.from(capabilities); +} + +export function listChannelMessageCapabilitiesForChannel(params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}): ChannelMessageCapability[] { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { + return []; + } + const plugin = getChannelPlugin(channelId as Parameters[0]); + return plugin?.actions + ? Array.from( + resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext(params), + includeCapabilities: true, + }).capabilities, + ) + : []; +} + +function mergeToolSchemaProperties( + target: Record, + source: Record | undefined, +) { + if (!source) { + return; + } + for (const [name, schema] of Object.entries(source)) { + if (!(name in target)) { + target[name] = schema; + } + } +} + +export function resolveChannelMessageToolSchemaProperties(params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}): Record { + const properties: Record = {}; + const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); + const discoveryBase = createMessageActionDiscoveryContext(params); + + for (const plugin of listChannelPlugins()) { + if (!plugin.actions) { + continue; + } + for (const contribution of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: discoveryBase, + includeSchema: true, + }).schemaContributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (currentChannel) { + if (visibility === "all-configured" || plugin.id === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); + } + continue; + } + mergeToolSchemaProperties(properties, contribution.properties); + } + } + + return properties; +} + +export function channelSupportsMessageCapability( + cfg: OpenClawConfig, + capability: ChannelMessageCapability, +): boolean { + return listChannelMessageCapabilities(cfg).includes(capability); +} + +export function channelSupportsMessageCapabilityForChannel( + params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; + }, + capability: ChannelMessageCapability, +): boolean { + return listChannelMessageCapabilitiesForChannel(params).includes(capability); +} + +export const __testing = { + resetLoggedMessageActionErrors() { + loggedMessageActionErrors.clear(); + }, +}; diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts new file mode 100644 index 00000000000..ab1ddef5235 --- /dev/null +++ b/src/channels/plugins/message-action-dispatch.ts @@ -0,0 +1,31 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { getChannelPlugin } from "./index.js"; +import type { ChannelMessageActionContext } from "./types.js"; + +function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { + const plugin = getChannelPlugin(ctx.channel); + return Boolean( + plugin?.actions?.requiresTrustedRequesterSender?.({ + action: ctx.action, + toolContext: ctx.toolContext, + }), + ); +} + +export async function dispatchChannelMessageAction( + ctx: ChannelMessageActionContext, +): Promise | null> { + if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { + throw new Error( + `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, + ); + } + const plugin = getChannelPlugin(ctx.channel); + if (!plugin?.actions?.handleAction) { + return null; + } + if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { + return null; + } + return await plugin.actions.handleAction(ctx); +} diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index b8b62afdecd..ed178a9e2fa 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -6,7 +6,7 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; -import { dispatchChannelMessageAction } from "./message-actions.js"; +import { dispatchChannelMessageAction } from "./message-action-dispatch.js"; import type { ChannelPlugin } from "./types.js"; const handleAction = vi.fn(async () => jsonResult({ ok: true })); diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 13a28e098db..396b82a498c 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -15,7 +15,7 @@ import { listChannelMessageCapabilities, listChannelMessageCapabilitiesForChannel, resolveChannelMessageToolSchemaProperties, -} from "./message-actions.js"; +} from "./message-action-discovery.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelPlugin } from "./types.js"; diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts deleted file mode 100644 index 8bf765ea81f..00000000000 --- a/src/channels/plugins/message-actions.ts +++ /dev/null @@ -1,370 +0,0 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { TSchema } from "@sinclair/typebox"; -import type { OpenClawConfig } from "../../config/config.js"; -import { defaultRuntime } from "../../runtime.js"; -import { getChannelPlugin, listChannelPlugins } from "./index.js"; -import { - createMessageActionDiscoveryContext, - resolveMessageActionDiscoveryChannelId, -} from "./message-action-discovery.js"; -import type { ChannelMessageCapability } from "./message-capabilities.js"; -import type { - ChannelMessageActionContext, - ChannelMessageActionDiscoveryContext, - ChannelMessageActionName, - ChannelMessageToolDiscovery, - ChannelMessageToolSchemaContribution, -} from "./types.js"; - -type ChannelActions = NonNullable>["actions"]>; - -function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { - const plugin = getChannelPlugin(ctx.channel); - return Boolean( - plugin?.actions?.requiresTrustedRequesterSender?.({ - action: ctx.action, - toolContext: ctx.toolContext, - }), - ); -} - -const loggedMessageActionErrors = new Set(); - -function logMessageActionError(params: { - pluginId: string; - operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; - error: unknown; -}) { - const message = params.error instanceof Error ? params.error.message : String(params.error); - const key = `${params.pluginId}:${params.operation}:${message}`; - if (loggedMessageActionErrors.has(key)) { - return; - } - loggedMessageActionErrors.add(key); - const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; - defaultRuntime.error?.( - `[message-actions] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`, - ); -} - -function runListActionsSafely(params: { - pluginId: string; - context: ChannelMessageActionDiscoveryContext; - listActions: NonNullable; -}): ChannelMessageActionName[] { - try { - const listed = params.listActions(params.context); - return Array.isArray(listed) ? listed : []; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "listActions", - error, - }); - return []; - } -} - -function describeMessageToolSafely(params: { - pluginId: string; - context: ChannelMessageActionDiscoveryContext; - describeMessageTool: NonNullable; -}): ChannelMessageToolDiscovery | null { - try { - return params.describeMessageTool(params.context) ?? null; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "describeMessageTool", - error, - }); - return null; - } -} - -function listCapabilities(params: { - pluginId: string; - actions: ChannelActions; - context: ChannelMessageActionDiscoveryContext; -}): readonly ChannelMessageCapability[] { - try { - return params.actions.getCapabilities?.(params.context) ?? []; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "getCapabilities", - error, - }); - return []; - } -} - -function normalizeToolSchemaContributions( - value: - | ChannelMessageToolSchemaContribution - | ChannelMessageToolSchemaContribution[] - | null - | undefined, -): ChannelMessageToolSchemaContribution[] { - if (!value) { - return []; - } - return Array.isArray(value) ? value : [value]; -} - -type ResolvedChannelMessageActionDiscovery = { - actions: ChannelMessageActionName[]; - capabilities: readonly ChannelMessageCapability[]; - schemaContributions: ChannelMessageToolSchemaContribution[]; -}; - -export function resolveMessageActionDiscoveryForPlugin(params: { - pluginId: string; - actions?: ChannelActions; - context: ChannelMessageActionDiscoveryContext; - includeActions?: boolean; - includeCapabilities?: boolean; - includeSchema?: boolean; -}): ResolvedChannelMessageActionDiscovery { - const adapter = params.actions; - if (!adapter) { - return { - actions: [], - capabilities: [], - schemaContributions: [], - }; - } - - if (adapter.describeMessageTool) { - const described = describeMessageToolSafely({ - pluginId: params.pluginId, - context: params.context, - describeMessageTool: adapter.describeMessageTool, - }); - return { - actions: - params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [], - capabilities: - params.includeCapabilities && Array.isArray(described?.capabilities) - ? described.capabilities - : [], - schemaContributions: params.includeSchema - ? normalizeToolSchemaContributions(described?.schema) - : [], - }; - } - - return { - actions: - params.includeActions && adapter.listActions - ? runListActionsSafely({ - pluginId: params.pluginId, - context: params.context, - listActions: adapter.listActions, - }) - : [], - capabilities: - params.includeCapabilities && adapter.getCapabilities - ? listCapabilities({ - pluginId: params.pluginId, - actions: adapter, - context: params.context, - }) - : [], - schemaContributions: - params.includeSchema && adapter.getToolSchema - ? normalizeToolSchemaContributions( - runGetToolSchemaSafely({ - pluginId: params.pluginId, - context: params.context, - getToolSchema: adapter.getToolSchema, - }), - ) - : [], - }; -} - -export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { - const actions = new Set(["send", "broadcast"]); - for (const plugin of listChannelPlugins()) { - for (const action of resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: { cfg }, - includeActions: true, - }).actions) { - actions.add(action); - } - } - return Array.from(actions); -} - -function runGetToolSchemaSafely(params: { - pluginId: string; - context: ChannelMessageActionDiscoveryContext; - getToolSchema: NonNullable; -}): - | ChannelMessageToolSchemaContribution - | ChannelMessageToolSchemaContribution[] - | null - | undefined { - try { - return params.getToolSchema(params.context); - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "getToolSchema", - error, - }); - return null; - } -} - -export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { - const capabilities = new Set(); - for (const plugin of listChannelPlugins()) { - for (const capability of resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: { cfg }, - includeCapabilities: true, - }).capabilities) { - capabilities.add(capability); - } - } - return Array.from(capabilities); -} - -export function listChannelMessageCapabilitiesForChannel(params: { - cfg: OpenClawConfig; - channel?: string; - currentChannelId?: string | null; - currentThreadTs?: string | null; - currentMessageId?: string | number | null; - accountId?: string | null; - sessionKey?: string | null; - sessionId?: string | null; - agentId?: string | null; - requesterSenderId?: string | null; -}): ChannelMessageCapability[] { - const channelId = resolveMessageActionDiscoveryChannelId(params.channel); - if (!channelId) { - return []; - } - const plugin = getChannelPlugin(channelId as Parameters[0]); - return plugin?.actions - ? Array.from( - resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: createMessageActionDiscoveryContext(params), - includeCapabilities: true, - }).capabilities, - ) - : []; -} - -function mergeToolSchemaProperties( - target: Record, - source: Record | undefined, -) { - if (!source) { - return; - } - for (const [name, schema] of Object.entries(source)) { - if (!(name in target)) { - target[name] = schema; - } - } -} - -export function resolveChannelMessageToolSchemaProperties(params: { - cfg: OpenClawConfig; - channel?: string; - currentChannelId?: string | null; - currentThreadTs?: string | null; - currentMessageId?: string | number | null; - accountId?: string | null; - sessionKey?: string | null; - sessionId?: string | null; - agentId?: string | null; - requesterSenderId?: string | null; -}): Record { - const properties: Record = {}; - const plugins = listChannelPlugins(); - const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); - const discoveryBase: ChannelMessageActionDiscoveryContext = - createMessageActionDiscoveryContext(params); - - for (const plugin of plugins) { - if (!plugin?.actions) { - continue; - } - for (const contribution of resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: discoveryBase, - includeSchema: true, - }).schemaContributions) { - const visibility = contribution.visibility ?? "current-channel"; - if (currentChannel) { - if (visibility === "all-configured" || plugin.id === currentChannel) { - mergeToolSchemaProperties(properties, contribution.properties); - } - continue; - } - mergeToolSchemaProperties(properties, contribution.properties); - } - } - - return properties; -} - -export function channelSupportsMessageCapability( - cfg: OpenClawConfig, - capability: ChannelMessageCapability, -): boolean { - return listChannelMessageCapabilities(cfg).includes(capability); -} - -export function channelSupportsMessageCapabilityForChannel( - params: { - cfg: OpenClawConfig; - channel?: string; - currentChannelId?: string | null; - currentThreadTs?: string | null; - currentMessageId?: string | number | null; - accountId?: string | null; - sessionKey?: string | null; - sessionId?: string | null; - agentId?: string | null; - requesterSenderId?: string | null; - }, - capability: ChannelMessageCapability, -): boolean { - return listChannelMessageCapabilitiesForChannel(params).includes(capability); -} - -export async function dispatchChannelMessageAction( - ctx: ChannelMessageActionContext, -): Promise | null> { - if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { - throw new Error( - `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, - ); - } - const plugin = getChannelPlugin(ctx.channel); - if (!plugin?.actions?.handleAction) { - return null; - } - if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { - return null; - } - return await plugin.actions.handleAction(ctx); -} - -export const __testing = { - resetLoggedMessageActionErrors() { - loggedMessageActionErrors.clear(); - }, -}; diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 29afdadbdf3..70646a288a2 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -7,7 +7,7 @@ import { } from "../../agents/tools/common.js"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; -import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { ChannelId, ChannelMessageActionName, diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index f5d1f2b9b28..3f3fd0f2fcc 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), })); -vi.mock("../../channels/plugins/message-actions.js", () => ({ +vi.mock("../../channels/plugins/message-action-dispatch.js", () => ({ dispatchChannelMessageAction: mocks.dispatchChannelMessageAction, })); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index c583a1ace91..5d518798afa 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -1,5 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js"; From 7dabcf287db205795a7ed5f15a8ea84968d841c1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:16:02 +0000 Subject: [PATCH 173/393] Agents: align compact message discovery scope --- src/agents/pi-embedded-runner/compact.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 7893f51b70c..66320fb0b21 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -113,6 +113,10 @@ export type CompactEmbeddedPiSessionParams = { messageChannel?: string; messageProvider?: string; agentAccountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + requesterSenderId?: string; authProfileId?: string; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; @@ -658,10 +662,14 @@ export async function compactEmbeddedPiSessionDirect( ? listChannelSupportedActions({ cfg: params.config, channel: runtimeChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, accountId: params.agentAccountId, sessionKey: params.sessionKey, sessionId: params.sessionId, agentId: sessionAgentId, + requesterSenderId: params.requesterSenderId, }) : undefined; const messageToolHints = runtimeChannel From ab1da26f4dbda2c35a2b6ae1066511fcd5f2b97e Mon Sep 17 00:00:00 2001 From: Brian Ernesto Date: Tue, 17 Mar 2026 18:29:11 -0600 Subject: [PATCH 174/393] fix(macos): show sessions after controls in tray menu (#38079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): show sessions after controls in tray menu When many sessions are active, the injected session rows push the toggles, action buttons, and settings items off-screen, requiring a scroll to reach them. Change findInsertIndex and findNodesInsertIndex to anchor just before the separator above 'Settings…' instead of before 'Send Heartbeats'. This ensures the controls section is always immediately visible on menu open, with sessions appearing below. * refactor: extract findAnchoredInsertIndex to eliminate duplication findInsertIndex and findNodesInsertIndex shared identical logic. Extract into a single private helper so any future anchor change (e.g. Settings item title) only needs one edit. * macOS: use structural tray menu anchor --------- Co-authored-by: Brian Ernesto Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenClaw/MenuSessionsInjector.swift | 47 ++++++++++--------- .../MenuSessionsInjectorTests.swift | 35 +++++++++++++- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 662d3fc6d13..4592c1ae307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -647,6 +647,7 @@ Docs: https://docs.openclaw.ai - Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev. - Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai. - Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune. +- macOS/tray menu: keep injected sessions and device rows below the controls section so toggles and action buttons stay visible even when many sessions are active. (#38079) Thanks @bernesto. - Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai. - Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd. - Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd. diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index eb6271d0a8c..9f667cc6239 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -1099,38 +1099,33 @@ extension MenuSessionsInjector { // MARK: - Width + placement private func findInsertIndex(in menu: NSMenu) -> Int? { - // Insert right before the separator above "Send Heartbeats". - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[..= 1 { return 1 } - return menu.items.count + self.findDynamicSectionInsertIndex(in: menu) } private func findNodesInsertIndex(in menu: NSMenu) -> Int? { - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[.. Int? { + // Keep controls and action buttons visible by inserting dynamic rows at the + // built-in footer boundary, not by matching localized menu item titles. + if let footerSeparatorIndex = menu.items.lastIndex(where: { item in + item.isSeparatorItem && !self.isInjectedItem(item) + }) { + return footerSeparatorIndex } - if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) { - return sepIdx + if let firstBaseItemIndex = menu.items.firstIndex(where: { !self.isInjectedItem($0) }) { + return min(firstBaseItemIndex + 1, menu.items.count) } - if menu.items.count >= 1 { return 1 } return menu.items.count } + private func isInjectedItem(_ item: NSMenuItem) -> Bool { + item.tag == self.tag || item.tag == self.nodesTag + } + private func initialWidth(for menu: NSMenu) -> CGFloat { if let openWidth = self.menuOpenWidth { return max(300, openWidth) @@ -1236,5 +1231,13 @@ extension MenuSessionsInjector { func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } + + func testingFindInsertIndex(in menu: NSMenu) -> Int? { + self.findInsertIndex(in: menu) + } + + func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? { + self.findNodesInsertIndex(in: menu) + } } #endif diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 186675f1eea..b1d01b9650e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -5,7 +5,26 @@ import Testing @Suite(.serialized) @MainActor struct MenuSessionsInjectorTests { - @Test func `injects disconnected message`() { + @Test func anchorsDynamicRowsBelowControlsAndActions() throws { + let injector = MenuSessionsInjector() + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) + + let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem })) + #expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex) + #expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex) + } + + @Test func injectsDisconnectedMessage() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(false) injector.setTestingSnapshot(nil, errorText: nil) @@ -19,7 +38,7 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) } - @Test func `injects session rows`() { + @Test func injectsSessionRows() throws { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) @@ -88,10 +107,22 @@ struct MenuSessionsInjectorTests { menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) #expect(menu.items.contains { $0.tag == 9_415_557 }) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) + let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" })) + let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" })) + let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 })) + let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" })) + #expect(sendHeartbeatsIndex < firstInjectedIndex) + #expect(openDashboardIndex < firstInjectedIndex) + #expect(firstInjectedIndex < settingsIndex) } @Test func `cost usage submenu does not use injector delegate`() { From ab62f3b9f411b742a873485eef0969a2fdef6a1c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:32:51 +0000 Subject: [PATCH 175/393] Agents: route embedded discovery and compaction ids --- src/agents/pi-embedded-runner/compact.ts | 30 ++++---- .../compaction-runtime-context.test.ts | 77 +++++++++++++++++++ .../compaction-runtime-context.ts | 76 ++++++++++++++++++ .../message-action-discovery-input.test.ts | 58 ++++++++++++++ .../message-action-discovery-input.ts | 27 +++++++ src/agents/pi-embedded-runner/run.ts | 41 ++++++---- .../pi-embedded-runner/run/attempt.test.ts | 34 ++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 41 ++++++---- 8 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 src/agents/pi-embedded-runner/compaction-runtime-context.test.ts create mode 100644 src/agents/pi-embedded-runner/compaction-runtime-context.ts create mode 100644 src/agents/pi-embedded-runner/message-action-discovery-input.test.ts create mode 100644 src/agents/pi-embedded-runner/message-action-discovery-input.ts diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 66320fb0b21..8c46de5c165 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -91,6 +91,7 @@ import { import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; +import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js"; import { buildModelAliasLines, resolveModelAsync } from "./model.js"; import { buildEmbeddedSandboxInfo } from "./sandbox-info.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; @@ -116,7 +117,8 @@ export type CompactEmbeddedPiSessionParams = { currentChannelId?: string; currentThreadTs?: string; currentMessageId?: string | number; - requesterSenderId?: string; + /** Trusted sender id from inbound context for scoped message-tool discovery. */ + senderId?: string; authProfileId?: string; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; @@ -659,18 +661,20 @@ export async function compactEmbeddedPiSessionDirect( }); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel - ? listChannelSupportedActions({ - cfg: params.config, - channel: runtimeChannel, - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.agentAccountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: sessionAgentId, - requesterSenderId: params.requesterSenderId, - }) + ? listChannelSupportedActions( + buildEmbeddedMessageActionDiscoveryInput({ + cfg: params.config, + channel: runtimeChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, + senderId: params.senderId, + }), + ) : undefined; const messageToolHints = runtimeChannel ? resolveChannelMessageToolHints({ diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts b/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts new file mode 100644 index 00000000000..9c87bfc6aaf --- /dev/null +++ b/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; + +describe("buildEmbeddedCompactionRuntimeContext", () => { + it("preserves sender and current message routing for compaction", () => { + expect( + buildEmbeddedCompactionRuntimeContext({ + sessionKey: "agent:main:thread:1", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + config: {} as OpenClawConfig, + senderIsOwner: true, + senderId: "user-123", + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }), + ).toMatchObject({ + sessionKey: "agent:main:thread:1", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + senderId: "user-123", + provider: "openai-codex", + model: "gpt-5.3-codex", + }); + }); + + it("normalizes nullable compaction routing fields to undefined", () => { + expect( + buildEmbeddedCompactionRuntimeContext({ + sessionKey: null, + messageChannel: null, + messageProvider: null, + agentAccountId: null, + currentChannelId: null, + currentThreadTs: null, + currentMessageId: null, + authProfileId: null, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + senderId: null, + provider: null, + modelId: null, + }), + ).toMatchObject({ + sessionKey: undefined, + messageChannel: undefined, + messageProvider: undefined, + agentAccountId: undefined, + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + authProfileId: undefined, + senderId: undefined, + provider: undefined, + model: undefined, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.ts b/src/agents/pi-embedded-runner/compaction-runtime-context.ts new file mode 100644 index 00000000000..5f64089f63b --- /dev/null +++ b/src/agents/pi-embedded-runner/compaction-runtime-context.ts @@ -0,0 +1,76 @@ +import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ExecElevatedDefaults } from "../bash-tools.js"; +import type { SkillSnapshot } from "../skills.js"; + +export type EmbeddedCompactionRuntimeContext = { + sessionKey?: string; + messageChannel?: string; + messageProvider?: string; + agentAccountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + authProfileId?: string; + workspaceDir: string; + agentDir: string; + config?: OpenClawConfig; + skillsSnapshot?: SkillSnapshot; + senderIsOwner?: boolean; + senderId?: string; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + reasoningLevel?: ReasoningLevel; + bashElevated?: ExecElevatedDefaults; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}; + +export function buildEmbeddedCompactionRuntimeContext(params: { + sessionKey?: string | null; + messageChannel?: string | null; + messageProvider?: string | null; + agentAccountId?: string | null; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + authProfileId?: string | null; + workspaceDir: string; + agentDir: string; + config?: OpenClawConfig; + skillsSnapshot?: SkillSnapshot; + senderIsOwner?: boolean; + senderId?: string | null; + provider?: string | null; + modelId?: string | null; + thinkLevel?: ThinkLevel; + reasoningLevel?: ReasoningLevel; + bashElevated?: ExecElevatedDefaults; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}): EmbeddedCompactionRuntimeContext { + return { + sessionKey: params.sessionKey ?? undefined, + messageChannel: params.messageChannel ?? undefined, + messageProvider: params.messageProvider ?? undefined, + agentAccountId: params.agentAccountId ?? undefined, + currentChannelId: params.currentChannelId ?? undefined, + currentThreadTs: params.currentThreadTs ?? undefined, + currentMessageId: params.currentMessageId ?? undefined, + authProfileId: params.authProfileId ?? undefined, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId ?? undefined, + provider: params.provider ?? undefined, + model: params.modelId ?? undefined, + thinkLevel: params.thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + }; +} diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts new file mode 100644 index 00000000000..7b2acd199c0 --- /dev/null +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js"; + +describe("buildEmbeddedMessageActionDiscoveryInput", () => { + it("maps sender and routing scope into message-action discovery context", () => { + expect( + buildEmbeddedMessageActionDiscoveryInput({ + channel: "telegram", + currentChannelId: "chat-1", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + accountId: "acct-1", + sessionKey: "agent:main:thread:1", + sessionId: "session-1", + agentId: "main", + senderId: "user-123", + }), + ).toEqual({ + cfg: undefined, + channel: "telegram", + currentChannelId: "chat-1", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + accountId: "acct-1", + sessionKey: "agent:main:thread:1", + sessionId: "session-1", + agentId: "main", + requesterSenderId: "user-123", + }); + }); + + it("normalizes nullable routing fields to undefined", () => { + expect( + buildEmbeddedMessageActionDiscoveryInput({ + channel: "slack", + currentChannelId: null, + currentThreadTs: null, + currentMessageId: null, + accountId: null, + sessionKey: null, + sessionId: null, + agentId: null, + senderId: null, + }), + ).toEqual({ + cfg: undefined, + channel: "slack", + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + accountId: undefined, + sessionKey: undefined, + sessionId: undefined, + agentId: undefined, + requesterSenderId: undefined, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.ts new file mode 100644 index 00000000000..3002e90d357 --- /dev/null +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.ts @@ -0,0 +1,27 @@ +import type { OpenClawConfig } from "../../config/config.js"; + +export function buildEmbeddedMessageActionDiscoveryInput(params: { + cfg?: OpenClawConfig; + channel: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + senderId?: string | null; +}) { + return { + cfg: params.cfg, + channel: params.channel, + currentChannelId: params.currentChannelId ?? undefined, + currentThreadTs: params.currentThreadTs ?? undefined, + currentMessageId: params.currentMessageId ?? undefined, + accountId: params.accountId ?? undefined, + sessionKey: params.sessionKey ?? undefined, + sessionId: params.sessionId ?? undefined, + agentId: params.agentId ?? undefined, + requesterSenderId: params.senderId ?? undefined, + }; +} diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 3f41357f0e5..a35c03d98ca 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -65,6 +65,7 @@ import { import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; +import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModelAsync } from "./model.js"; @@ -1141,24 +1142,30 @@ export async function runEmbeddedPiAgent( force: true, compactionTarget: "budget", runtimeContext: { - sessionKey: params.sessionKey, - messageChannel: params.messageChannel, - messageProvider: params.messageProvider, - agentAccountId: params.agentAccountId, - authProfileId: lastProfileId, - workspaceDir: resolvedWorkspace, - agentDir, - config: params.config, - skillsSnapshot: params.skillsSnapshot, - senderIsOwner: params.senderIsOwner, - provider, - model: modelId, + ...buildEmbeddedCompactionRuntimeContext({ + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + messageProvider: params.messageProvider, + agentAccountId: params.agentAccountId, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + authProfileId: lastProfileId, + workspaceDir: resolvedWorkspace, + agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId, + provider, + modelId, + thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + }), runId: params.runId, - thinkLevel, - reasoningLevel: params.reasoningLevel, - bashElevated: params.bashElevated, - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, trigger: "overflow", ...(observedOverflowTokens !== undefined ? { currentTokenCount: observedOverflowTokens } diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index d18966af421..20bf752587b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1249,4 +1249,38 @@ describe("buildAfterTurnRuntimeContext", () => { agentDir: "/tmp/agent", }); }); + + it("preserves sender and channel routing context for scoped compaction discovery", () => { + const legacy = buildAfterTurnRuntimeContext({ + attempt: { + sessionKey: "agent:main:session:abc", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + config: {} as OpenClawConfig, + skillsSnapshot: undefined, + senderIsOwner: true, + senderId: "user-123", + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + + expect(legacy).toMatchObject({ + senderId: "user-123", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + }); + }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 0fa03797a60..9a46beca5d2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -101,6 +101,7 @@ import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js"; import type { CompactEmbeddedPiSessionParams } from "../compact.js"; +import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent } from "../extra-params.js"; @@ -111,6 +112,7 @@ import { } from "../google.js"; import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js"; import { log } from "../logger.js"; +import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; import { buildModelAliasLines } from "../model.js"; import { clearActiveEmbeddedRun, @@ -1280,9 +1282,13 @@ export function buildAfterTurnRuntimeContext(params: { | "messageChannel" | "messageProvider" | "agentAccountId" + | "currentChannelId" + | "currentThreadTs" + | "currentMessageId" | "config" | "skillsSnapshot" | "senderIsOwner" + | "senderId" | "provider" | "modelId" | "thinkLevel" @@ -1295,25 +1301,29 @@ export function buildAfterTurnRuntimeContext(params: { workspaceDir: string; agentDir: string; }): Partial { - return { + return buildEmbeddedCompactionRuntimeContext({ sessionKey: params.attempt.sessionKey, messageChannel: params.attempt.messageChannel, messageProvider: params.attempt.messageProvider, agentAccountId: params.attempt.agentAccountId, + currentChannelId: params.attempt.currentChannelId, + currentThreadTs: params.attempt.currentThreadTs, + currentMessageId: params.attempt.currentMessageId, authProfileId: params.attempt.authProfileId, workspaceDir: params.workspaceDir, agentDir: params.agentDir, config: params.attempt.config, skillsSnapshot: params.attempt.skillsSnapshot, senderIsOwner: params.attempt.senderIsOwner, + senderId: params.attempt.senderId, provider: params.attempt.provider, - model: params.attempt.modelId, + modelId: params.attempt.modelId, thinkLevel: params.attempt.thinkLevel, reasoningLevel: params.attempt.reasoningLevel, bashElevated: params.attempt.bashElevated, extraSystemPrompt: params.attempt.extraSystemPrompt, ownerNumbers: params.attempt.ownerNumbers, - }; + }); } function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { @@ -1620,17 +1630,20 @@ export async function runEmbeddedAttempt( const reasoningTagHint = isReasoningTagProvider(params.provider); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel - ? listChannelSupportedActions({ - cfg: params.config, - channel: runtimeChannel, - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.agentAccountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: sessionAgentId, - }) + ? listChannelSupportedActions( + buildEmbeddedMessageActionDiscoveryInput({ + cfg: params.config, + channel: runtimeChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, + senderId: params.senderId, + }), + ) : undefined; const messageToolHints = runtimeChannel ? resolveChannelMessageToolHints({ From f2de673130ad2cd159e3569a90c7f434bd0b366d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:47:46 +0000 Subject: [PATCH 176/393] Docs: clarify plugin-owned message discovery --- docs/help/testing.md | 11 +++++++++++ docs/tools/plugin.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/docs/help/testing.md b/docs/help/testing.md index 2055db4373f..0d14f507bc9 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -52,6 +52,17 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Runs in CI - No real keys required - Should be fast and stable +- Embedded runner note: + - When you change message-tool discovery inputs or compaction runtime context, + keep both levels of coverage. + - Add focused helper regressions for pure routing/normalization seams. + - Also keep the embedded runner integration suites healthy: + `src/agents/pi-embedded-runner/compact.hooks.test.ts`, + `src/agents/pi-embedded-runner/run.overflow-compaction.test.ts`, and + `src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts`. + - Those suites verify that scoped ids and compaction behavior still flow + through the real `run.ts` / `compact.ts` paths; helper-only tests are not a + sufficient substitute for those seams. - Pool note: - OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards. - On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 27979dcb125..af4cd1bf6ac 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -188,6 +188,46 @@ The important design boundary: That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active. +### Channel plugins and the shared message tool + +Channel plugins do not need to register a separate send/edit/react tool for +normal chat actions. OpenClaw keeps one shared `message` tool in core, and +channel plugins own the channel-specific discovery and execution behind it. + +The current boundary is: + +- core owns the shared `message` tool host, prompt wiring, session/thread + bookkeeping, and execution dispatch +- channel plugins own scoped action discovery, capability discovery, and any + channel-specific schema fragments +- channel plugins execute the final action through their action adapter + +For channel plugins, the preferred SDK surface is +`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery +call lets a plugin return its visible actions, capabilities, and schema +contributions together so those pieces do not drift apart. + +Core passes runtime scope into that discovery step. Important fields include: + +- `accountId` +- `currentChannelId` +- `currentThreadTs` +- `currentMessageId` +- `sessionKey` +- `sessionId` +- `agentId` +- trusted inbound `requesterSenderId` + +That matters for context-sensitive plugins. A channel can hide or expose +message actions based on the active account, current room/thread/message, or +trusted requester identity without hardcoding channel-specific branches in the +core `message` tool. + +This is why embedded-runner routing changes are still plugin work: the runner is +responsible for forwarding the current chat/session identity into the plugin +discovery boundary so the shared `message` tool exposes the right channel-owned +surface for the current turn. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a From 53df7ff86d19100b7cab90c7dddcc2b94bce5269 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:06:43 +0000 Subject: [PATCH 177/393] Agents: stabilize overflow runner test harness --- .../run.overflow-compaction.harness.ts | 406 ++++++++++++++++++ .../run.overflow-compaction.loop.test.ts | 55 +-- .../run.overflow-compaction.mocks.shared.ts | 292 ------------- .../run.overflow-compaction.shared-test.ts | 30 -- .../run.overflow-compaction.test.ts | 30 +- .../sessions-yield.orchestration.test.ts | 17 +- .../usage-reporting.test.ts | 32 +- 7 files changed, 479 insertions(+), 383 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts delete mode 100644 src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts delete mode 100644 src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts new file mode 100644 index 00000000000..9e7853ef7d5 --- /dev/null +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts @@ -0,0 +1,406 @@ +import { vi, type Mock } from "vitest"; +import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { + PluginHookAgentContext, + PluginHookBeforeAgentStartResult, + PluginHookBeforeModelResolveResult, + PluginHookBeforePromptBuildResult, +} from "../../plugins/types.js"; +import type { EmbeddedRunAttemptResult } from "./run/types.js"; + +type MockCompactionResult = + | { + ok: true; + compacted: true; + result: { + summary: string; + firstKeptEntryId?: string; + tokensBefore?: number; + tokensAfter?: number; + }; + reason?: string; + } + | { + ok: false; + compacted: false; + reason: string; + result?: undefined; + }; + +export const mockedGlobalHookRunner = { + hasHooks: vi.fn((_hookName: string) => false), + runBeforeAgentStart: vi.fn( + async ( + _event: { prompt: string; messages?: unknown[] }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforePromptBuild: vi.fn( + async ( + _event: { prompt: string; messages: unknown[] }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforeModelResolve: vi.fn( + async ( + _event: { prompt: string }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforeCompaction: vi.fn(async () => undefined), + runAfterCompaction: vi.fn(async () => undefined), +}; + +export const mockedContextEngine = { + info: { ownsCompaction: false as boolean }, + compact: vi.fn<(params: unknown) => Promise>(async () => ({ + ok: false as const, + compacted: false as const, + reason: "nothing to compact", + })), +}; + +export const mockedContextEngineCompact = mockedContextEngine.compact; +export const mockedCompactDirect = mockedContextEngine.compact; +export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void>(); +export const mockedPrepareProviderRuntimeAuth = vi.fn(async () => undefined); +export const mockedRunEmbeddedAttempt = + vi.fn<(params: unknown) => Promise>(); +export const mockedSessionLikelyHasOversizedToolResults = vi.fn(() => false); +export const mockedTruncateOversizedToolResultsInSession = vi.fn< + () => Promise +>(async () => ({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", +})); + +type MockFailoverErrorDescription = { + message: string; + reason: string | undefined; + status: number | undefined; + code: string | undefined; +}; + +type MockCoerceToFailoverError = ( + err: unknown, + params?: { provider?: string; model?: string; profileId?: string }, +) => unknown; +type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; +type MockResolveFailoverStatus = (reason: string) => number | undefined; +type MockTruncateOversizedToolResultsResult = { + truncated: boolean; + truncatedCount: number; + reason?: string; +}; + +export const mockedCoerceToFailoverError = vi.fn(); +export const mockedDescribeFailoverError = vi.fn( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), +); +export const mockedResolveFailoverStatus = vi.fn(); + +export const mockedLog: { + debug: Mock<(...args: unknown[]) => void>; + info: Mock<(...args: unknown[]) => void>; + warn: Mock<(...args: unknown[]) => void>; + error: Mock<(...args: unknown[]) => void>; + isEnabled: Mock<(level?: string) => boolean>; +} = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + isEnabled: vi.fn(() => false), +}; + +export const mockedClassifyFailoverReason = vi.fn(() => null); +export const mockedExtractObservedOverflowTokenCount = vi.fn((msg?: string) => { + const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); + return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; +}); +export const mockedIsCompactionFailureError = vi.fn(() => false); +export const mockedIsLikelyContextOverflowError = vi.fn((msg?: string) => { + const lower = (msg ?? "").toLowerCase(); + return ( + lower.includes("request_too_large") || + lower.includes("context window exceeded") || + lower.includes("prompt is too long") + ); +}); +export const mockedPickFallbackThinkingLevel = vi.fn<(params?: unknown) => ThinkLevel | null>( + () => null, +); + +export const overflowBaseRunParams = { + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-1", +} as const; + +export function resetRunOverflowCompactionHarnessMocks(): void { + mockedGlobalHookRunner.hasHooks.mockReset(); + mockedGlobalHookRunner.hasHooks.mockReturnValue(false); + mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); + mockedGlobalHookRunner.runBeforeAgentStart.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforePromptBuild.mockReset(); + mockedGlobalHookRunner.runBeforePromptBuild.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforeModelResolve.mockReset(); + mockedGlobalHookRunner.runBeforeModelResolve.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforeCompaction.mockReset(); + mockedGlobalHookRunner.runBeforeCompaction.mockResolvedValue(undefined); + mockedGlobalHookRunner.runAfterCompaction.mockReset(); + mockedGlobalHookRunner.runAfterCompaction.mockResolvedValue(undefined); + + mockedContextEngine.info.ownsCompaction = false; + mockedContextEngineCompact.mockReset(); + mockedContextEngineCompact.mockResolvedValue({ + ok: false, + compacted: false, + reason: "nothing to compact", + }); + + mockedEnsureRuntimePluginsLoaded.mockReset(); + mockedPrepareProviderRuntimeAuth.mockReset(); + mockedPrepareProviderRuntimeAuth.mockResolvedValue(undefined); + mockedRunEmbeddedAttempt.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); + mockedTruncateOversizedToolResultsInSession.mockReset(); + mockedTruncateOversizedToolResultsInSession.mockResolvedValue({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", + }); + + mockedCoerceToFailoverError.mockReset(); + mockedCoerceToFailoverError.mockReturnValue(null); + mockedDescribeFailoverError.mockReset(); + mockedDescribeFailoverError.mockImplementation( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), + ); + mockedResolveFailoverStatus.mockReset(); + mockedResolveFailoverStatus.mockReturnValue(undefined); + + mockedLog.debug.mockReset(); + mockedLog.info.mockReset(); + mockedLog.warn.mockReset(); + mockedLog.error.mockReset(); + mockedLog.isEnabled.mockReset(); + mockedLog.isEnabled.mockReturnValue(false); + + mockedClassifyFailoverReason.mockReset(); + mockedClassifyFailoverReason.mockReturnValue(null); + mockedExtractObservedOverflowTokenCount.mockReset(); + mockedExtractObservedOverflowTokenCount.mockImplementation((msg?: string) => { + const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); + return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; + }); + mockedIsCompactionFailureError.mockReset(); + mockedIsCompactionFailureError.mockReturnValue(false); + mockedIsLikelyContextOverflowError.mockReset(); + mockedIsLikelyContextOverflowError.mockImplementation((msg?: string) => { + const lower = (msg ?? "").toLowerCase(); + return ( + lower.includes("request_too_large") || + lower.includes("context window exceeded") || + lower.includes("prompt is too long") + ); + }); + mockedPickFallbackThinkingLevel.mockReset(); + mockedPickFallbackThinkingLevel.mockReturnValue(null); +} + +export async function loadRunOverflowCompactionHarness(): Promise<{ + runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +}> { + resetRunOverflowCompactionHarnessMocks(); + vi.resetModules(); + + vi.doMock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner), + })); + + vi.doMock("../../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: vi.fn(async () => mockedContextEngine), + })); + + vi.doMock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, + })); + + vi.doMock("../../plugins/provider-runtime.js", () => ({ + prepareProviderRuntimeAuth: mockedPrepareProviderRuntimeAuth, + })); + + vi.doMock("../auth-profiles.js", () => ({ + isProfileInCooldown: vi.fn(() => false), + markAuthProfileFailure: vi.fn(async () => {}), + markAuthProfileGood: vi.fn(async () => {}), + markAuthProfileUsed: vi.fn(async () => {}), + resolveProfilesUnavailableReason: vi.fn(() => undefined), + })); + + vi.doMock("../usage.js", () => ({ + normalizeUsage: vi.fn((usage?: unknown) => + usage && typeof usage === "object" ? usage : undefined, + ), + derivePromptTokens: vi.fn( + (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => + usage + ? (() => { + const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + return sum > 0 ? sum : undefined; + })() + : undefined, + ), + })); + + vi.doMock("../workspace-run.js", () => ({ + resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ + workspaceDir: params.workspaceDir, + usedFallback: false, + fallbackReason: undefined, + agentId: "main", + })), + redactRunIdentifier: vi.fn((value?: string) => value ?? ""), + })); + + vi.doMock("../pi-embedded-helpers.js", () => ({ + formatBillingErrorMessage: vi.fn(() => ""), + classifyFailoverReason: mockedClassifyFailoverReason, + extractObservedOverflowTokenCount: mockedExtractObservedOverflowTokenCount, + formatAssistantErrorText: vi.fn(() => ""), + isAuthAssistantError: vi.fn(() => false), + isBillingAssistantError: vi.fn(() => false), + isCompactionFailureError: mockedIsCompactionFailureError, + isLikelyContextOverflowError: mockedIsLikelyContextOverflowError, + isFailoverAssistantError: vi.fn(() => false), + isFailoverErrorMessage: vi.fn(() => false), + parseImageSizeError: vi.fn(() => null), + parseImageDimensionError: vi.fn(() => null), + isRateLimitAssistantError: vi.fn(() => false), + isTimeoutErrorMessage: vi.fn(() => false), + pickFallbackThinkingLevel: mockedPickFallbackThinkingLevel, + })); + + vi.doMock("./run/attempt.js", () => ({ + runEmbeddedAttempt: mockedRunEmbeddedAttempt, + })); + + vi.doMock("./model.js", () => ({ + resolveModelAsync: vi.fn(async () => ({ + model: { + id: "test-model", + provider: "anthropic", + contextWindow: 200000, + api: "messages", + }, + error: null, + authStorage: { + setRuntimeApiKey: vi.fn(), + }, + modelRegistry: {}, + })), + })); + + vi.doMock("../model-auth.js", () => ({ + applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), + ensureAuthProfileStore: vi.fn(() => ({})), + getApiKeyForModel: vi.fn(async () => ({ + apiKey: "test-key", + profileId: "test-profile", + source: "test", + })), + resolveAuthProfileOrder: vi.fn(() => []), + })); + + vi.doMock("../models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + + vi.doMock("../context-window-guard.js", () => ({ + CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000, + CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000, + evaluateContextWindowGuard: vi.fn(() => ({ + shouldWarn: false, + shouldBlock: false, + tokens: 200000, + source: "model", + })), + resolveContextWindowInfo: vi.fn(() => ({ + tokens: 200000, + source: "model", + })), + })); + + vi.doMock("../../process/command-queue.js", () => ({ + enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()), + })); + + vi.doMock("../../utils/message-channel.js", () => ({ + isMarkdownCapableMessageChannel: vi.fn(() => true), + })); + + vi.doMock("../agent-paths.js", () => ({ + resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"), + })); + + vi.doMock("../defaults.js", () => ({ + DEFAULT_CONTEXT_TOKENS: 200000, + DEFAULT_MODEL: "test-model", + DEFAULT_PROVIDER: "anthropic", + })); + + vi.doMock("../failover-error.js", () => ({ + FailoverError: class extends Error {}, + coerceToFailoverError: mockedCoerceToFailoverError, + describeFailoverError: mockedDescribeFailoverError, + resolveFailoverStatus: mockedResolveFailoverStatus, + })); + + vi.doMock("./lanes.js", () => ({ + resolveSessionLane: vi.fn(() => "session-lane"), + resolveGlobalLane: vi.fn(() => "global-lane"), + })); + + vi.doMock("./logger.js", () => ({ + log: mockedLog, + })); + + vi.doMock("./run/payloads.js", () => ({ + buildEmbeddedRunPayloads: vi.fn(() => []), + })); + + vi.doMock("./tool-result-truncation.js", () => ({ + truncateOversizedToolResultsInSession: mockedTruncateOversizedToolResultsInSession, + sessionLikelyHasOversizedToolResults: mockedSessionLikelyHasOversizedToolResults, + })); + + vi.doMock("./utils.js", () => ({ + describeUnknownError: vi.fn((err: unknown) => { + if (err instanceof Error) { + return err.message; + } + return String(err); + }), + })); + + const { runEmbeddedPiAgent } = await import("./run.js"); + return { runEmbeddedPiAgent }; +} diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts index 7a2550ba1e9..f74b14c56df 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts @@ -1,17 +1,4 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { isCompactionFailureError, isLikelyContextOverflowError } from "../pi-embedded-helpers.js"; - -vi.mock(import("../../utils.js"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveUserPath: vi.fn((p: string) => p), - }; -}); - -import { log } from "./logger.js"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -20,26 +7,38 @@ import { queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; import { + loadRunOverflowCompactionHarness, mockedContextEngine, mockedCompactDirect, + mockedIsCompactionFailureError, + mockedIsLikelyContextOverflowError, + mockedLog, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams as baseParams, -} from "./run.overflow-compaction.shared-test.js"; +} from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -const mockedIsCompactionFailureError = vi.mocked(isCompactionFailureError); -const mockedIsLikelyContextOverflowError = vi.mocked(isLikelyContextOverflowError); +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("overflow compaction in run loop", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); mockedSessionLikelyHasOversizedToolResults.mockReset(); mockedTruncateOversizedToolResultsInSession.mockReset(); mockedContextEngine.info.ownsCompaction = false; + mockedLog.debug.mockReset(); + mockedLog.info.mockReset(); + mockedLog.warn.mockReset(); + mockedLog.error.mockReset(); + mockedLog.isEnabled.mockReset(); + mockedLog.isEnabled.mockReturnValue(false); mockedIsCompactionFailureError.mockImplementation((msg?: string) => { if (!msg) { return false; @@ -87,12 +86,14 @@ describe("overflow compaction in run loop", () => { }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith( + expect(mockedLog.warn).toHaveBeenCalledWith( expect.stringContaining( "context overflow detected (attempt 1/3); attempting auto-compaction", ), ); - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-compaction succeeded")); + expect(mockedLog.info).toHaveBeenCalledWith( + expect.stringContaining("auto-compaction succeeded"), + ); // Should not be an error result expect(result.meta.error).toBeUndefined(); }); @@ -116,7 +117,7 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("source=promptError")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("source=promptError")); expect(result.meta.error).toBeUndefined(); }); @@ -137,7 +138,7 @@ describe("overflow compaction in run loop", () => { expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); expect(result.meta.error?.kind).toBe("context_overflow"); expect(result.payloads?.[0]?.isError).toBe(true); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed")); }); it("falls back to tool-result truncation and retries when oversized results are detected", async () => { @@ -165,7 +166,9 @@ describe("overflow compaction in run loop", () => { expect.objectContaining({ sessionFile: "/tmp/session.json" }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("Truncated 1 tool result(s)")); + expect(mockedLog.info).toHaveBeenCalledWith( + expect.stringContaining("Truncated 1 tool result(s)"), + ); expect(result.meta.error).toBeUndefined(); }); @@ -284,7 +287,7 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); expect(result.meta.error).toBeUndefined(); }); @@ -302,7 +305,9 @@ describe("overflow compaction in run loop", () => { await expect(runEmbeddedPiAgent(baseParams)).rejects.toThrow("transport disconnected"); expect(mockedCompactDirect).not.toHaveBeenCalled(); - expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); + expect(mockedLog.warn).not.toHaveBeenCalledWith( + expect.stringContaining("source=assistantError"), + ); }); it("returns an explicit timeout payload when the run times out before producing any reply", async () => { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts deleted file mode 100644 index 8451ef54994..00000000000 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { vi } from "vitest"; -import type { - PluginHookAgentContext, - PluginHookBeforeAgentStartResult, - PluginHookBeforeModelResolveResult, - PluginHookBeforePromptBuildResult, -} from "../../plugins/types.js"; - -type MockCompactionResult = - | { - ok: true; - compacted: true; - result: { - summary: string; - firstKeptEntryId?: string; - tokensBefore?: number; - tokensAfter?: number; - }; - reason?: string; - } - | { - ok: false; - compacted: false; - reason: string; - result?: undefined; - }; - -export const mockedGlobalHookRunner = { - hasHooks: vi.fn((_hookName: string) => false), - runBeforeAgentStart: vi.fn( - async ( - _event: { prompt: string; messages?: unknown[] }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforePromptBuild: vi.fn( - async ( - _event: { prompt: string; messages: unknown[] }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforeModelResolve: vi.fn( - async ( - _event: { prompt: string }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforeCompaction: vi.fn(async () => undefined), - runAfterCompaction: vi.fn(async () => undefined), -}; - -export const mockedContextEngine = { - info: { ownsCompaction: false as boolean }, - compact: vi.fn<(params: unknown) => Promise>(async () => ({ - ok: false as const, - compacted: false as const, - reason: "nothing to compact", - })), -}; - -export const mockedContextEngineCompact = vi.mocked(mockedContextEngine.compact); -export const mockedEnsureRuntimePluginsLoaded: (...args: unknown[]) => void = vi.fn(); - -vi.mock("../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner), -})); - -vi.mock("../../context-engine/index.js", () => ({ - ensureContextEnginesInitialized: vi.fn(), - resolveContextEngine: vi.fn(async () => mockedContextEngine), -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, -})); - -vi.mock("../auth-profiles.js", () => ({ - isProfileInCooldown: vi.fn(() => false), - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn((usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => - usage - ? (() => { - const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - return sum > 0 ? sum : undefined; - })() - : undefined, - ), - hasNonzeroUsage: vi.fn(() => false), -})); - -vi.mock("../workspace-run.js", () => ({ - resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ - workspaceDir: params.workspaceDir, - usedFallback: false, - fallbackReason: undefined, - agentId: "main", - })), - redactRunIdentifier: vi.fn((value?: string) => value ?? ""), -})); - -vi.mock("../pi-embedded-helpers.js", () => ({ - formatBillingErrorMessage: vi.fn(() => ""), - classifyFailoverReason: vi.fn(() => null), - extractObservedOverflowTokenCount: vi.fn((msg?: string) => { - const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); - return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; - }), - formatAssistantErrorText: vi.fn(() => ""), - isAuthAssistantError: vi.fn(() => false), - isBillingAssistantError: vi.fn(() => false), - isCompactionFailureError: vi.fn(() => false), - isLikelyContextOverflowError: vi.fn((msg?: string) => { - const lower = (msg ?? "").toLowerCase(); - return ( - lower.includes("request_too_large") || - lower.includes("context window exceeded") || - lower.includes("prompt is too long") - ); - }), - isFailoverAssistantError: vi.fn(() => false), - isFailoverErrorMessage: vi.fn(() => false), - parseImageSizeError: vi.fn(() => null), - parseImageDimensionError: vi.fn(() => null), - isRateLimitAssistantError: vi.fn(() => false), - isTimeoutErrorMessage: vi.fn(() => false), - pickFallbackThinkingLevel: vi.fn(() => null), -})); - -vi.mock("./run/attempt.js", () => ({ - runEmbeddedAttempt: vi.fn(), -})); - -vi.mock("./compact.js", () => ({ - compactEmbeddedPiSessionDirect: vi.fn(), -})); - -vi.mock("./model.js", () => ({ - resolveModel: vi.fn(() => ({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - })), - resolveModelAsync: vi.fn(async () => ({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - })), -})); - -vi.mock("../model-auth.js", () => ({ - ensureAuthProfileStore: vi.fn(() => ({})), - getApiKeyForModel: vi.fn(async () => ({ - apiKey: "test-key", - profileId: "test-profile", - source: "test", - })), - resolveAuthProfileOrder: vi.fn(() => []), -})); - -vi.mock("../models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), -})); - -vi.mock("../context-window-guard.js", () => ({ - CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000, - CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000, - evaluateContextWindowGuard: vi.fn(() => ({ - shouldWarn: false, - shouldBlock: false, - tokens: 200000, - source: "model", - })), - resolveContextWindowInfo: vi.fn(() => ({ - tokens: 200000, - source: "model", - })), -})); - -vi.mock("../../process/command-queue.js", () => ({ - enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()), -})); - -vi.mock(import("../../utils/message-channel.js"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isMarkdownCapableMessageChannel: vi.fn(() => true), - }; -}); - -vi.mock("../agent-paths.js", () => ({ - resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"), -})); - -vi.mock("../defaults.js", () => ({ - DEFAULT_CONTEXT_TOKENS: 200000, - DEFAULT_MODEL: "test-model", - DEFAULT_PROVIDER: "anthropic", -})); - -type MockFailoverErrorDescription = { - message: string; - reason: string | undefined; - status: number | undefined; - code: string | undefined; -}; - -type MockCoerceToFailoverError = ( - err: unknown, - params?: { provider?: string; model?: string; profileId?: string }, -) => unknown; -type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; -type MockResolveFailoverStatus = (reason: string) => number | undefined; - -export const mockedCoerceToFailoverError = vi.fn(); -export const mockedDescribeFailoverError = vi.fn( - (err: unknown): MockFailoverErrorDescription => ({ - message: err instanceof Error ? err.message : String(err), - reason: undefined, - status: undefined, - code: undefined, - }), -); -export const mockedResolveFailoverStatus = vi.fn(); - -vi.mock("../failover-error.js", () => ({ - FailoverError: class extends Error {}, - coerceToFailoverError: mockedCoerceToFailoverError, - describeFailoverError: mockedDescribeFailoverError, - resolveFailoverStatus: mockedResolveFailoverStatus, -})); - -vi.mock("./lanes.js", () => ({ - resolveSessionLane: vi.fn(() => "session-lane"), - resolveGlobalLane: vi.fn(() => "global-lane"), -})); - -vi.mock("./logger.js", () => ({ - log: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - isEnabled: vi.fn(() => false), - }, -})); - -vi.mock("./run/payloads.js", () => ({ - buildEmbeddedRunPayloads: vi.fn(() => []), -})); - -vi.mock("./tool-result-truncation.js", () => ({ - truncateOversizedToolResultsInSession: vi.fn(async () => ({ - truncated: false, - truncatedCount: 0, - reason: "no oversized tool results", - })), - sessionLikelyHasOversizedToolResults: vi.fn(() => false), -})); - -vi.mock("./utils.js", () => ({ - describeUnknownError: vi.fn((err: unknown) => { - if (err instanceof Error) { - return err.message; - } - return String(err); - }), -})); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts deleted file mode 100644 index c697ac9526a..00000000000 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { vi } from "vitest"; -import { - mockedContextEngine, - mockedContextEngineCompact, -} from "./run.overflow-compaction.mocks.shared.js"; -import { runEmbeddedAttempt } from "./run/attempt.js"; -import { - sessionLikelyHasOversizedToolResults, - truncateOversizedToolResultsInSession, -} from "./tool-result-truncation.js"; - -export const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt); -export const mockedCompactDirect = mockedContextEngineCompact; -export const mockedSessionLikelyHasOversizedToolResults = vi.mocked( - sessionLikelyHasOversizedToolResults, -); -export const mockedTruncateOversizedToolResultsInSession = vi.mocked( - truncateOversizedToolResultsInSession, -); -export { mockedContextEngine }; - -export const overflowBaseRunParams = { - sessionId: "test-session", - sessionKey: "test-key", - sessionFile: "/tmp/session.json", - workspaceDir: "/tmp/workspace", - prompt: "hello", - timeoutMs: 30000, - runId: "run-1", -} as const; diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index d18123a4ae2..75a9ab6e034 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,7 +1,4 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { pickFallbackThinkingLevel } from "../pi-embedded-helpers.js"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -10,24 +7,30 @@ import { queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; import { + loadRunOverflowCompactionHarness, mockedCoerceToFailoverError, mockedDescribeFailoverError, mockedGlobalHookRunner, + mockedPickFallbackThinkingLevel, mockedResolveFailoverStatus, -} from "./run.overflow-compaction.mocks.shared.js"; -import { mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams, -} from "./run.overflow-compaction.shared-test.js"; -const mockedPickFallbackThinkingLevel = vi.mocked(pickFallbackThinkingLevel); +} from "./run.overflow-compaction.harness.js"; + +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { beforeEach(() => { - vi.clearAllMocks(); + return loadRunOverflowCompactionHarness().then((loaded) => { + runEmbeddedPiAgent = loaded.runEmbeddedPiAgent; + }); + }); + + beforeEach(() => { mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); mockedCoerceToFailoverError.mockReset(); @@ -257,7 +260,8 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { it("returns retry_limit when repeated retries never converge", async () => { mockedRunEmbeddedAttempt.mockClear(); mockedCompactDirect.mockClear(); - mockedPickFallbackThinkingLevel.mockClear(); + mockedPickFallbackThinkingLevel.mockReset(); + mockedPickFallbackThinkingLevel.mockReturnValue(null); mockedRunEmbeddedAttempt.mockResolvedValue( makeAttemptResult({ promptError: new Error("unsupported reasoning mode") }), ); @@ -288,15 +292,15 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { status: 429, }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError })); - mockedCoerceToFailoverError.mockReturnValueOnce(normalized); + mockedRunEmbeddedAttempt.mockResolvedValue(makeAttemptResult({ promptError })); + mockedCoerceToFailoverError.mockReturnValue(normalized); mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ message: err instanceof Error ? err.message : String(err), reason: err === normalized ? "rate_limit" : undefined, status: err === normalized ? 429 : undefined, code: undefined, })); - mockedResolveFailoverStatus.mockReturnValueOnce(429); + mockedResolveFailoverStatus.mockReturnValue(429); await expect( runEmbeddedPiAgent({ diff --git a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts b/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts index e05ffd19cbf..69a81d129fb 100644 --- a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts +++ b/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts @@ -3,20 +3,25 @@ * with no pending tool calls, so the parent session is idle when subagent * results arrive. */ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; -import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js"; import { + loadRunOverflowCompactionHarness, + mockedGlobalHookRunner, mockedRunEmbeddedAttempt, overflowBaseRunParams, -} from "./run.overflow-compaction.shared-test.js"; +} from "./run.overflow-compaction.harness.js"; import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./runs.js"; +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; + describe("sessions_yield orchestration", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); + mockedRunEmbeddedAttempt.mockReset(); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index 7c29c5f99cf..f748ac3b9b5 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -1,22 +1,20 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + loadRunOverflowCompactionHarness, + mockedEnsureRuntimePluginsLoaded, + mockedRunEmbeddedAttempt, +} from "./run.overflow-compaction.harness.js"; -const runtimePluginMocks = vi.hoisted(() => ({ - ensureRuntimePluginsLoaded: vi.fn(), -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, -})); - -import { runEmbeddedPiAgent } from "./run.js"; -import { runEmbeddedAttempt } from "./run/attempt.js"; - -const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt); +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent usage reporting", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); + mockedEnsureRuntimePluginsLoaded.mockReset(); + mockedRunEmbeddedAttempt.mockReset(); }); it("bootstraps runtime plugins with the resolved workspace before running", async () => { @@ -39,7 +37,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { runId: "run-plugin-bootstrap", }); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: undefined, workspaceDir: "/tmp/workspace", }); @@ -66,7 +64,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { allowGatewaySubagentBinding: true, }); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: undefined, workspaceDir: "/tmp/workspace", allowGatewaySubagentBinding: true, From 50cac3965744afb7917fa84801858a2945f2c244 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:06:48 +0000 Subject: [PATCH 178/393] Agents: stabilize compaction hook test harness --- .../compact.hooks.harness.ts | 410 ++++++++++++++++++ .../pi-embedded-runner/compact.hooks.test.ts | 371 ++-------------- 2 files changed, 445 insertions(+), 336 deletions(-) create mode 100644 src/agents/pi-embedded-runner/compact.hooks.harness.ts diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts new file mode 100644 index 00000000000..fa796a1a59d --- /dev/null +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -0,0 +1,410 @@ +import { vi, type Mock } from "vitest"; + +type MockResolvedModel = { + model: { provider: string; api: string; id: string; input: unknown[] }; + error: null; + authStorage: { setRuntimeApiKey: Mock<(provider?: string, apiKey?: string) => void> }; + modelRegistry: Record; +}; + +export const contextEngineCompactMock = vi.fn(async () => ({ + ok: true as boolean, + compacted: true as boolean, + reason: undefined as string | undefined, + result: { summary: "engine-summary", tokensAfter: 50 } as + | { summary: string; tokensAfter: number } + | undefined, +})); + +export const hookRunner = { + hasHooks: vi.fn<(hookName?: string) => boolean>(), + runBeforeCompaction: vi.fn(async () => undefined), + runAfterCompaction: vi.fn(async () => undefined), +}; + +export const ensureRuntimePluginsLoaded: Mock<(params?: unknown) => void> = vi.fn(); +export const resolveContextEngineMock = vi.fn(async () => ({ + info: { ownsCompaction: true as boolean }, + compact: contextEngineCompactMock, +})); +export const resolveModelMock: Mock< + (provider?: string, modelId?: string, agentDir?: string, cfg?: unknown) => MockResolvedModel +> = vi.fn((_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, +})); +export const sessionCompactImpl = vi.fn(async () => ({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, +})); +export const triggerInternalHook: Mock<(event?: unknown) => void> = vi.fn(); +export const sanitizeSessionHistoryMock = vi.fn( + async (params: { messages: unknown[] }) => params.messages, +); +export const getMemorySearchManagerMock = vi.fn(async () => ({ + manager: { + sync: vi.fn(async () => {}), + }, +})); +export const resolveMemorySearchConfigMock = vi.fn(() => ({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, +})); +export const resolveSessionAgentIdMock = vi.fn(() => "main"); +export const estimateTokensMock = vi.fn((_message?: unknown) => 10); +export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn(); +export const createOpenClawCodingToolsMock = vi.fn(() => []); + +export function resetCompactHooksHarnessMocks(): void { + hookRunner.hasHooks.mockReset(); + hookRunner.hasHooks.mockReturnValue(false); + hookRunner.runBeforeCompaction.mockReset(); + hookRunner.runBeforeCompaction.mockResolvedValue(undefined); + hookRunner.runAfterCompaction.mockReset(); + hookRunner.runAfterCompaction.mockResolvedValue(undefined); + + ensureRuntimePluginsLoaded.mockReset(); + + resolveContextEngineMock.mockReset(); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: true }, + compact: contextEngineCompactMock, + }); + contextEngineCompactMock.mockReset(); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + reason: undefined, + result: { summary: "engine-summary", tokensAfter: 50 }, + }); + + resolveModelMock.mockReset(); + resolveModelMock.mockReturnValue({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }); + + sessionCompactImpl.mockReset(); + sessionCompactImpl.mockResolvedValue({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, + }); + + triggerInternalHook.mockReset(); + sanitizeSessionHistoryMock.mockReset(); + sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { + return params.messages; + }); + + getMemorySearchManagerMock.mockReset(); + getMemorySearchManagerMock.mockResolvedValue({ + manager: { + sync: vi.fn(async () => {}), + }, + }); + resolveMemorySearchConfigMock.mockReset(); + resolveMemorySearchConfigMock.mockReturnValue({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, + }); + resolveSessionAgentIdMock.mockReset(); + resolveSessionAgentIdMock.mockReturnValue("main"); + estimateTokensMock.mockReset(); + estimateTokensMock.mockReturnValue(10); + sessionAbortCompactionMock.mockReset(); + createOpenClawCodingToolsMock.mockReset(); + createOpenClawCodingToolsMock.mockReturnValue([]); +} + +export async function loadCompactHooksHarness(): Promise<{ + compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; + compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession; + onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; +}> { + resetCompactHooksHarnessMocks(); + vi.resetModules(); + + vi.doMock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookRunner, + })); + + vi.doMock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded, + })); + + vi.doMock("../../hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../../hooks/internal-hooks.js", + ); + return { + ...actual, + triggerInternalHook, + }; + }); + + vi.doMock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: vi.fn(), + getOAuthProviders: vi.fn(() => []), + })); + + vi.doMock("@mariozechner/pi-coding-agent", () => ({ + AuthStorage: class AuthStorage {}, + ModelRegistry: class ModelRegistry {}, + createAgentSession: vi.fn(async () => { + const session = { + sessionId: "session-1", + messages: [ + { role: "user", content: "hello", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp: 3, + }, + ], + agent: { + replaceMessages: vi.fn((messages: unknown[]) => { + session.messages = [...(messages as typeof session.messages)]; + }), + streamFn: vi.fn(), + }, + compact: vi.fn(async () => { + session.messages.splice(1); + return await sessionCompactImpl(); + }), + abortCompaction: sessionAbortCompactionMock, + dispose: vi.fn(), + }; + return { session }; + }), + DefaultResourceLoader: class DefaultResourceLoader {}, + SessionManager: { + open: vi.fn(() => ({})), + }, + SettingsManager: { + create: vi.fn(() => ({})), + }, + estimateTokens: estimateTokensMock, + })); + + vi.doMock("../session-tool-result-guard-wrapper.js", () => ({ + guardSessionManager: vi.fn(() => ({ + flushPendingToolResults: vi.fn(), + })), + })); + + vi.doMock("../pi-settings.js", () => ({ + ensurePiCompactionReserveTokens: vi.fn(), + resolveCompactionReserveTokensFloor: vi.fn(() => 0), + })); + + vi.doMock("../models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + + vi.doMock("../model-auth.js", () => ({ + applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), + getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), + resolveModelAuthMode: vi.fn(() => "env"), + })); + + vi.doMock("../sandbox.js", () => ({ + resolveSandboxContext: vi.fn(async () => null), + })); + + vi.doMock("../session-file-repair.js", () => ({ + repairSessionFileIfNeeded: vi.fn(async () => {}), + })); + + vi.doMock("../session-write-lock.js", () => ({ + acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), + resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), + })); + + vi.doMock("../../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: resolveContextEngineMock, + })); + + vi.doMock("../../process/command-queue.js", () => ({ + enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()), + })); + + vi.doMock("./lanes.js", () => ({ + resolveSessionLane: vi.fn(() => "test-session-lane"), + resolveGlobalLane: vi.fn(() => "test-global-lane"), + })); + + vi.doMock("../context-window-guard.js", () => ({ + resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })), + })); + + vi.doMock("../bootstrap-files.js", () => ({ + makeBootstrapWarn: vi.fn(() => () => {}), + resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), + })); + + vi.doMock("../docs-path.js", () => ({ + resolveOpenClawDocsPath: vi.fn(async () => undefined), + })); + + vi.doMock("../channel-tools.js", () => ({ + listChannelSupportedActions: vi.fn(() => undefined), + resolveChannelMessageToolHints: vi.fn(() => undefined), + })); + + vi.doMock("../pi-tools.js", () => ({ + createOpenClawCodingTools: createOpenClawCodingToolsMock, + })); + + vi.doMock("./google.js", () => ({ + logToolSchemasForGoogle: vi.fn(), + sanitizeSessionHistory: sanitizeSessionHistoryMock, + sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools), + })); + + vi.doMock("./tool-split.js", () => ({ + splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })), + })); + + vi.doMock("../transcript-policy.js", () => ({ + resolveTranscriptPolicy: vi.fn(() => ({ + allowSyntheticToolResults: false, + validateGeminiTurns: false, + validateAnthropicTurns: false, + })), + })); + + vi.doMock("./extensions.js", () => ({ + buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })), + })); + + vi.doMock("./history.js", () => ({ + getDmHistoryLimitFromSessionKey: vi.fn(() => undefined), + limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)), + })); + + vi.doMock("../skills.js", () => ({ + applySkillEnvOverrides: vi.fn(() => () => {}), + applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), + loadWorkspaceSkillEntries: vi.fn(() => []), + resolveSkillsPromptForRun: vi.fn(() => undefined), + })); + + vi.doMock("../agent-paths.js", () => ({ + resolveOpenClawAgentDir: vi.fn(() => "/tmp"), + })); + + vi.doMock("../agent-scope.js", () => ({ + resolveSessionAgentId: resolveSessionAgentIdMock, + resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), + })); + + vi.doMock("../memory-search.js", () => ({ + resolveMemorySearchConfig: resolveMemorySearchConfigMock, + })); + + vi.doMock("../../memory/index.js", () => ({ + getMemorySearchManager: getMemorySearchManagerMock, + })); + + vi.doMock("../date-time.js", () => ({ + formatUserTime: vi.fn(() => ""), + resolveUserTimeFormat: vi.fn(() => ""), + resolveUserTimezone: vi.fn(() => ""), + })); + + vi.doMock("../defaults.js", () => ({ + DEFAULT_MODEL: "fake-model", + DEFAULT_PROVIDER: "openai", + DEFAULT_CONTEXT_TOKENS: 128_000, + })); + + vi.doMock("../utils.js", () => ({ + resolveUserPath: vi.fn((p: string) => p), + })); + + vi.doMock("../../infra/machine-name.js", () => ({ + getMachineDisplayName: vi.fn(async () => "machine"), + })); + + vi.doMock("../../config/channel-capabilities.js", () => ({ + resolveChannelCapabilities: vi.fn(() => undefined), + })); + + vi.doMock("../../utils/message-channel.js", () => ({ + INTERNAL_MESSAGE_CHANNEL: "webchat", + normalizeMessageChannel: vi.fn(() => undefined), + })); + + vi.doMock("../pi-embedded-helpers.js", () => ({ + ensureSessionHeader: vi.fn(async () => {}), + validateAnthropicTurns: vi.fn((m: unknown[]) => m), + validateGeminiTurns: vi.fn((m: unknown[]) => m), + })); + + vi.doMock("../pi-project-settings.js", () => ({ + createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({ + getGlobalSettings: vi.fn(() => ({})), + })), + })); + + vi.doMock("./sandbox-info.js", () => ({ + buildEmbeddedSandboxInfo: vi.fn(() => undefined), + })); + + vi.doMock("./model.js", () => ({ + buildModelAliasLines: vi.fn(() => []), + resolveModel: resolveModelMock, + resolveModelAsync: vi.fn( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => + resolveModelMock(provider, modelId, agentDir, cfg), + ), + })); + + vi.doMock("./session-manager-cache.js", () => ({ + prewarmSessionFile: vi.fn(async () => {}), + trackSessionManagerAccess: vi.fn(), + })); + + vi.doMock("./system-prompt.js", () => ({ + applySystemPromptOverrideToSession: vi.fn(), + buildEmbeddedSystemPrompt: vi.fn(() => ""), + createSystemPromptOverride: vi.fn(() => () => ""), + })); + + vi.doMock("./utils.js", () => ({ + describeUnknownError: vi.fn((err: unknown) => String(err)), + mapThinkingLevel: vi.fn(() => "off"), + resolveExecToolDefaults: vi.fn(() => undefined), + })); + + const [compactModule, transcriptEvents] = await Promise.all([ + import("./compact.js"), + import("../../sessions/transcript-events.js"), + ]); + + return { + ...compactModule, + onSessionTranscriptUpdate: transcriptEvents.onSessionTranscriptUpdate, + }; +} diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 72b16ad003f..8989176330a 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1,348 +1,39 @@ +import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; - -const { - hookRunner, +import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; +import { + contextEngineCompactMock, + createOpenClawCodingToolsMock, ensureRuntimePluginsLoaded, + estimateTokensMock, + getMemorySearchManagerMock, + hookRunner, + loadCompactHooksHarness, resolveContextEngineMock, + resolveMemorySearchConfigMock, resolveModelMock, + resolveSessionAgentIdMock, + sanitizeSessionHistoryMock, + sessionAbortCompactionMock, sessionCompactImpl, triggerInternalHook, - sanitizeSessionHistoryMock, - contextEngineCompactMock, - getMemorySearchManagerMock, - resolveMemorySearchConfigMock, - resolveSessionAgentIdMock, - estimateTokensMock, - sessionAbortCompactionMock, - createOpenClawCodingToolsMock, -} = vi.hoisted(() => { - const contextEngineCompactMock = vi.fn(async () => ({ - ok: true as boolean, - compacted: true as boolean, - reason: undefined as string | undefined, - result: { summary: "engine-summary", tokensAfter: 50 } as - | { summary: string; tokensAfter: number } - | undefined, - })); +} from "./compact.hooks.harness.js"; - return { - hookRunner: { - hasHooks: vi.fn(), - runBeforeCompaction: vi.fn(), - runAfterCompaction: vi.fn(), - }, - ensureRuntimePluginsLoaded: vi.fn(), - resolveContextEngineMock: vi.fn(async () => ({ - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - })), - resolveModelMock: vi.fn( - (_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - }), - ), - sessionCompactImpl: vi.fn(async () => ({ - summary: "summary", - firstKeptEntryId: "entry-1", - tokensBefore: 120, - details: { ok: true }, - })), - triggerInternalHook: vi.fn(), - sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages), - contextEngineCompactMock, - getMemorySearchManagerMock: vi.fn(async () => ({ - manager: { - sync: vi.fn(async () => {}), - }, - })), - resolveMemorySearchConfigMock: vi.fn(() => ({ - sources: ["sessions"], - sync: { - sessions: { - postCompactionForce: true, - }, - }, - })), - resolveSessionAgentIdMock: vi.fn(() => "main"), - estimateTokensMock: vi.fn((_message?: unknown) => 10), - sessionAbortCompactionMock: vi.fn(), - createOpenClawCodingToolsMock: vi.fn(() => []), - }; -}); - -vi.mock("../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => hookRunner, -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded, -})); - -vi.mock("../../hooks/internal-hooks.js", async () => { - const actual = await vi.importActual( - "../../hooks/internal-hooks.js", - ); - return { - ...actual, - triggerInternalHook, - }; -}); - -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: vi.fn(), - getOAuthProviders: vi.fn(() => []), -})); - -vi.mock("@mariozechner/pi-coding-agent", () => { - return { - AuthStorage: class AuthStorage {}, - ModelRegistry: class ModelRegistry {}, - createAgentSession: vi.fn(async () => { - const session = { - sessionId: "session-1", - messages: [ - { role: "user", content: "hello", timestamp: 1 }, - { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, - { - role: "toolResult", - toolCallId: "t1", - toolName: "exec", - content: [{ type: "text", text: "output" }], - isError: false, - timestamp: 3, - }, - ], - agent: { - replaceMessages: vi.fn((messages: unknown[]) => { - session.messages = [...(messages as typeof session.messages)]; - }), - streamFn: vi.fn(), - }, - compact: vi.fn(async () => { - // simulate compaction trimming to a single message - session.messages.splice(1); - return await sessionCompactImpl(); - }), - abortCompaction: sessionAbortCompactionMock, - dispose: vi.fn(), - }; - return { session }; - }), - SessionManager: { - open: vi.fn(() => ({})), - }, - SettingsManager: { - create: vi.fn(() => ({})), - }, - estimateTokens: estimateTokensMock, - }; -}); - -vi.mock("../session-tool-result-guard-wrapper.js", () => ({ - guardSessionManager: vi.fn(() => ({ - flushPendingToolResults: vi.fn(), - })), -})); - -vi.mock("../pi-settings.js", () => ({ - ensurePiCompactionReserveTokens: vi.fn(), - resolveCompactionReserveTokensFloor: vi.fn(() => 0), -})); - -vi.mock("../models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), -})); - -vi.mock("../model-auth.js", () => ({ - applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), - getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), - resolveModelAuthMode: vi.fn(() => "env"), -})); - -vi.mock("../sandbox.js", () => ({ - resolveSandboxContext: vi.fn(async () => null), -})); - -vi.mock("../session-file-repair.js", () => ({ - repairSessionFileIfNeeded: vi.fn(async () => {}), -})); - -vi.mock("../session-write-lock.js", () => ({ - acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), - resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), -})); - -vi.mock("../../context-engine/index.js", () => ({ - ensureContextEnginesInitialized: vi.fn(), - resolveContextEngine: resolveContextEngineMock, -})); - -vi.mock("../../process/command-queue.js", () => ({ - enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()), -})); - -vi.mock("./lanes.js", () => ({ - resolveSessionLane: vi.fn(() => "test-session-lane"), - resolveGlobalLane: vi.fn(() => "test-global-lane"), -})); - -vi.mock("../context-window-guard.js", () => ({ - resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })), -})); - -vi.mock("../bootstrap-files.js", () => ({ - makeBootstrapWarn: vi.fn(() => () => {}), - resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), -})); - -vi.mock("../docs-path.js", () => ({ - resolveOpenClawDocsPath: vi.fn(async () => undefined), -})); - -vi.mock("../channel-tools.js", () => ({ - listChannelSupportedActions: vi.fn(() => undefined), - resolveChannelMessageToolHints: vi.fn(() => undefined), -})); - -vi.mock("../pi-tools.js", () => ({ - createOpenClawCodingTools: createOpenClawCodingToolsMock, -})); - -vi.mock("./google.js", () => ({ - logToolSchemasForGoogle: vi.fn(), - sanitizeSessionHistory: sanitizeSessionHistoryMock, - sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools), -})); - -vi.mock("./tool-split.js", () => ({ - splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })), -})); - -vi.mock("../transcript-policy.js", () => ({ - resolveTranscriptPolicy: vi.fn(() => ({ - allowSyntheticToolResults: false, - validateGeminiTurns: false, - validateAnthropicTurns: false, - })), -})); - -vi.mock("./extensions.js", () => ({ - buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })), -})); - -vi.mock("./history.js", () => ({ - getDmHistoryLimitFromSessionKey: vi.fn(() => undefined), - limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)), -})); - -vi.mock("../skills.js", () => ({ - applySkillEnvOverrides: vi.fn(() => () => {}), - applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), - loadWorkspaceSkillEntries: vi.fn(() => []), - resolveSkillsPromptForRun: vi.fn(() => undefined), -})); - -vi.mock("../agent-paths.js", () => ({ - resolveOpenClawAgentDir: vi.fn(() => "/tmp"), -})); - -vi.mock("../agent-scope.js", () => ({ - resolveSessionAgentId: resolveSessionAgentIdMock, - resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), -})); - -vi.mock("../memory-search.js", () => ({ - resolveMemorySearchConfig: resolveMemorySearchConfigMock, -})); - -vi.mock("../../memory/index.js", () => ({ - getMemorySearchManager: getMemorySearchManagerMock, -})); - -vi.mock("../date-time.js", () => ({ - formatUserTime: vi.fn(() => ""), - resolveUserTimeFormat: vi.fn(() => ""), - resolveUserTimezone: vi.fn(() => ""), -})); - -vi.mock("../defaults.js", () => ({ - DEFAULT_MODEL: "fake-model", - DEFAULT_PROVIDER: "openai", - DEFAULT_CONTEXT_TOKENS: 128_000, -})); - -vi.mock("../utils.js", () => ({ - resolveUserPath: vi.fn((p: string) => p), -})); - -vi.mock("../../infra/machine-name.js", () => ({ - getMachineDisplayName: vi.fn(async () => "machine"), -})); - -vi.mock("../../config/channel-capabilities.js", () => ({ - resolveChannelCapabilities: vi.fn(() => undefined), -})); - -vi.mock("../../utils/message-channel.js", () => ({ - INTERNAL_MESSAGE_CHANNEL: "webchat", - normalizeMessageChannel: vi.fn(() => undefined), -})); - -vi.mock("../pi-embedded-helpers.js", () => ({ - ensureSessionHeader: vi.fn(async () => {}), - validateAnthropicTurns: vi.fn((m: unknown[]) => m), - validateGeminiTurns: vi.fn((m: unknown[]) => m), -})); - -vi.mock("../pi-project-settings.js", () => ({ - createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({ - getGlobalSettings: vi.fn(() => ({})), - })), -})); - -vi.mock("./sandbox-info.js", () => ({ - buildEmbeddedSandboxInfo: vi.fn(() => undefined), -})); - -vi.mock("./model.js", () => ({ - buildModelAliasLines: vi.fn(() => []), - resolveModel: resolveModelMock, - resolveModelAsync: vi.fn( - async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => - resolveModelMock(provider, modelId, agentDir, cfg), - ), -})); - -vi.mock("./session-manager-cache.js", () => ({ - prewarmSessionFile: vi.fn(async () => {}), - trackSessionManagerAccess: vi.fn(), -})); - -vi.mock("./system-prompt.js", () => ({ - applySystemPromptOverrideToSession: vi.fn(), - buildEmbeddedSystemPrompt: vi.fn(() => ""), - createSystemPromptOverride: vi.fn(() => () => ""), -})); - -vi.mock("./utils.js", () => ({ - describeUnknownError: vi.fn((err: unknown) => String(err)), - mapThinkingLevel: vi.fn(() => "off"), - resolveExecToolDefaults: vi.fn(() => undefined), -})); - -import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; -import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; -import { compactEmbeddedPiSessionDirect, compactEmbeddedPiSession } from "./compact.js"; +let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; +let compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession; +let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; const TEST_SESSION_ID = "session-1"; const TEST_SESSION_KEY = "agent:main:session-1"; const TEST_SESSION_FILE = "/tmp/session.jsonl"; const TEST_WORKSPACE_DIR = "/tmp"; const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions"; +type SessionHookEvent = { + type?: string; + action?: string; + sessionKey?: string; + context?: Record; +}; function mockResolvedModel() { resolveModelMock.mockReset(); @@ -389,10 +80,18 @@ function wrappedCompactionArgs(overrides: Record = {}) { }; } -const sessionHook = (action: string) => - triggerInternalHook.mock.calls.find( - (call) => call[0]?.type === "session" && call[0]?.action === action, - )?.[0]; +const sessionHook = (action: string): SessionHookEvent | undefined => + triggerInternalHook.mock.calls.find((call) => { + const event = call[0] as SessionHookEvent | undefined; + return event?.type === "session" && event.action === action; + })?.[0] as SessionHookEvent | undefined; + +beforeEach(async () => { + const loaded = await loadCompactHooksHarness(); + compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect; + compactEmbeddedPiSession = loaded.compactEmbeddedPiSession; + onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate; +}); describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { From 9a455a8c08fcd4b63a70c056805216fc7f157c27 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:15:51 +0000 Subject: [PATCH 179/393] Tests: remove compaction hook polling --- .../compact.hooks.harness.ts | 11 ++- .../pi-embedded-runner/compact.hooks.test.ts | 74 +++++++++++-------- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index fa796a1a59d..e065b0105b3 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -6,6 +6,11 @@ type MockResolvedModel = { authStorage: { setRuntimeApiKey: Mock<(provider?: string, apiKey?: string) => void> }; modelRegistry: Record; }; +type MockMemorySearchManager = { + manager: { + sync: (params?: unknown) => Promise; + }; +}; export const contextEngineCompactMock = vi.fn(async () => ({ ok: true as boolean, @@ -45,9 +50,11 @@ export const triggerInternalHook: Mock<(event?: unknown) => void> = vi.fn(); export const sanitizeSessionHistoryMock = vi.fn( async (params: { messages: unknown[] }) => params.messages, ); -export const getMemorySearchManagerMock = vi.fn(async () => ({ +export const getMemorySearchManagerMock: Mock< + (params?: unknown) => Promise +> = vi.fn(async () => ({ manager: { - sync: vi.fn(async () => {}), + sync: vi.fn(async (_params?: unknown) => {}), }, })); export const resolveMemorySearchConfigMock = vi.fn(() => ({ diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 8989176330a..c5806609c0d 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -34,6 +34,23 @@ type SessionHookEvent = { sessionKey?: string; context?: Record; }; +type PostCompactionSyncParams = { + reason: string; + sessionFiles: string[]; +}; +type PostCompactionSync = (params?: unknown) => Promise; +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} function mockResolvedModel() { resolveModelMock.mockReset(); @@ -388,11 +405,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("awaits post-compaction memory sync in await mode when postCompactionForce is true", async () => { - let releaseSync: (() => void) | undefined; - const syncGate = new Promise((resolve) => { - releaseSync = resolve; + const syncStarted = createDeferred(); + const syncRelease = createDeferred(); + const sync = vi.fn(async (params) => { + syncStarted.resolve(params as PostCompactionSyncParams); + await syncRelease.promise; }); - const sync = vi.fn(() => syncGate); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); let settled = false; @@ -405,14 +423,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { void resultPromise.then(() => { settled = true; }); - await vi.waitFor(() => { - expect(sync).toHaveBeenCalledWith({ - reason: "post-compaction", - sessionFiles: [TEST_SESSION_FILE], - }); + await expect(syncStarted.promise).resolves.toEqual({ + reason: "post-compaction", + sessionFiles: [TEST_SESSION_FILE], }); expect(settled).toBe(false); - releaseSync?.(); + syncRelease.resolve(undefined); const result = await resultPromise; expect(result.ok).toBe(true); expect(settled).toBe(true); @@ -435,12 +451,17 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("fires post-compaction memory sync without awaiting it in async mode", async () => { - const sync = vi.fn(async () => {}); - let resolveManager: ((value: { manager: { sync: typeof sync } }) => void) | undefined; - const managerGate = new Promise<{ manager: { sync: typeof sync } }>((resolve) => { - resolveManager = resolve; + const sync = vi.fn(async () => {}); + const managerRequested = createDeferred(); + const managerGate = createDeferred<{ manager: { sync: PostCompactionSync } }>(); + const syncStarted = createDeferred(); + sync.mockImplementation(async (params) => { + syncStarted.resolve(params as PostCompactionSyncParams); + }); + getMemorySearchManagerMock.mockImplementation(async () => { + managerRequested.resolve(undefined); + return await managerGate.promise; }); - getMemorySearchManagerMock.mockImplementation(() => managerGate); let settled = false; const resultPromise = compactEmbeddedPiSessionDirect( @@ -449,26 +470,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }), ); - await vi.waitFor(() => { - expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); - }); + await managerRequested.promise; void resultPromise.then(() => { settled = true; }); - await vi.waitFor(() => { - expect(settled).toBe(true); - }); + await resultPromise; + expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); + expect(settled).toBe(true); expect(sync).not.toHaveBeenCalled(); - resolveManager?.({ manager: { sync } }); - await managerGate; - await vi.waitFor(() => { - expect(sync).toHaveBeenCalledWith({ - reason: "post-compaction", - sessionFiles: [TEST_SESSION_FILE], - }); + managerGate.resolve({ manager: { sync } }); + await expect(syncStarted.promise).resolves.toEqual({ + reason: "post-compaction", + sessionFiles: [TEST_SESSION_FILE], }); - const result = await resultPromise; - expect(result.ok).toBe(true); }); it("registers the Ollama api provider before compaction", async () => { From 2d3bcbfe081880da23c3f44d2b84f062daf341bb Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:20:11 -0500 Subject: [PATCH 180/393] CLI: skip exec SecretRef dry-run resolution unless explicitly allowed (#49322) * CLI: gate exec SecretRef dry-run resolution behind opt-in * Docs: clarify config dry-run exec opt-in behavior * CLI: preserve static exec dry-run validation --- docs/cli/config.md | 31 +++- docs/cli/index.md | 5 +- src/cli/config-cli.integration.test.ts | 144 +++++++++++++++++ src/cli/config-cli.test.ts | 213 ++++++++++++++++++++++++- src/cli/config-cli.ts | 113 ++++++++++++- src/cli/config-set-dryrun.ts | 2 + src/cli/config-set-input.ts | 1 + 7 files changed, 495 insertions(+), 14 deletions(-) diff --git a/docs/cli/config.md b/docs/cli/config.md index ba4e6adf60f..72ba3af0c9d 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -176,19 +176,31 @@ openclaw config set channels.discord.token \ --ref-id DISCORD_BOT_TOKEN \ --dry-run \ --json + +openclaw config set channels.discord.token \ + --ref-provider vault \ + --ref-source exec \ + --ref-id discord/token \ + --dry-run \ + --allow-exec ``` Dry-run behavior: -- Builder mode: requires full SecretRef resolvability for changed refs/providers. -- JSON mode (`--strict-json`, `--json`, or batch mode): requires full resolvability and schema validation. +- Builder mode: runs SecretRef resolvability checks for changed refs/providers. +- JSON mode (`--strict-json`, `--json`, or batch mode): runs schema validation plus SecretRef resolvability checks. +- Exec SecretRef checks are skipped by default during dry-run to avoid command side effects. +- Use `--allow-exec` with `--dry-run` to opt in to exec SecretRef checks (this may execute provider commands). +- `--allow-exec` is dry-run only and errors if used without `--dry-run`. `--dry-run --json` prints a machine-readable report: - `ok`: whether dry-run passed - `operations`: number of assignments evaluated - `checks`: whether schema/resolvability checks ran -- `refsChecked`: number of refs resolved during dry-run +- `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped) +- `refsChecked`: number of refs actually resolved during dry-run +- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set - `errors`: structured schema/resolvability failures when `ok=false` ### JSON Output Shape @@ -202,8 +214,10 @@ Dry-run behavior: checks: { schema: boolean, resolvability: boolean, + resolvabilityComplete: boolean, }, refsChecked: number, + skippedExecRefs: number, errors?: [ { kind: "schema" | "resolvability", @@ -224,9 +238,11 @@ Success example: "inputModes": ["builder"], "checks": { "schema": false, - "resolvability": true + "resolvability": true, + "resolvabilityComplete": true }, - "refsChecked": 1 + "refsChecked": 1, + "skippedExecRefs": 0 } ``` @@ -240,9 +256,11 @@ Failure example: "inputModes": ["builder"], "checks": { "schema": false, - "resolvability": true + "resolvability": true, + "resolvabilityComplete": true }, "refsChecked": 1, + "skippedExecRefs": 0, "errors": [ { "kind": "resolvability", @@ -257,6 +275,7 @@ If dry-run fails: - `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape. - `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch). +- `Dry run note: skipped exec SecretRef resolvability check(s)`: dry-run skipped exec refs; rerun with `--allow-exec` if you need exec resolvability validation. - For batch mode, fix failing entries and rerun `--dry-run` before writing. ## Subcommands diff --git a/docs/cli/index.md b/docs/cli/index.md index 5acbb4b3166..a247a4085de 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -400,8 +400,9 @@ Subcommands: - SecretRef builder mode: `config set --ref-provider --ref-source --ref-id ` - provider builder mode: `config set secrets.providers. --provider-source ...` - batch mode: `config set --batch-json ''` or `config set --batch-file ` -- `config set --dry-run`: validate assignments without writing `openclaw.json`. -- `config set --dry-run --json`: emit machine-readable dry-run output (checks, operations, errors). +- `config set --dry-run`: validate assignments without writing `openclaw.json` (exec SecretRef checks are skipped by default). +- `config set --allow-exec --dry-run`: opt in to exec SecretRef dry-run checks (may execute provider commands). +- `config set --dry-run --json`: emit machine-readable dry-run output (checks + completeness signal, operations, refs checked/skipped, errors). - `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode. - `config unset `: remove a value. - `config file`: print the active config file path. diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index 1224d56c220..ed749019c34 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -23,6 +23,39 @@ function createTestRuntime() { }; } +function createExecDryRunBatch(params: { markerPath: string }) { + const response = JSON.stringify({ + protocolVersion: 1, + values: { + dryrun_id: "ok", + }, + }); + const script = [ + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(params.markerPath)}, "dryrun\\n", "utf8");`, + `process.stdout.write(${JSON.stringify(response)});`, + ].join(""); + return [ + { + path: "secrets.providers.runner", + provider: { + source: "exec", + command: process.execPath, + args: ["-e", script], + allowInsecurePath: true, + }, + }, + { + path: "channels.discord.token", + ref: { + source: "exec", + provider: "runner", + id: "dryrun_id", + }, + }, + ]; +} + describe("config cli integration", () => { it("supports batch-file dry-run and then writes real config changes", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-")); @@ -183,4 +216,115 @@ describe("config cli integration", () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + + it("skips exec provider execution during dry-run by default", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-skip-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const markerPath = path.join(tempDir, "marker.txt"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + }, + runtime: runtime.runtime, + }); + const after = fs.readFileSync(configPath, "utf8"); + + expect(after).toBe(before); + expect(fs.existsSync(markerPath)).toBe(false); + expect( + runtime.logs.some((line) => + line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ), + ).toBe(true); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("executes exec providers during dry-run when --allow-exec is set", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-allow-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const markerPath = path.join(tempDir, "marker.txt"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + allowExec: true, + }, + runtime: runtime.runtime, + }); + const after = fs.readFileSync(configPath, "utf8"); + + expect(after).toBe(before); + expect(fs.existsSync(markerPath)).toBe(true); + expect( + runtime.logs.some((line) => + line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ), + ).toBe(false); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 582cd9fd2d3..ded6ad806da 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -386,6 +386,7 @@ describe("config cli", () => { expect(helpText).toContain("--provider-source"); expect(helpText).toContain("--batch-json"); expect(helpText).toContain("--dry-run"); + expect(helpText).toContain("--allow-exec"); expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json"); expect(helpText).toContain( "openclaw config set channels.discord.token --ref-provider default --ref-source", @@ -556,6 +557,169 @@ describe("config cli", () => { expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); }); + it("skips exec SecretRef resolvability checks in dry-run by default", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Dry run note: skipped 1 exec SecretRef resolvability check(s). Re-run with --allow-exec", + ), + ); + }); + + it("allows exec SecretRef resolvability checks in dry-run when --allow-exec is set", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + "--allow-exec", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + expect.objectContaining({ + source: "exec", + provider: "runner", + id: "openai", + }), + expect.any(Object), + ); + expect(mockLog).not.toHaveBeenCalledWith( + expect.stringContaining("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ); + }); + + it("rejects --allow-exec without --dry-run", async () => { + const nonexistentBatchPath = path.join( + os.tmpdir(), + `openclaw-config-batch-nonexistent-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + await expect( + runConfigCommand(["config", "set", "--batch-file", nonexistentBatchPath, "--allow-exec"]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("config set mode error: --allow-exec requires --dry-run."), + ); + }); + + it("fails dry-run when skipped exec refs use an unconfigured provider", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: {}, + }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('Secret provider "runner" is not configured'), + ); + }); + + it("fails dry-run when skipped exec refs use a provider with mismatched source", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "env", + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + 'Secret provider "runner" has source "env" but ref requests "exec".', + ), + ); + }); + it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 }, @@ -749,19 +913,66 @@ describe("config cli", () => { expect(typeof raw).toBe("string"); const payload = JSON.parse(String(raw)) as { ok: boolean; - checks: { schema: boolean; resolvability: boolean }; + checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean }; refsChecked: number; + skippedExecRefs: number; operations: number; }; expect(payload.ok).toBe(true); expect(payload.operations).toBe(1); expect(payload.refsChecked).toBe(1); + expect(payload.skippedExecRefs).toBe(0); expect(payload.checks).toEqual({ schema: false, resolvability: true, + resolvabilityComplete: true, }); }); + it("emits skipped exec metadata for --dry-run --json success", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + "--json", + ]); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + checks: { resolvability: boolean; resolvabilityComplete: boolean }; + refsChecked: number; + skippedExecRefs: number; + }; + expect(payload.ok).toBe(true); + expect(payload.checks.resolvability).toBe(true); + expect(payload.checks.resolvabilityComplete).toBe(false); + expect(payload.refsChecked).toBe(0); + expect(payload.skippedExecRefs).toBe(1); + }); + it("emits structured JSON for --dry-run --json failure", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 }, diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 0da785a2fd8..8ec98f1804d 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -22,6 +22,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, isValidFileSecretRefId, isValidSecretProviderAlias, secretRefKey, @@ -815,6 +816,66 @@ async function collectDryRunResolvabilityErrors(params: { return failures; } +function collectDryRunStaticErrorsForSkippedExecRefs(params: { + refs: SecretRef[]; + config: OpenClawConfig; +}): ConfigSetDryRunError[] { + const failures: ConfigSetDryRunError[] = []; + for (const ref of params.refs) { + const id = ref.id.trim(); + const refLabel = `${ref.source}:${ref.provider}:${id}`; + if (!id) { + failures.push({ + kind: "resolvability", + message: "Error: Secret reference id is empty.", + ref: refLabel, + }); + continue; + } + if (!isValidExecSecretRefId(id)) { + failures.push({ + kind: "resolvability", + message: `Error: ${formatExecSecretRefIdValidationMessage()} (ref: ${refLabel}).`, + ref: refLabel, + }); + continue; + } + const providerConfig = params.config.secrets?.providers?.[ref.provider]; + if (!providerConfig) { + failures.push({ + kind: "resolvability", + message: `Error: Secret provider "${ref.provider}" is not configured (ref: ${refLabel}).`, + ref: refLabel, + }); + continue; + } + if (providerConfig.source !== ref.source) { + failures.push({ + kind: "resolvability", + message: `Error: Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "${ref.source}".`, + ref: refLabel, + }); + } + } + return failures; +} + +function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInDryRun: boolean }): { + refsToResolve: SecretRef[]; + skippedExecRefs: SecretRef[]; +} { + const refsToResolve: SecretRef[] = []; + const skippedExecRefs: SecretRef[] = []; + for (const ref of params.refs) { + if (ref.source === "exec" && !params.allowExecInDryRun) { + skippedExecRefs.push(ref); + continue; + } + refsToResolve.push(ref); + } + return { refsToResolve, skippedExecRefs }; +} + function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { const validated = validateConfigObjectRaw(config); if (validated.ok) { @@ -826,7 +887,11 @@ function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError })); } -function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string { +function formatDryRunFailureMessage(params: { + errors: ConfigSetDryRunError[]; + skippedExecRefs: number; +}): string { + const { errors, skippedExecRefs } = params; const schemaErrors = errors.filter((error) => error.kind === "schema"); const resolveErrors = errors.filter((error) => error.kind === "resolvability"); const lines: string[] = []; @@ -847,6 +912,11 @@ function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string { lines.push(`- ... ${resolveErrors.length - 5} more`); } } + if (skippedExecRefs > 0) { + lines.push( + `Dry run note: skipped ${skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ); + } return lines.join("\n"); } @@ -868,6 +938,9 @@ export async function runConfigSet(opts: { if (!modeResolution.ok) { throw modeError(modeResolution.error); } + if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) { + throw modeError("--allow-exec requires --dry-run."); + } const batchEntries = parseBatchSource(opts.cliOptions); if (batchEntries) { @@ -903,14 +976,24 @@ export async function runConfigSet(opts: { operations, }) : []; + const selectedDryRunRefs = selectDryRunRefsForResolution({ + refs, + allowExecInDryRun: Boolean(opts.cliOptions.allowExec), + }); const errors: ConfigSetDryRunError[] = []; if (hasJsonMode) { errors.push(...collectDryRunSchemaErrors(nextConfig)); } if (hasJsonMode || hasBuilderMode) { + errors.push( + ...collectDryRunStaticErrorsForSkippedExecRefs({ + refs: selectedDryRunRefs.skippedExecRefs, + config: nextConfig, + }), + ); errors.push( ...(await collectDryRunResolvabilityErrors({ - refs, + refs: selectedDryRunRefs.refsToResolve, config: nextConfig, })), ); @@ -923,15 +1006,23 @@ export async function runConfigSet(opts: { checks: { schema: hasJsonMode, resolvability: hasJsonMode || hasBuilderMode, + resolvabilityComplete: + (hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0, }, - refsChecked: refs.length, + refsChecked: selectedDryRunRefs.refsToResolve.length, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, ...(errors.length > 0 ? { errors } : {}), }; if (errors.length > 0) { if (opts.cliOptions.json) { throw new ConfigSetDryRunValidationError(dryRunResult); } - throw new Error(formatDryRunFailureMessage(errors)); + throw new Error( + formatDryRunFailureMessage({ + errors, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, + }), + ); } if (opts.cliOptions.json) { runtime.log(JSON.stringify(dryRunResult, null, 2)); @@ -943,6 +1034,13 @@ export async function runConfigSet(opts: { ), ); } + if (dryRunResult.skippedExecRefs > 0) { + runtime.log( + info( + `Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ), + ); + } runtime.log( info( `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, @@ -1133,7 +1231,12 @@ export function registerConfigCli(program: Command) { .option("--json", "Legacy alias for --strict-json", false) .option( "--dry-run", - "Validate changes without writing openclaw.json (checks run in builder/json/batch modes)", + "Validate changes without writing openclaw.json (checks run in builder/json/batch modes; exec SecretRefs are skipped unless --allow-exec is set)", + false, + ) + .option( + "--allow-exec", + "Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)", false, ) .option("--ref-provider ", "SecretRef builder: provider alias") diff --git a/src/cli/config-set-dryrun.ts b/src/cli/config-set-dryrun.ts index c122a47b33f..d121f25eab1 100644 --- a/src/cli/config-set-dryrun.ts +++ b/src/cli/config-set-dryrun.ts @@ -14,7 +14,9 @@ export type ConfigSetDryRunResult = { checks: { schema: boolean; resolvability: boolean; + resolvabilityComplete: boolean; }; refsChecked: number; + skippedExecRefs: number; errors?: ConfigSetDryRunError[]; }; diff --git a/src/cli/config-set-input.ts b/src/cli/config-set-input.ts index b5de984fcdd..b192422288f 100644 --- a/src/cli/config-set-input.ts +++ b/src/cli/config-set-input.ts @@ -5,6 +5,7 @@ export type ConfigSetOptions = { strictJson?: boolean; json?: boolean; dryRun?: boolean; + allowExec?: boolean; refProvider?: string; refSource?: string; refId?: string; From d073ec42cd7fabd1004f6959628743817a4cb0e8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:20:04 +0000 Subject: [PATCH 181/393] Tests: reuse embedded runner harness imports --- src/agents/pi-embedded-runner/compact.hooks.test.ts | 9 +++++++-- .../run.overflow-compaction.test.ts | 11 +++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index c5806609c0d..1a97501959e 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1,5 +1,5 @@ import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; import { contextEngineCompactMock, @@ -13,6 +13,7 @@ import { resolveMemorySearchConfigMock, resolveModelMock, resolveSessionAgentIdMock, + resetCompactHooksHarnessMocks, sanitizeSessionHistoryMock, sessionAbortCompactionMock, sessionCompactImpl, @@ -103,13 +104,17 @@ const sessionHook = (action: string): SessionHookEvent | undefined => return event?.type === "session" && event.action === action; })?.[0] as SessionHookEvent | undefined; -beforeEach(async () => { +beforeAll(async () => { const loaded = await loadCompactHooksHarness(); compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect; compactEmbeddedPiSession = loaded.compactEmbeddedPiSession; onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate; }); +beforeEach(() => { + resetCompactHooksHarnessMocks(); +}); + describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { ensureRuntimePluginsLoaded.mockReset(); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 75a9ab6e034..1f5f0b6de35 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -16,6 +16,7 @@ import { mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, + resetRunOverflowCompactionHarnessMocks, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams, @@ -24,10 +25,12 @@ import { let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - return loadRunOverflowCompactionHarness().then((loaded) => { - runEmbeddedPiAgent = loaded.runEmbeddedPiAgent; - }); + resetRunOverflowCompactionHarnessMocks(); }); beforeEach(() => { From d3fc6c0cc79fa347b1bbd9cc5566bc5319187bee Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 19:05:51 -0700 Subject: [PATCH 182/393] Plugins: internalize mattermost and tlon SDK imports --- extensions/mattermost/runtime-api.ts | 1 + extensions/mattermost/src/channel.ts | 2 +- extensions/mattermost/src/config-schema.ts | 2 +- extensions/mattermost/src/group-mentions.ts | 2 +- extensions/mattermost/src/mattermost/accounts.ts | 2 +- extensions/mattermost/src/mattermost/directory.ts | 2 +- extensions/mattermost/src/mattermost/interactions.ts | 2 +- extensions/mattermost/src/mattermost/model-picker.ts | 2 +- extensions/mattermost/src/mattermost/monitor-auth.ts | 4 ++-- extensions/mattermost/src/mattermost/monitor-helpers.ts | 4 ++-- .../mattermost/src/mattermost/monitor-websocket.ts | 2 +- extensions/mattermost/src/mattermost/monitor.ts | 4 ++-- extensions/mattermost/src/mattermost/probe.ts | 2 +- extensions/mattermost/src/mattermost/reactions.ts | 2 +- extensions/mattermost/src/mattermost/reply-delivery.ts | 4 ++-- extensions/mattermost/src/mattermost/runtime-api.ts | 1 + extensions/mattermost/src/mattermost/send.ts | 2 +- extensions/mattermost/src/mattermost/slash-http.ts | 2 +- extensions/mattermost/src/mattermost/slash-state.ts | 6 +++--- .../mattermost/src/mattermost/target-resolution.ts | 2 +- extensions/mattermost/src/runtime-api.ts | 1 + extensions/mattermost/src/runtime.ts | 2 +- extensions/mattermost/src/secret-input.ts | 2 +- extensions/mattermost/src/setup-core.ts | 2 +- extensions/mattermost/src/setup-surface.ts | 2 +- extensions/mattermost/src/types.ts | 2 +- extensions/tlon/api.ts | 1 + extensions/tlon/src/channel.runtime.ts | 9 +++------ extensions/tlon/src/channel.ts | 4 ++-- extensions/tlon/src/config-schema.ts | 2 +- extensions/tlon/src/monitor/discovery.ts | 2 +- extensions/tlon/src/monitor/history.ts | 2 +- extensions/tlon/src/monitor/index.ts | 4 ++-- extensions/tlon/src/monitor/media.ts | 2 +- extensions/tlon/src/monitor/processed-messages.ts | 2 +- extensions/tlon/src/runtime.ts | 2 +- extensions/tlon/src/types.ts | 2 +- extensions/tlon/src/urbit/auth.ts | 2 +- extensions/tlon/src/urbit/base-url.ts | 2 +- extensions/tlon/src/urbit/channel-ops.ts | 2 +- extensions/tlon/src/urbit/context.ts | 2 +- extensions/tlon/src/urbit/fetch.ts | 4 ++-- extensions/tlon/src/urbit/sse-client.ts | 2 +- extensions/tlon/src/urbit/upload.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 2 ++ 45 files changed, 57 insertions(+), 54 deletions(-) create mode 100644 extensions/mattermost/runtime-api.ts create mode 100644 extensions/mattermost/src/mattermost/runtime-api.ts create mode 100644 extensions/mattermost/src/runtime-api.ts diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts new file mode 100644 index 00000000000..e13fee5ad71 --- /dev/null +++ b/extensions/mattermost/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/mattermost"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 964310bcbdd..90c7b718639 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -17,7 +17,7 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, type ChannelPlugin, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index d578de86e9a..bd1f42dfd7f 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -4,7 +4,7 @@ import { GroupPolicySchema, MarkdownConfigSchema, requireOpenAllowFrom, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { z } from "zod"; import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 153edc2c84c..4996d115371 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -1,5 +1,5 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import type { ChannelGroupContext } from "openclaw/plugin-sdk/mattermost"; +import type { ChannelGroupContext } from "./runtime-api.js"; import { resolveMattermostAccount } from "./mattermost/accounts.js"; export function resolveMattermostGroupRequireMention( diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index ae154ba8923..7f2b3ff4175 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js"; import type { MattermostAccountConfig, diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index 1b9d3e91e86..630ed7c7194 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -2,7 +2,7 @@ import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index f4ef06cf1ed..a51002667f8 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -4,7 +4,7 @@ import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index 1547041a74a..925308b04cc 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -6,7 +6,7 @@ import { resolveStoredModelOverride, type ModelsProviderData, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { MattermostInteractiveButtonInput } from "./interactions.js"; const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker"; diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 7f263cd09b5..e83f06b8ba6 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -1,11 +1,11 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { evaluateSenderGroupAccessForPolicy, isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple, resolveControlCommandGate, resolveEffectiveAllowFromLists, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import type { MattermostChannel } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index 219c0562638..d6ce7ee4aa9 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -2,8 +2,8 @@ import { formatInboundFromLabel as formatInboundFromLabelShared, resolveThreadSessionKeys as resolveThreadSessionKeysShared, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; -export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; +export { createDedupeCache, rawDataToString } from "../runtime-api.js"; export type ResponsePrefixContext = { model?: string; diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts index 7f04a18f09b..c04affbae1d 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -1,4 +1,4 @@ -import type { ChannelAccountSnapshot, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; +import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js"; import WebSocket from "ws"; import type { MattermostPost } from "./client.js"; import { rawDataToString } from "./monitor-helpers.js"; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index e56e4a9b9af..a849cf52160 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig, ReplyPayload, RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { buildAgentMediaPayload, buildModelsProviderData, @@ -30,7 +30,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, listSkillCommandsForAgents, type HistoryEntry, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js"; import { diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index 2966e20f209..d3ee56ab3a0 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/mattermost"; +import type { BaseProbeResult } from "../runtime-api.js"; import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; export type MattermostProbe = BaseProbeResult & { diff --git a/extensions/mattermost/src/mattermost/reactions.ts b/extensions/mattermost/src/mattermost/reactions.ts index 3515153edd2..42de67b4e10 100644 --- a/extensions/mattermost/src/mattermost/reactions.ts +++ b/extensions/mattermost/src/mattermost/reactions.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 5c94e51934b..6fc88c8ba83 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost"; -import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; +import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; type MarkdownTableMode = Parameters[1]; diff --git a/extensions/mattermost/src/mattermost/runtime-api.ts b/extensions/mattermost/src/mattermost/runtime-api.ts new file mode 100644 index 00000000000..cb133391638 --- /dev/null +++ b/extensions/mattermost/src/mattermost/runtime-api.ts @@ -0,0 +1 @@ +export * from "../../runtime-api.js"; diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index c589c8829a0..e6bbdf2298a 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,4 +1,4 @@ -import { loadOutboundMediaFromUrl, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index a094b3571ff..401cc56172a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -16,7 +16,7 @@ import { type OpenClawConfig, type ReplyPayload, type RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index f79f670df8d..8e5fe1f08b3 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -10,7 +10,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js"; import { createSlashCommandHttpHandler } from "./slash-http.js"; @@ -86,8 +86,8 @@ export function activateSlashCommands(params: { registeredCommands: MattermostRegisteredCommand[]; triggerMap?: Map; api: { - cfg: import("openclaw/plugin-sdk/mattermost").OpenClawConfig; - runtime: import("openclaw/plugin-sdk/mattermost").RuntimeEnv; + cfg: import("../runtime-api.js").OpenClawConfig; + runtime: import("../runtime-api.js").RuntimeEnv; }; log?: (msg: string) => void; }) { diff --git a/extensions/mattermost/src/mattermost/target-resolution.ts b/extensions/mattermost/src/mattermost/target-resolution.ts index d3b59a3e696..9fa1a170ca3 100644 --- a/extensions/mattermost/src/mattermost/target-resolution.ts +++ b/extensions/mattermost/src/mattermost/target-resolution.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/runtime-api.ts b/extensions/mattermost/src/runtime-api.ts new file mode 100644 index 00000000000..ece735819df --- /dev/null +++ b/extensions/mattermost/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "../runtime-api.js"; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index b5ec1942973..e238fa963e2 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost"; +import type { PluginRuntime } from "./runtime-api.js"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = diff --git a/extensions/mattermost/src/secret-input.ts b/extensions/mattermost/src/secret-input.ts index 576f5b9fc45..b32083456e7 100644 --- a/extensions/mattermost/src/secret-input.ts +++ b/extensions/mattermost/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 781967c70a6..13a4991fcd0 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -7,7 +7,7 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index d3b0a66b4c8..385c4dc75e3 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -3,7 +3,7 @@ import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; import { listMattermostAccountIds } from "./mattermost/accounts.js"; diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index e6fcc19098c..b77a542122b 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -3,7 +3,7 @@ import type { DmPolicy, GroupPolicy, SecretInput, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; export type MattermostReplyToMode = "off" | "first" | "all"; export type MattermostChatTypeKey = "direct" | "channel" | "group"; diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts index 8f7fe4d268b..ca61d62ee69 100644 --- a/extensions/tlon/api.ts +++ b/extensions/tlon/api.ts @@ -1,2 +1,3 @@ +export * from "openclaw/plugin-sdk/tlon"; export * from "./src/setup-core.js"; export * from "./src/setup-surface.js"; diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 525359a2a4e..c6523f61739 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,10 +1,7 @@ import crypto from "node:crypto"; import { configureClient } from "@tloncorp/api"; -import type { - ChannelOutboundAdapter, - ChannelPlugin, - OpenClawConfig, -} from "openclaw/plugin-sdk/tlon"; +import type { ChannelOutboundAdapter, ChannelPlugin, OpenClawConfig } from "../api.js"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../api.js"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonSetupWizard } from "./setup-surface.js"; import { @@ -230,7 +227,7 @@ export async function startTlonGatewayAccount( accountId: account.accountId, ship: account.ship, url: account.url, - } as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot); + } as ChannelAccountSnapshot); ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`); return monitorTlonProvider({ runtime: ctx.runtime, diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index daea0d8a52e..0e22d237589 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,5 +1,4 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { applyTlonSetupConfig, @@ -14,6 +13,7 @@ import { resolveTlonOutboundTarget, } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; +import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const TLON_CHANNEL_ID = "tlon" as const; @@ -214,7 +214,7 @@ export const tlonPlugin: ChannelPlugin = { lastError: runtime?.lastError ?? null, probe, }; - return snapshot as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot; + return snapshot as ChannelAccountSnapshot; }, }, gateway: { diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 666f65e35da..7f12949f30d 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -1,4 +1,4 @@ -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/tlon"; +import { buildChannelConfigSchema } from "../api.js"; import { z } from "zod"; const ShipSchema = z.string().min(1); diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts index a7224608bf0..66ec43a2680 100644 --- a/extensions/tlon/src/monitor/discovery.ts +++ b/extensions/tlon/src/monitor/discovery.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv } from "../../api.js"; import type { Foreigns } from "../urbit/foreigns.js"; import { formatChangesDate } from "./utils.js"; diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts index a67fae7ada4..0ebfc6e231c 100644 --- a/extensions/tlon/src/monitor/history.ts +++ b/extensions/tlon/src/monitor/history.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv } from "../../api.js"; import { extractMessageText } from "./utils.js"; /** diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 19c9ec5b841..e7749010462 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; -import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "../../api.js"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../../api.js"; import { getTlonRuntime } from "../runtime.js"; import { createSettingsManager, type TlonSettingsStore } from "../settings.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index 588598e4d2d..ea86328d2ce 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import { fetchWithSsrFGuard } from "../api.js"; import { getDefaultSsrFPolicy } from "../urbit/context.js"; // Default to OpenClaw workspace media directory diff --git a/extensions/tlon/src/monitor/processed-messages.ts b/extensions/tlon/src/monitor/processed-messages.ts index d849724c4a5..ed5231aa98b 100644 --- a/extensions/tlon/src/monitor/processed-messages.ts +++ b/extensions/tlon/src/monitor/processed-messages.ts @@ -1,4 +1,4 @@ -import { createDedupeCache } from "openclaw/plugin-sdk/tlon"; +import { createDedupeCache } from "../../api.js"; export type ProcessedMessageTracker = { mark: (id?: string | null) => boolean; diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index a07eb5cf648..bf284e214a8 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/tlon"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = createPluginRuntimeStore("Tlon runtime not initialized"); diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts index e9bc27ac169..7aa0690c14f 100644 --- a/extensions/tlon/src/types.ts +++ b/extensions/tlon/src/types.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon"; +import type { OpenClawConfig } from "../api.js"; export type TlonResolvedAccount = { accountId: string; diff --git a/extensions/tlon/src/urbit/auth.ts b/extensions/tlon/src/urbit/auth.ts index 3b7ccd16593..687fb0e4121 100644 --- a/extensions/tlon/src/urbit/auth.ts +++ b/extensions/tlon/src/urbit/auth.ts @@ -1,4 +1,4 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { UrbitAuthError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts index e90168b47a9..15321d3e391 100644 --- a/extensions/tlon/src/urbit/base-url.ts +++ b/extensions/tlon/src/urbit/base-url.ts @@ -1,4 +1,4 @@ -import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/tlon"; +import { isBlockedHostnameOrIp } from "../../api.js"; export type UrbitBaseUrlValidation = | { ok: true; baseUrl: string; hostname: string } diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index ef65e4ca9fe..98b3981942e 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -1,4 +1,4 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { UrbitHttpError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/context.ts b/extensions/tlon/src/urbit/context.ts index 6fbae002f5d..01b49d94041 100644 --- a/extensions/tlon/src/urbit/context.ts +++ b/extensions/tlon/src/urbit/context.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { SsrFPolicy } from "../../api.js"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts index a1551df547d..638c70f0840 100644 --- a/extensions/tlon/src/urbit/fetch.ts +++ b/extensions/tlon/src/urbit/fetch.ts @@ -1,5 +1,5 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; +import { fetchWithSsrFGuard } from "../../api.js"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index afa87502320..2fae6b82041 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/upload.ts b/extensions/tlon/src/urbit/upload.ts index 81aaef84a06..6176c132207 100644 --- a/extensions/tlon/src/urbit/upload.ts +++ b/extensions/tlon/src/urbit/upload.ts @@ -2,7 +2,7 @@ * Upload an image from a URL to Tlon storage. */ import { uploadFile } from "@tloncorp/api"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import { fetchWithSsrFGuard } from "../../api.js"; import { getDefaultSsrFPolicy } from "./context.js"; /** diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 0d49e580d11..b953d4d974a 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -122,11 +122,13 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "diffs", "llm-task", "line", + "mattermost", "memory-lancedb", "nextcloud-talk", "synology-chat", "talk-voice", "thread-ownership", + "tlon", "voice-call", ] as const; From 4c36436fb4ddf2126de04acf375c834236ce9104 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:06:29 +0000 Subject: [PATCH 183/393] Plugin SDK: add legacy message discovery helper --- extensions/feishu/src/channel.ts | 109 +++++++++++--------- extensions/mattermost/src/channel.ts | 87 +++++++++------- extensions/msteams/src/channel.ts | 51 +++++---- src/channels/plugins/message-tool-legacy.ts | 13 +++ src/plugin-sdk/channel-runtime.ts | 1 + 5 files changed, 152 insertions(+), 109 deletions(-) create mode 100644 src/channels/plugins/message-tool-legacy.ts diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index ded06f97f53..da5cd8e4382 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,8 +1,14 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolCardSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { buildChannelConfigSchema, @@ -49,6 +55,56 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( "feishuChannelRuntime", ); +function describeFeishuMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabled = + cfg.channels?.feishu?.enabled !== false && + Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)); + if (listEnabledFeishuAccounts(cfg).length === 0) { + return { + actions: [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; + } + const actions = new Set([ + "send", + "read", + "edit", + "thread-reply", + "pin", + "list-pins", + "unpin", + "member-info", + "channel-info", + "channel-list", + ]); + if (areAnyFeishuReactionActionsEnabled(cfg)) { + actions.add("react"); + actions.add("reactions"); + } + return { + actions: Array.from(actions), + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; +} + function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, accountId: string, @@ -396,53 +452,8 @@ export const feishuPlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), }, actions: { - describeMessageTool: ({ - cfg, - }: Parameters>[0]) => { - const enabled = - cfg.channels?.feishu?.enabled !== false && - Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)); - if (listEnabledFeishuAccounts(cfg).length === 0) { - return { - actions: [], - capabilities: enabled ? ["cards"] : [], - schema: enabled - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, - }; - } - const actions = new Set([ - "send", - "read", - "edit", - "thread-reply", - "pin", - "list-pins", - "unpin", - "member-info", - "channel-info", - "channel-list", - ]); - if (areAnyFeishuReactionActionsEnabled(cfg)) { - actions.add("react"); - actions.add("reactions"); - } - return { - actions: Array.from(actions), - capabilities: enabled ? ["cards"] : [], - schema: enabled - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, - }; - }, + describeMessageTool: describeFeishuMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeFeishuMessageTool), handleAction: async (ctx) => { const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined }); if ( diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 90c7b718639..5688e13d8ae 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -4,7 +4,11 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolButtonsSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, @@ -42,46 +46,49 @@ import { getMattermostRuntime } from "./runtime.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; +function describeMattermostMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabledAccounts = listMattermostAccountIds(cfg) + .map((accountId) => resolveMattermostAccount({ cfg, accountId })) + .filter((account) => account.enabled) + .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); + + const actions: ChannelMessageActionName[] = []; + + if (enabledAccounts.length > 0) { + actions.push("send"); + } + + const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; + const baseReactions = actionsConfig?.reactions; + const hasReactionCapableAccount = enabledAccounts.some((account) => { + const accountActions = account.config.actions as { reactions?: boolean } | undefined; + return (accountActions?.reactions ?? baseReactions ?? true) !== false; + }); + if (hasReactionCapableAccount) { + actions.push("react"); + } + + return { + actions, + capabilities: enabledAccounts.length > 0 ? ["buttons"] : [], + schema: + enabledAccounts.length > 0 + ? { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + } + : null, + }; +} + const mattermostMessageActions: ChannelMessageActionAdapter = { - describeMessageTool: ({ - cfg, - }: Parameters>[0]) => { - const enabledAccounts = listMattermostAccountIds(cfg) - .map((accountId) => resolveMattermostAccount({ cfg, accountId })) - .filter((account) => account.enabled) - .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); - - const actions: ChannelMessageActionName[] = []; - - // Send (buttons) is available whenever there's at least one enabled account - if (enabledAccounts.length > 0) { - actions.push("send"); - } - - // React requires per-account reactions config check - const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; - const baseReactions = actionsConfig?.reactions; - const hasReactionCapableAccount = enabledAccounts.some((account) => { - const accountActions = account.config.actions as { reactions?: boolean } | undefined; - return (accountActions?.reactions ?? baseReactions ?? true) !== false; - }); - if (hasReactionCapableAccount) { - actions.push("react"); - } - - return { - actions, - capabilities: enabledAccounts.length > 0 ? ["buttons"] : [], - schema: - enabledAccounts.length > 0 - ? { - properties: { - buttons: createMessageToolButtonsSchema(), - }, - } - : null, - }; - }, + describeMessageTool: describeMattermostMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMattermostMessageTool), supportsAction: ({ action }) => { return action === "send" || action === "react"; }, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 827507c24f2..7458389efb1 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,7 +1,13 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolCardSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, @@ -64,6 +70,27 @@ const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( "msTeamsChannelRuntime", ); +function describeMSTeamsMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabled = + cfg.channels?.msteams?.enabled !== false && + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); + return { + actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; +} + export const msteamsPlugin: ChannelPlugin = { id: "msteams", meta: { @@ -370,24 +397,8 @@ export const msteamsPlugin: ChannelPlugin = { }, }, actions: { - describeMessageTool: ({ - cfg, - }: Parameters>[0]) => { - const enabled = - cfg.channels?.msteams?.enabled !== false && - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); - return { - actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [], - capabilities: enabled ? ["cards"] : [], - schema: enabled - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, - }; - }, + describeMessageTool: describeMSTeamsMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMSTeamsMessageTool), handleAction: async (ctx) => { // Handle send action with card parameter if (ctx.action === "send" && ctx.params.card) { diff --git a/src/channels/plugins/message-tool-legacy.ts b/src/channels/plugins/message-tool-legacy.ts new file mode 100644 index 00000000000..2c74213439f --- /dev/null +++ b/src/channels/plugins/message-tool-legacy.ts @@ -0,0 +1,13 @@ +import type { ChannelMessageActionAdapter } from "./types.js"; + +export function createLegacyMessageToolDiscoveryMethods( + describeMessageTool: NonNullable, +): Pick { + const describe = (ctx: Parameters[0]) => + describeMessageTool(ctx) ?? null; + return { + listActions: (ctx) => [...(describe(ctx)?.actions ?? [])], + getCapabilities: (ctx) => [...(describe(ctx)?.capabilities ?? [])], + getToolSchema: (ctx) => describe(ctx)?.schema ?? null, + }; +} diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 089e10609af..1460acba87d 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -34,6 +34,7 @@ export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; export * from "../channels/plugins/directory-config.js"; export * from "../channels/plugins/media-payload.js"; +export * from "../channels/plugins/message-tool-legacy.js"; export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; From 9df3e9b617b2251462a9f9c8c96d31ec76fceca4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:01 +0000 Subject: [PATCH 184/393] Discord: move action runtime into extension --- extensions/discord/runtime-api.ts | 3 + .../src/actions/handle-action.guild-admin.ts | 6 +- .../discord/src/actions/handle-action.ts | 4 +- .../discord/src/actions/runtime.guild.ts | 139 +++++++---- .../discord/src/actions/runtime.messaging.ts | 235 ++++++++++++------ .../src/actions/runtime.moderation-shared.ts | 2 +- .../actions/runtime.moderation.authz.test.ts | 37 +-- .../discord/src/actions/runtime.moderation.ts | 33 ++- .../src/actions/runtime.presence.test.ts | 11 +- .../discord/src/actions/runtime.presence.ts | 10 +- .../discord/src/actions/runtime.shared.ts | 2 +- .../discord/src/actions/runtime.test.ts | 48 ++-- .../discord/src/actions/runtime.ts | 14 +- extensions/discord/src/channel-actions.ts | 2 + src/plugin-sdk/agent-runtime.ts | 12 +- 15 files changed, 363 insertions(+), 195 deletions(-) rename src/agents/tools/discord-actions-guild.ts => extensions/discord/src/actions/runtime.guild.ts (78%) rename src/agents/tools/discord-actions-messaging.ts => extensions/discord/src/actions/runtime.messaging.ts (71%) rename src/agents/tools/discord-actions-moderation-shared.ts => extensions/discord/src/actions/runtime.moderation-shared.ts (94%) rename src/agents/tools/discord-actions-moderation.authz.test.ts => extensions/discord/src/actions/runtime.moderation.authz.test.ts (81%) rename src/agents/tools/discord-actions-moderation.ts => extensions/discord/src/actions/runtime.moderation.ts (78%) rename src/agents/tools/discord-actions-presence.test.ts => extensions/discord/src/actions/runtime.presence.test.ts (94%) rename src/agents/tools/discord-actions-presence.ts => extensions/discord/src/actions/runtime.presence.ts (92%) rename src/agents/tools/discord-actions-shared.ts => extensions/discord/src/actions/runtime.shared.ts (78%) rename src/agents/tools/discord-actions.test.ts => extensions/discord/src/actions/runtime.test.ts (94%) rename src/agents/tools/discord-actions.ts => extensions/discord/src/actions/runtime.ts (79%) diff --git a/extensions/discord/runtime-api.ts b/extensions/discord/runtime-api.ts index 3850143c4ef..938b03d9c4a 100644 --- a/extensions/discord/runtime-api.ts +++ b/extensions/discord/runtime-api.ts @@ -1,4 +1,7 @@ export * from "./src/audit.js"; +export * from "./src/actions/runtime.js"; +export * from "./src/actions/runtime.moderation-shared.js"; +export * from "./src/actions/runtime.shared.js"; export * from "./src/channel-actions.js"; export * from "./src/directory-live.js"; export * from "./src/monitor.js"; diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index 0f6075384a5..e63d00f23ec 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -5,12 +5,12 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import { handleDiscordAction } from "./runtime.js"; import { isDiscordModerationAction, readDiscordModerationCommand, -} from "openclaw/plugin-sdk/agent-runtime"; -import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +} from "./runtime.moderation-shared.js"; type Ctx = Pick< ChannelMessageActionContext, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index d23b078292a..0fca934e86f 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -4,8 +4,6 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import { readDiscordParentIdParam } from "openclaw/plugin-sdk/agent-runtime"; -import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; @@ -13,6 +11,8 @@ import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; +import { handleDiscordAction } from "./runtime.js"; +import { readDiscordParentIdParam } from "./runtime.shared.js"; const providerId = "discord"; diff --git a/src/agents/tools/discord-actions-guild.ts b/extensions/discord/src/actions/runtime.guild.ts similarity index 78% rename from src/agents/tools/discord-actions-guild.ts rename to extensions/discord/src/actions/runtime.guild.ts index fa427d87650..5b3ed54dc83 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -1,5 +1,14 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; +import { + type ActionGate, + jsonResult, + parseAvailableTags, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { getPresence } from "../monitor/presence-cache.js"; import { addRoleDiscord, createChannelDiscord, @@ -19,17 +28,29 @@ import { setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../plugin-sdk/discord.js"; -import { getPresence } from "../../plugin-sdk/discord.js"; -import { - type ActionGate, - jsonResult, - parseAvailableTags, - readNumberParam, - readStringArrayParam, - readStringParam, -} from "./common.js"; -import { readDiscordParentIdParam } from "./discord-actions-shared.js"; +} from "../send.js"; +import { readDiscordParentIdParam } from "./runtime.shared.js"; + +export const discordGuildActionRuntime = { + addRoleDiscord, + createChannelDiscord, + createScheduledEventDiscord, + deleteChannelDiscord, + editChannelDiscord, + fetchChannelInfoDiscord, + fetchMemberInfoDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, + listGuildChannelsDiscord, + listGuildEmojisDiscord, + listScheduledEventsDiscord, + moveChannelDiscord, + removeChannelPermissionDiscord, + removeRoleDiscord, + setChannelPermissionDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, +}; type DiscordRoleMutation = (params: { guildId: string; @@ -85,8 +106,8 @@ export async function handleDiscordGuildAction( required: true, }); const member = accountId - ? await fetchMemberInfoDiscord(guildId, userId, { accountId }) - : await fetchMemberInfoDiscord(guildId, userId); + ? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId }) + : await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId); const presence = getPresence(accountId, userId); const activities = presence?.activities ?? undefined; const status = presence?.status ?? undefined; @@ -100,8 +121,8 @@ export async function handleDiscordGuildAction( required: true, }); const roles = accountId - ? await fetchRoleInfoDiscord(guildId, { accountId }) - : await fetchRoleInfoDiscord(guildId); + ? await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId); return jsonResult({ ok: true, roles }); } case "emojiList": { @@ -112,8 +133,8 @@ export async function handleDiscordGuildAction( required: true, }); const emojis = accountId - ? await listGuildEmojisDiscord(guildId, { accountId }) - : await listGuildEmojisDiscord(guildId); + ? await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listGuildEmojisDiscord(guildId); return jsonResult({ ok: true, emojis }); } case "emojiUpload": { @@ -129,7 +150,7 @@ export async function handleDiscordGuildAction( }); const roleIds = readStringArrayParam(params, "roleIds"); const emoji = accountId - ? await uploadEmojiDiscord( + ? await discordGuildActionRuntime.uploadEmojiDiscord( { guildId, name, @@ -138,7 +159,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await uploadEmojiDiscord({ + : await discordGuildActionRuntime.uploadEmojiDiscord({ guildId, name, mediaUrl, @@ -162,7 +183,7 @@ export async function handleDiscordGuildAction( required: true, }); const sticker = accountId - ? await uploadStickerDiscord( + ? await discordGuildActionRuntime.uploadStickerDiscord( { guildId, name, @@ -172,7 +193,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await uploadStickerDiscord({ + : await discordGuildActionRuntime.uploadStickerDiscord({ guildId, name, description, @@ -185,14 +206,22 @@ export async function handleDiscordGuildAction( if (!isActionEnabled("roles", false)) { throw new Error("Discord role changes are disabled."); } - await runRoleMutation({ accountId, values: params, mutate: addRoleDiscord }); + await runRoleMutation({ + accountId, + values: params, + mutate: discordGuildActionRuntime.addRoleDiscord, + }); return jsonResult({ ok: true }); } case "roleRemove": { if (!isActionEnabled("roles", false)) { throw new Error("Discord role changes are disabled."); } - await runRoleMutation({ accountId, values: params, mutate: removeRoleDiscord }); + await runRoleMutation({ + accountId, + values: params, + mutate: discordGuildActionRuntime.removeRoleDiscord, + }); return jsonResult({ ok: true }); } case "channelInfo": { @@ -203,8 +232,8 @@ export async function handleDiscordGuildAction( required: true, }); const channel = accountId - ? await fetchChannelInfoDiscord(channelId, { accountId }) - : await fetchChannelInfoDiscord(channelId); + ? await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId, { accountId }) + : await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId); return jsonResult({ ok: true, channel }); } case "channelList": { @@ -215,8 +244,8 @@ export async function handleDiscordGuildAction( required: true, }); const channels = accountId - ? await listGuildChannelsDiscord(guildId, { accountId }) - : await listGuildChannelsDiscord(guildId); + ? await discordGuildActionRuntime.listGuildChannelsDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listGuildChannelsDiscord(guildId); return jsonResult({ ok: true, channels }); } case "voiceStatus": { @@ -230,8 +259,10 @@ export async function handleDiscordGuildAction( required: true, }); const voice = accountId - ? await fetchVoiceStatusDiscord(guildId, userId, { accountId }) - : await fetchVoiceStatusDiscord(guildId, userId); + ? await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId, { + accountId, + }) + : await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId); return jsonResult({ ok: true, voice }); } case "eventList": { @@ -242,8 +273,8 @@ export async function handleDiscordGuildAction( required: true, }); const events = accountId - ? await listScheduledEventsDiscord(guildId, { accountId }) - : await listScheduledEventsDiscord(guildId); + ? await discordGuildActionRuntime.listScheduledEventsDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listScheduledEventsDiscord(guildId); return jsonResult({ ok: true, events }); } case "eventCreate": { @@ -274,8 +305,10 @@ export async function handleDiscordGuildAction( privacy_level: 2, }; const event = accountId - ? await createScheduledEventDiscord(guildId, payload, { accountId }) - : await createScheduledEventDiscord(guildId, payload); + ? await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload, { + accountId, + }) + : await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload); return jsonResult({ ok: true, event }); } case "channelCreate": { @@ -290,7 +323,7 @@ export async function handleDiscordGuildAction( const position = readNumberParam(params, "position", { integer: true }); const nsfw = params.nsfw as boolean | undefined; const channel = accountId - ? await createChannelDiscord( + ? await discordGuildActionRuntime.createChannelDiscord( { guildId, name, @@ -302,7 +335,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await createChannelDiscord({ + : await discordGuildActionRuntime.createChannelDiscord({ guildId, name, type: type ?? undefined, @@ -348,8 +381,8 @@ export async function handleDiscordGuildAction( availableTags, }; const channel = accountId - ? await editChannelDiscord(editPayload, { accountId }) - : await editChannelDiscord(editPayload); + ? await discordGuildActionRuntime.editChannelDiscord(editPayload, { accountId }) + : await discordGuildActionRuntime.editChannelDiscord(editPayload); return jsonResult({ ok: true, channel }); } case "channelDelete": { @@ -360,8 +393,8 @@ export async function handleDiscordGuildAction( required: true, }); const result = accountId - ? await deleteChannelDiscord(channelId, { accountId }) - : await deleteChannelDiscord(channelId); + ? await discordGuildActionRuntime.deleteChannelDiscord(channelId, { accountId }) + : await discordGuildActionRuntime.deleteChannelDiscord(channelId); return jsonResult(result); } case "channelMove": { @@ -375,7 +408,7 @@ export async function handleDiscordGuildAction( const parentId = readDiscordParentIdParam(params); const position = readNumberParam(params, "position", { integer: true }); if (accountId) { - await moveChannelDiscord( + await discordGuildActionRuntime.moveChannelDiscord( { guildId, channelId, @@ -385,7 +418,7 @@ export async function handleDiscordGuildAction( { accountId }, ); } else { - await moveChannelDiscord({ + await discordGuildActionRuntime.moveChannelDiscord({ guildId, channelId, parentId, @@ -402,7 +435,7 @@ export async function handleDiscordGuildAction( const name = readStringParam(params, "name", { required: true }); const position = readNumberParam(params, "position", { integer: true }); const channel = accountId - ? await createChannelDiscord( + ? await discordGuildActionRuntime.createChannelDiscord( { guildId, name, @@ -411,7 +444,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await createChannelDiscord({ + : await discordGuildActionRuntime.createChannelDiscord({ guildId, name, type: 4, @@ -429,7 +462,7 @@ export async function handleDiscordGuildAction( const name = readStringParam(params, "name"); const position = readNumberParam(params, "position", { integer: true }); const channel = accountId - ? await editChannelDiscord( + ? await discordGuildActionRuntime.editChannelDiscord( { channelId: categoryId, name: name ?? undefined, @@ -437,7 +470,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await editChannelDiscord({ + : await discordGuildActionRuntime.editChannelDiscord({ channelId: categoryId, name: name ?? undefined, position: position ?? undefined, @@ -452,8 +485,8 @@ export async function handleDiscordGuildAction( required: true, }); const result = accountId - ? await deleteChannelDiscord(categoryId, { accountId }) - : await deleteChannelDiscord(categoryId); + ? await discordGuildActionRuntime.deleteChannelDiscord(categoryId, { accountId }) + : await discordGuildActionRuntime.deleteChannelDiscord(categoryId); return jsonResult(result); } case "channelPermissionSet": { @@ -468,7 +501,7 @@ export async function handleDiscordGuildAction( const allow = readStringParam(params, "allow"); const deny = readStringParam(params, "deny"); if (accountId) { - await setChannelPermissionDiscord( + await discordGuildActionRuntime.setChannelPermissionDiscord( { channelId, targetId, @@ -479,7 +512,7 @@ export async function handleDiscordGuildAction( { accountId }, ); } else { - await setChannelPermissionDiscord({ + await discordGuildActionRuntime.setChannelPermissionDiscord({ channelId, targetId, targetType, @@ -495,9 +528,11 @@ export async function handleDiscordGuildAction( } const { channelId, targetId } = readChannelPermissionTarget(params); if (accountId) { - await removeChannelPermissionDiscord(channelId, targetId, { accountId }); + await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId, { + accountId, + }); } else { - await removeChannelPermissionDiscord(channelId, targetId); + await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId); } return jsonResult({ ok: true }); } diff --git a/src/agents/tools/discord-actions-messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts similarity index 71% rename from src/agents/tools/discord-actions-messaging.ts rename to extensions/discord/src/actions/runtime.messaging.ts index bad969ede80..92ef443cf44 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -1,7 +1,19 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; +import { withNormalizedTimestamp } from "../../../../src/agents/date-time.js"; +import { assertMediaNotDataUrl } from "../../../../src/agents/sandbox-paths.js"; +import { + type ActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { resolvePollMaxSelections } from "../../../../src/polls.js"; +import { readDiscordComponentSpec } from "../components.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -23,20 +35,34 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../plugin-sdk/discord.js"; -import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js"; -import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js"; -import { resolvePollMaxSelections } from "../../polls.js"; -import { withNormalizedTimestamp } from "../date-time.js"; -import { assertMediaNotDataUrl } from "../sandbox-paths.js"; -import { - type ActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringParam, -} from "./common.js"; +} from "../send.js"; +import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js"; +import { resolveDiscordChannelId } from "../targets.js"; + +export const discordMessagingActionRuntime = { + createThreadDiscord, + deleteMessageDiscord, + editMessageDiscord, + fetchChannelPermissionsDiscord, + fetchMessageDiscord, + fetchReactionsDiscord, + listPinsDiscord, + listThreadsDiscord, + pinMessageDiscord, + reactMessageDiscord, + readDiscordComponentSpec, + readMessagesDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, + resolveDiscordChannelId, + searchMessagesDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + sendVoiceMessageDiscord, + unpinMessageDiscord, +}; function parseDiscordMessageLink(link: string) { const normalized = link.trim(); @@ -65,7 +91,7 @@ export async function handleDiscordMessagingAction( cfg?: OpenClawConfig, ): Promise> { const resolveChannelId = () => - resolveDiscordChannelId( + discordMessagingActionRuntime.resolveDiscordChannelId( readStringParam(params, "channelId", { required: true, }), @@ -95,28 +121,45 @@ export async function handleDiscordMessagingAction( }); if (remove) { if (accountId) { - await removeReactionDiscord(channelId, messageId, emoji, { + await discordMessagingActionRuntime.removeReactionDiscord(channelId, messageId, emoji, { ...cfgOptions, accountId, }); } else { - await removeReactionDiscord(channelId, messageId, emoji, cfgOptions); + await discordMessagingActionRuntime.removeReactionDiscord( + channelId, + messageId, + emoji, + cfgOptions, + ); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = accountId - ? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId }) - : await removeOwnReactionsDiscord(channelId, messageId, cfgOptions); + ? await discordMessagingActionRuntime.removeOwnReactionsDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.removeOwnReactionsDiscord( + channelId, + messageId, + cfgOptions, + ); return jsonResult({ ok: true, removed: removed.removed }); } if (accountId) { - await reactMessageDiscord(channelId, messageId, emoji, { + await discordMessagingActionRuntime.reactMessageDiscord(channelId, messageId, emoji, { ...cfgOptions, accountId, }); } else { - await reactMessageDiscord(channelId, messageId, emoji, cfgOptions); + await discordMessagingActionRuntime.reactMessageDiscord( + channelId, + messageId, + emoji, + cfgOptions, + ); } return jsonResult({ ok: true, added: emoji }); } @@ -129,11 +172,15 @@ export async function handleDiscordMessagingAction( required: true, }); const limit = readNumberParam(params, "limit"); - const reactions = await fetchReactionsDiscord(channelId, messageId, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - limit, - }); + const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord( + channelId, + messageId, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + limit, + }, + ); return jsonResult({ ok: true, reactions }); } case "sticker": { @@ -146,7 +193,7 @@ export async function handleDiscordMessagingAction( required: true, label: "stickerIds", }); - await sendStickerDiscord(to, stickerIds, { + await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, { ...cfgOptions, ...(accountId ? { accountId } : {}), content, @@ -169,7 +216,7 @@ export async function handleDiscordMessagingAction( const allowMultiselect = readBooleanParam(params, "allowMultiselect"); const durationHours = readNumberParam(params, "durationHours"); const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect); - await sendPollDiscord( + await discordMessagingActionRuntime.sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, { ...cfgOptions, ...(accountId ? { accountId } : {}), content }, @@ -182,8 +229,11 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const permissions = accountId - ? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId }) - : await fetchChannelPermissionsDiscord(channelId, cfgOptions); + ? await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, permissions }); } case "fetchMessage": { @@ -206,8 +256,11 @@ export async function handleDiscordMessagingAction( ); } const message = accountId - ? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }) - : await fetchMessageDiscord(channelId, messageId, cfgOptions); + ? await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, cfgOptions); return jsonResult({ ok: true, message: normalizeMessage(message), @@ -228,8 +281,11 @@ export async function handleDiscordMessagingAction( around: readStringParam(params, "around"), }; const messages = accountId - ? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId }) - : await readMessagesDiscord(channelId, query, cfgOptions); + ? await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, cfgOptions); return jsonResult({ ok: true, messages: messages.map((message) => normalizeMessage(message)), @@ -245,7 +301,7 @@ export async function handleDiscordMessagingAction( const rawComponents = params.components; const componentSpec = rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents) - ? readDiscordComponentSpec(rawComponents) + ? discordMessagingActionRuntime.readDiscordComponentSpec(rawComponents) : null; const components: DiscordSendComponents | undefined = Array.isArray(rawComponents) || typeof rawComponents === "function" @@ -279,16 +335,20 @@ export async function handleDiscordMessagingAction( const payload = componentSpec.text ? componentSpec : { ...componentSpec, text: normalizedContent }; - const result = await sendDiscordComponentMessage(to, payload, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - silent, - replyTo: replyTo ?? undefined, - sessionKey: sessionKey ?? undefined, - agentId: agentId ?? undefined, - mediaUrl: mediaUrl ?? undefined, - filename: filename ?? undefined, - }); + const result = await discordMessagingActionRuntime.sendDiscordComponentMessage( + to, + payload, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + silent, + replyTo: replyTo ?? undefined, + sessionKey: sessionKey ?? undefined, + agentId: agentId ?? undefined, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + }, + ); return jsonResult({ ok: true, result, components: true }); } @@ -305,7 +365,7 @@ export async function handleDiscordMessagingAction( ); } assertMediaNotDataUrl(mediaUrl); - const result = await sendVoiceMessageDiscord(to, mediaUrl, { + const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, { ...cfgOptions, ...(accountId ? { accountId } : {}), replyTo, @@ -314,7 +374,7 @@ export async function handleDiscordMessagingAction( return jsonResult({ ok: true, result, voiceMessage: true }); } - const result = await sendMessageDiscord(to, content ?? "", { + const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", { ...cfgOptions, ...(accountId ? { accountId } : {}), mediaUrl, @@ -338,8 +398,18 @@ export async function handleDiscordMessagingAction( required: true, }); const message = accountId - ? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId }) - : await editMessageDiscord(channelId, messageId, { content }, cfgOptions); + ? await discordMessagingActionRuntime.editMessageDiscord( + channelId, + messageId, + { content }, + { ...cfgOptions, accountId }, + ) + : await discordMessagingActionRuntime.editMessageDiscord( + channelId, + messageId, + { content }, + cfgOptions, + ); return jsonResult({ ok: true, message }); } case "deleteMessage": { @@ -351,9 +421,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await deleteMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -375,8 +448,11 @@ export async function handleDiscordMessagingAction( appliedTags: appliedTags ?? undefined, }; const thread = accountId - ? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId }) - : await createThreadDiscord(channelId, payload, cfgOptions); + ? await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, cfgOptions); return jsonResult({ ok: true, thread }); } case "threadList": { @@ -391,7 +467,7 @@ export async function handleDiscordMessagingAction( const before = readStringParam(params, "before"); const limit = readNumberParam(params, "limit"); const threads = accountId - ? await listThreadsDiscord( + ? await discordMessagingActionRuntime.listThreadsDiscord( { guildId, channelId, @@ -401,7 +477,7 @@ export async function handleDiscordMessagingAction( }, { ...cfgOptions, accountId }, ) - : await listThreadsDiscord( + : await discordMessagingActionRuntime.listThreadsDiscord( { guildId, channelId, @@ -423,13 +499,17 @@ export async function handleDiscordMessagingAction( }); const mediaUrl = readStringParam(params, "mediaUrl"); const replyTo = readStringParam(params, "replyTo"); - const result = await sendMessageDiscord(`channel:${channelId}`, content, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - mediaUrl, - mediaLocalRoots: options?.mediaLocalRoots, - replyTo, - }); + const result = await discordMessagingActionRuntime.sendMessageDiscord( + `channel:${channelId}`, + content, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + mediaUrl, + mediaLocalRoots: options?.mediaLocalRoots, + replyTo, + }, + ); return jsonResult({ ok: true, result }); } case "pinMessage": { @@ -441,9 +521,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await pinMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -456,9 +539,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await unpinMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -468,8 +554,11 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const pins = accountId - ? await listPinsDiscord(channelId, { ...cfgOptions, accountId }) - : await listPinsDiscord(channelId, cfgOptions); + ? await discordMessagingActionRuntime.listPinsDiscord(channelId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.listPinsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) }); } case "searchMessages": { @@ -490,7 +579,7 @@ export async function handleDiscordMessagingAction( const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])]; const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])]; const results = accountId - ? await searchMessagesDiscord( + ? await discordMessagingActionRuntime.searchMessagesDiscord( { guildId, content, @@ -500,7 +589,7 @@ export async function handleDiscordMessagingAction( }, { ...cfgOptions, accountId }, ) - : await searchMessagesDiscord( + : await discordMessagingActionRuntime.searchMessagesDiscord( { guildId, content, diff --git a/src/agents/tools/discord-actions-moderation-shared.ts b/extensions/discord/src/actions/runtime.moderation-shared.ts similarity index 94% rename from src/agents/tools/discord-actions-moderation-shared.ts rename to extensions/discord/src/actions/runtime.moderation-shared.ts index b2d9ec0ba99..7b6ef95d8f1 100644 --- a/src/agents/tools/discord-actions-moderation-shared.ts +++ b/extensions/discord/src/actions/runtime.moderation-shared.ts @@ -1,5 +1,5 @@ import { PermissionFlagsBits } from "discord-api-types/v10"; -import { readNumberParam, readStringParam } from "./common.js"; +import { readNumberParam, readStringParam } from "../../../../src/agents/tools/common.js"; export type DiscordModerationAction = "timeout" | "kick" | "ban"; diff --git a/src/agents/tools/discord-actions-moderation.authz.test.ts b/extensions/discord/src/actions/runtime.moderation.authz.test.ts similarity index 81% rename from src/agents/tools/discord-actions-moderation.authz.test.ts rename to extensions/discord/src/actions/runtime.moderation.authz.test.ts index d6b3651ca88..66d2a4ba9d8 100644 --- a/src/agents/tools/discord-actions-moderation.authz.test.ts +++ b/extensions/discord/src/actions/runtime.moderation.authz.test.ts @@ -1,25 +1,30 @@ import { PermissionFlagsBits } from "discord-api-types/v10"; -import { describe, expect, it, vi } from "vitest"; -import type { DiscordActionConfig } from "../../config/config.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { + discordModerationActionRuntime, + handleDiscordModerationAction, +} from "./runtime.moderation.js"; -const discordSendMocks = vi.hoisted(() => ({ - banMemberDiscord: vi.fn(async () => ({ ok: true })), - kickMemberDiscord: vi.fn(async () => ({ ok: true })), - timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })), - hasAnyGuildPermissionDiscord: vi.fn(async () => false), -})); - -const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasAnyGuildPermissionDiscord } = - discordSendMocks; - -vi.mock("../../../extensions/discord/src/send.js", () => ({ - ...discordSendMocks, -})); +const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime }; +const banMemberDiscord = vi.fn(async () => ({ ok: true })); +const kickMemberDiscord = vi.fn(async () => ({ ok: true })); +const timeoutMemberDiscord = vi.fn(async () => ({ id: "user-1" })); +const hasAnyGuildPermissionDiscord = vi.fn(async () => false); const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true; describe("discord moderation sender authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(discordModerationActionRuntime, originalDiscordModerationActionRuntime, { + banMemberDiscord, + kickMemberDiscord, + timeoutMemberDiscord, + hasAnyGuildPermissionDiscord, + }); + }); + it("rejects ban when sender lacks BAN_MEMBERS", async () => { hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false); diff --git a/src/agents/tools/discord-actions-moderation.ts b/extensions/discord/src/actions/runtime.moderation.ts similarity index 78% rename from src/agents/tools/discord-actions-moderation.ts rename to extensions/discord/src/actions/runtime.moderation.ts index 56d7a80d4c9..3278daa6532 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/extensions/discord/src/actions/runtime.moderation.ts @@ -1,17 +1,28 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; +import { + type ActionGate, + jsonResult, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; import { banMemberDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, timeoutMemberDiscord, -} from "../../plugin-sdk/discord.js"; -import { type ActionGate, jsonResult, readStringParam } from "./common.js"; +} from "../send.js"; import { isDiscordModerationAction, readDiscordModerationCommand, requiredGuildPermissionForModerationAction, -} from "./discord-actions-moderation-shared.js"; +} from "./runtime.moderation-shared.js"; + +export const discordModerationActionRuntime = { + banMemberDiscord, + hasAnyGuildPermissionDiscord, + kickMemberDiscord, + timeoutMemberDiscord, +}; async function verifySenderModerationPermission(params: { guildId: string; @@ -23,7 +34,7 @@ async function verifySenderModerationPermission(params: { if (!params.senderUserId) { return; } - const hasPermission = await hasAnyGuildPermissionDiscord( + const hasPermission = await discordModerationActionRuntime.hasAnyGuildPermissionDiscord( params.guildId, params.senderUserId, [params.requiredPermission], @@ -57,7 +68,7 @@ export async function handleDiscordModerationAction( switch (command.action) { case "timeout": { const member = accountId - ? await timeoutMemberDiscord( + ? await discordModerationActionRuntime.timeoutMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -67,7 +78,7 @@ export async function handleDiscordModerationAction( }, { accountId }, ) - : await timeoutMemberDiscord({ + : await discordModerationActionRuntime.timeoutMemberDiscord({ guildId: command.guildId, userId: command.userId, durationMinutes: command.durationMinutes, @@ -78,7 +89,7 @@ export async function handleDiscordModerationAction( } case "kick": { if (accountId) { - await kickMemberDiscord( + await discordModerationActionRuntime.kickMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -87,7 +98,7 @@ export async function handleDiscordModerationAction( { accountId }, ); } else { - await kickMemberDiscord({ + await discordModerationActionRuntime.kickMemberDiscord({ guildId: command.guildId, userId: command.userId, reason: command.reason, @@ -97,7 +108,7 @@ export async function handleDiscordModerationAction( } case "ban": { if (accountId) { - await banMemberDiscord( + await discordModerationActionRuntime.banMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -107,7 +118,7 @@ export async function handleDiscordModerationAction( { accountId }, ); } else { - await banMemberDiscord({ + await discordModerationActionRuntime.banMemberDiscord({ guildId: command.guildId, userId: command.userId, reason: command.reason, diff --git a/src/agents/tools/discord-actions-presence.test.ts b/extensions/discord/src/actions/runtime.presence.test.ts similarity index 94% rename from src/agents/tools/discord-actions-presence.test.ts rename to extensions/discord/src/actions/runtime.presence.test.ts index dc8080666c6..7cc118150de 100644 --- a/src/agents/tools/discord-actions-presence.test.ts +++ b/extensions/discord/src/actions/runtime.presence.test.ts @@ -1,12 +1,9 @@ import type { GatewayPlugin } from "@buape/carbon/gateway"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - clearGateways, - registerGateway, -} from "../../../extensions/discord/src/monitor/gateway-registry.js"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { ActionGate } from "./common.js"; -import { handleDiscordPresenceAction } from "./discord-actions-presence.js"; +import type { ActionGate } from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { clearGateways, registerGateway } from "../monitor/gateway-registry.js"; +import { handleDiscordPresenceAction } from "./runtime.presence.js"; const mockUpdatePresence = vi.fn(); diff --git a/src/agents/tools/discord-actions-presence.ts b/extensions/discord/src/actions/runtime.presence.ts similarity index 92% rename from src/agents/tools/discord-actions-presence.ts rename to extensions/discord/src/actions/runtime.presence.ts index 53c42829bb0..6d3a9f15bc2 100644 --- a/src/agents/tools/discord-actions-presence.ts +++ b/extensions/discord/src/actions/runtime.presence.ts @@ -1,8 +1,12 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import { getGateway } from "../../plugin-sdk/discord.js"; -import { type ActionGate, jsonResult, readStringParam } from "./common.js"; +import { + type ActionGate, + jsonResult, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { getGateway } from "../monitor/gateway-registry.js"; const ACTIVITY_TYPE_MAP: Record = { playing: 0, diff --git a/src/agents/tools/discord-actions-shared.ts b/extensions/discord/src/actions/runtime.shared.ts similarity index 78% rename from src/agents/tools/discord-actions-shared.ts rename to extensions/discord/src/actions/runtime.shared.ts index 6f8283b5240..bd2ce7a08d6 100644 --- a/src/agents/tools/discord-actions-shared.ts +++ b/extensions/discord/src/actions/runtime.shared.ts @@ -1,4 +1,4 @@ -import { readStringParam } from "./common.js"; +import { readStringParam } from "../../../../src/agents/tools/common.js"; export function readDiscordParentIdParam( params: Record, diff --git a/src/agents/tools/discord-actions.test.ts b/extensions/discord/src/actions/runtime.test.ts similarity index 94% rename from src/agents/tools/discord-actions.test.ts rename to extensions/discord/src/actions/runtime.test.ts index c03cb2fdafa..8f11162f8f3 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -1,11 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DiscordActionConfig, OpenClawConfig } from "../../config/config.js"; -import { handleDiscordGuildAction } from "./discord-actions-guild.js"; -import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; -import { handleDiscordAction } from "./discord-actions.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js"; +import { handleDiscordAction } from "./runtime.js"; +import { + discordMessagingActionRuntime, + handleDiscordMessagingAction, +} from "./runtime.messaging.js"; +import { + discordModerationActionRuntime, + handleDiscordModerationAction, +} from "./runtime.moderation.js"; -const discordSendMocks = vi.hoisted(() => ({ +const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime }; +const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime }; +const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime }; + +const discordSendMocks = { banMemberDiscord: vi.fn(async () => ({})), createChannelDiscord: vi.fn(async () => ({ id: "new-channel", @@ -42,7 +53,7 @@ const discordSendMocks = vi.hoisted(() => ({ setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })), timeoutMemberDiscord: vi.fn(async () => ({})), unpinMessageDiscord: vi.fn(async () => ({})), -})); +}; const { createChannelDiscord, @@ -67,21 +78,28 @@ const { timeoutMemberDiscord, } = discordSendMocks; -vi.mock("../../../extensions/discord/src/send.js", () => ({ - ...discordSendMocks, -})); - const enableAllActions = () => true; const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions"; const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo"; const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation"; -describe("handleDiscordMessagingAction", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +beforeEach(() => { + vi.clearAllMocks(); + Object.assign( + discordMessagingActionRuntime, + originalDiscordMessagingActionRuntime, + discordSendMocks, + ); + Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks); + Object.assign( + discordModerationActionRuntime, + originalDiscordModerationActionRuntime, + discordSendMocks, + ); +}); +describe("handleDiscordMessagingAction", () => { it.each([ { name: "without account", diff --git a/src/agents/tools/discord-actions.ts b/extensions/discord/src/actions/runtime.ts similarity index 79% rename from src/agents/tools/discord-actions.ts rename to extensions/discord/src/actions/runtime.ts index b953e56cffd..7efa5a1536f 100644 --- a/src/agents/tools/discord-actions.ts +++ b/extensions/discord/src/actions/runtime.ts @@ -1,11 +1,11 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createDiscordActionGate } from "../../plugin-sdk/discord.js"; -import { readStringParam } from "./common.js"; -import { handleDiscordGuildAction } from "./discord-actions-guild.js"; -import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; -import { handleDiscordPresenceAction } from "./discord-actions-presence.js"; +import { readStringParam } from "../../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { createDiscordActionGate } from "../accounts.js"; +import { handleDiscordGuildAction } from "./runtime.guild.js"; +import { handleDiscordMessagingAction } from "./runtime.messaging.js"; +import { handleDiscordModerationAction } from "./runtime.moderation.js"; +import { handleDiscordPresenceAction } from "./runtime.presence.js"; const messagingActions = new Set([ "react", diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index c4be7728439..960b08acdf6 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,4 +1,5 @@ import { + createLegacyMessageToolDiscoveryMethods, createDiscordMessageToolComponentsSchema, createUnionActionGate, listTokenSourcedAccounts, @@ -132,6 +133,7 @@ function describeDiscordMessageTool({ export const discordMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeDiscordMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeDiscordMessageTool), extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action === "sendMessage") { diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 03490dc8432..5aaff75014f 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -16,14 +16,18 @@ export * from "../agents/provider-id.js"; export * from "../agents/schema/typebox.js"; export * from "../agents/sglang-defaults.js"; export * from "../agents/tools/common.js"; -export * from "../agents/tools/discord-actions-shared.js"; -export * from "../agents/tools/discord-actions.js"; -export * from "../agents/tools/telegram-actions.js"; export * from "../agents/tools/web-guarded-fetch.js"; export * from "../agents/tools/web-shared.js"; -export * from "../agents/tools/discord-actions-moderation-shared.js"; export * from "../agents/tools/web-fetch-utils.js"; export * from "../agents/vllm-defaults.js"; // Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../agents/agent-command.js"; export * from "../tts/tts.js"; +// Legacy channel action runtime re-exports. New bundled plugin code should use +// local extension-owned modules instead of adding more public SDK surface here. +export { + handleDiscordAction, + readDiscordParentIdParam, + isDiscordModerationAction, + readDiscordModerationCommand, +} from "../../extensions/discord/runtime-api.js"; From c3386d34d2900f4628e4e1e0721e43a08aab74a6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:13 +0000 Subject: [PATCH 185/393] Telegram: move action runtime into extension --- extensions/telegram/runtime-api.ts | 1 + .../telegram/src/action-runtime.test.ts | 39 +++--- .../telegram/src/action-runtime.ts | 118 +++++++++++------- extensions/telegram/src/channel-actions.ts | 4 +- src/plugin-sdk/agent-runtime.ts | 4 + 5 files changed, 96 insertions(+), 70 deletions(-) rename src/agents/tools/telegram-actions.test.ts => extensions/telegram/src/action-runtime.test.ts (95%) rename src/agents/tools/telegram-actions.ts => extensions/telegram/src/action-runtime.ts (87%) diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index e704dc007a3..76f87396469 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -1,4 +1,5 @@ export * from "./src/audit.js"; +export * from "./src/action-runtime.js"; export * from "./src/channel-actions.js"; export * from "./src/monitor.js"; export * from "./src/probe.js"; diff --git a/src/agents/tools/telegram-actions.test.ts b/extensions/telegram/src/action-runtime.test.ts similarity index 95% rename from src/agents/tools/telegram-actions.test.ts rename to extensions/telegram/src/action-runtime.test.ts index 997de707765..ad59933415f 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { captureEnv } from "../../test-utils/env.js"; -import { handleTelegramAction, readTelegramButtons } from "./telegram-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { captureEnv } from "../../../test/helpers/extensions/env.js"; +import { + handleTelegramAction, + readTelegramButtons, + telegramActionRuntime, +} from "./action-runtime.js"; +const originalTelegramActionRuntime = { ...telegramActionRuntime }; const reactMessageTelegram = vi.fn(async () => ({ ok: true })); const sendMessageTelegram = vi.fn(async () => ({ messageId: "789", @@ -36,24 +41,6 @@ const createForumTopicTelegram = vi.fn(async () => ({ })); let envSnapshot: ReturnType; -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - reactMessageTelegram: (...args: Parameters) => - reactMessageTelegram(...args), - sendMessageTelegram: (...args: Parameters) => - sendMessageTelegram(...args), - sendPollTelegram: (...args: Parameters) => sendPollTelegram(...args), - sendStickerTelegram: (...args: Parameters) => - sendStickerTelegram(...args), - deleteMessageTelegram: (...args: Parameters) => - deleteMessageTelegram(...args), - editMessageTelegram: (...args: Parameters) => - editMessageTelegram(...args), - editForumTopicTelegram: (...args: Parameters) => - editForumTopicTelegram(...args), - createForumTopicTelegram: (...args: Parameters) => - createForumTopicTelegram(...args), -})); - describe("handleTelegramAction", () => { const defaultReactionAction = { action: "react", @@ -107,6 +94,16 @@ describe("handleTelegramAction", () => { beforeEach(() => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]); + Object.assign(telegramActionRuntime, originalTelegramActionRuntime, { + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, + deleteMessageTelegram, + editMessageTelegram, + editForumTopicTelegram, + createForumTopicTelegram, + }); reactMessageTelegram.mockClear(); sendMessageTelegram.mockClear(); sendPollTelegram.mockClear(); diff --git a/src/agents/tools/telegram-actions.ts b/extensions/telegram/src/action-runtime.ts similarity index 87% rename from src/agents/tools/telegram-actions.ts rename to extensions/telegram/src/action-runtime.ts index d648b1e5f41..e6e56e9eb3a 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -1,15 +1,23 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { - createTelegramActionGate, - resolveTelegramPollActionGateState, -} from "../../plugin-sdk/telegram.js"; -import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js"; + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; +import { resolvePollMaxSelections } from "../../../src/polls.js"; +import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js"; +import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../plugin-sdk/telegram.js"; +} from "./inline-buttons.js"; +import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -19,22 +27,22 @@ import { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../plugin-sdk/telegram.js"; -import { +} from "./send.js"; +import { getCacheStats, searchStickers } from "./sticker-cache.js"; +import { resolveTelegramToken } from "./token.js"; + +export const telegramActionRuntime = { + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageTelegram, getCacheStats, - resolveTelegramReactionLevel, - resolveTelegramToken, + reactMessageTelegram, searchStickers, -} from "../../plugin-sdk/telegram.js"; -import { resolvePollMaxSelections } from "../../polls.js"; -import { - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringOrNumberParam, - readStringParam, -} from "./common.js"; + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, +}; const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"]; @@ -155,14 +163,19 @@ export async function handleTelegramAction( hint: "Telegram bot token missing. Do not retry.", }); } - let reactionResult: Awaited>; + let reactionResult: Awaited>; try { - reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { - cfg, - token, - remove, - accountId: accountId ?? undefined, - }); + reactionResult = await telegramActionRuntime.reactMessageTelegram( + chatId ?? "", + messageId ?? 0, + emoji ?? "", + { + cfg, + token, + remove, + accountId: accountId ?? undefined, + }, + ); } catch (err) { const isInvalid = String(err).includes("REACTION_INVALID"); return jsonResult({ @@ -241,7 +254,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendMessageTelegram(to, content, { + const result = await telegramActionRuntime.sendMessageTelegram(to, content, { cfg, token, accountId: accountId ?? undefined, @@ -290,7 +303,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendPollTelegram( + const result = await telegramActionRuntime.sendPollTelegram( to, { question, @@ -334,7 +347,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - await deleteMessageTelegram(chatId ?? "", messageId ?? 0, { + await telegramActionRuntime.deleteMessageTelegram(chatId ?? "", messageId ?? 0, { cfg, token, accountId: accountId ?? undefined, @@ -375,12 +388,17 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { - cfg, - token, - accountId: accountId ?? undefined, - buttons, - }); + const result = await telegramActionRuntime.editMessageTelegram( + chatId ?? "", + messageId ?? 0, + content, + { + cfg, + token, + accountId: accountId ?? undefined, + buttons, + }, + ); return jsonResult({ ok: true, messageId: result.messageId, @@ -408,7 +426,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendStickerTelegram(to, fileId, { + const result = await telegramActionRuntime.sendStickerTelegram(to, fileId, { cfg, token, accountId: accountId ?? undefined, @@ -430,7 +448,7 @@ export async function handleTelegramAction( } const query = readStringParam(params, "query", { required: true }); const limit = readNumberParam(params, "limit", { integer: true }) ?? 5; - const results = searchStickers(query, limit); + const results = telegramActionRuntime.searchStickers(query, limit); return jsonResult({ ok: true, count: results.length, @@ -444,7 +462,7 @@ export async function handleTelegramAction( } if (action === "stickerCacheStats") { - const stats = getCacheStats(); + const stats = telegramActionRuntime.getCacheStats(); return jsonResult({ ok: true, ...stats }); } @@ -464,7 +482,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await createForumTopicTelegram(chatId ?? "", name, { + const result = await telegramActionRuntime.createForumTopicTelegram(chatId ?? "", name, { cfg, token, accountId: accountId ?? undefined, @@ -500,13 +518,17 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await editForumTopicTelegram(chatId ?? "", messageThreadId, { - cfg, - token, - accountId: accountId ?? undefined, - name: name ?? undefined, - iconCustomEmojiId: iconCustomEmojiId ?? undefined, - }); + const result = await telegramActionRuntime.editForumTopicTelegram( + chatId ?? "", + messageThreadId, + { + cfg, + token, + accountId: accountId ?? undefined, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + }, + ); return jsonResult(result); } diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index a23430f02da..cd757688835 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -4,10 +4,10 @@ import { readStringOrNumberParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import { handleTelegramAction } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import { + createLegacyMessageToolDiscoveryMethods, createMessageToolButtonsSchema, createTelegramPollExtraToolSchemas, createUnionActionGate, @@ -27,6 +27,7 @@ import { listEnabledTelegramAccounts, resolveTelegramPollActionGateState, } from "./accounts.js"; +import { handleTelegramAction } from "./action-runtime.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; @@ -177,6 +178,7 @@ function readTelegramMessageIdParam(params: Record): number { export const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeTelegramMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeTelegramMessageTool), extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 5aaff75014f..20ab0596a12 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -31,3 +31,7 @@ export { isDiscordModerationAction, readDiscordModerationCommand, } from "../../extensions/discord/runtime-api.js"; +export { + handleTelegramAction, + readTelegramButtons, +} from "../../extensions/telegram/runtime-api.js"; From b3ae50c71cb80b49ae30c589c8f385846e9683d8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:26 +0000 Subject: [PATCH 186/393] Slack: move action runtime into extension --- extensions/slack/runtime-api.ts | 1 + .../slack/src/action-runtime.test.ts | 352 ++++++++++-------- .../slack/src/action-runtime.ts | 110 +++--- src/channels/plugins/slack.actions.ts | 7 +- src/plugin-sdk/slack.ts | 2 +- .../runtime/runtime-slack-ops.runtime.ts | 2 +- src/plugins/runtime/types-channel.ts | 2 +- 7 files changed, 264 insertions(+), 212 deletions(-) rename src/agents/tools/slack-actions.test.ts => extensions/slack/src/action-runtime.test.ts (64%) rename src/agents/tools/slack-actions.ts => extensions/slack/src/action-runtime.ts (79%) diff --git a/extensions/slack/runtime-api.ts b/extensions/slack/runtime-api.ts index b40f24e4177..68281fd83d3 100644 --- a/extensions/slack/runtime-api.ts +++ b/extensions/slack/runtime-api.ts @@ -1,3 +1,4 @@ +export * from "./src/action-runtime.js"; export * from "./src/directory-live.js"; export * from "./src/index.js"; export * from "./src/resolve-channels.js"; diff --git a/src/agents/tools/slack-actions.test.ts b/extensions/slack/src/action-runtime.test.ts similarity index 64% rename from src/agents/tools/slack-actions.test.ts rename to extensions/slack/src/action-runtime.test.ts index bf28c2bed01..803118b877a 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { handleSlackAction } from "./slack-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { handleSlackAction, slackActionRuntime } from "./action-runtime.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +const originalSlackActionRuntime = { ...slackActionRuntime }; const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null); const editSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); @@ -14,31 +16,10 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({})); const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]); const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({})); -const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); +const recordSlackThreadParticipation = vi.fn(); +const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" })); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); -vi.mock("../../../extensions/slack/src/actions.js", () => ({ - deleteSlackMessage: (...args: Parameters) => - deleteSlackMessage(...args), - downloadSlackFile: (...args: Parameters) => downloadSlackFile(...args), - editSlackMessage: (...args: Parameters) => editSlackMessage(...args), - getSlackMemberInfo: (...args: Parameters) => - getSlackMemberInfo(...args), - listSlackEmojis: (...args: Parameters) => listSlackEmojis(...args), - listSlackPins: (...args: Parameters) => listSlackPins(...args), - listSlackReactions: (...args: Parameters) => - listSlackReactions(...args), - pinSlackMessage: (...args: Parameters) => pinSlackMessage(...args), - reactSlackMessage: (...args: Parameters) => reactSlackMessage(...args), - readSlackMessages: (...args: Parameters) => readSlackMessages(...args), - removeOwnSlackReactions: (...args: Parameters) => - removeOwnSlackReactions(...args), - removeSlackReaction: (...args: Parameters) => - removeSlackReaction(...args), - sendSlackMessage: (...args: Parameters) => sendSlackMessage(...args), - unpinSlackMessage: (...args: Parameters) => unpinSlackMessage(...args), -})); - describe("handleSlackAction", () => { function slackConfig(overrides?: Record): OpenClawConfig { return { @@ -105,6 +86,24 @@ describe("handleSlackAction", () => { beforeEach(() => { vi.clearAllMocks(); + Object.assign(slackActionRuntime, originalSlackActionRuntime, { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + parseSlackBlocksInput, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + recordSlackThreadParticipation, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, + }); }); it.each([ @@ -261,6 +260,7 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", + content: "", blocks, }, slackConfig(), @@ -275,7 +275,7 @@ describe("handleSlackAction", () => { it.each([ { name: "invalid blocks JSON", - blocks: "{bad-json", + blocks: "{not json", expectedError: /blocks must be valid JSON/i, }, { name: "empty blocks arrays", blocks: "[]", expectedError: /at least one block/i }, @@ -285,6 +285,7 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", + content: "", blocks, }, slackConfig(), @@ -311,8 +312,9 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", - blocks: [{ type: "divider" }], - mediaUrl: "https://example.com/image.png", + content: "hello", + mediaUrl: "https://example.com/file.png", + blocks: JSON.stringify([{ type: "divider" }]), }, slackConfig(), ), @@ -322,13 +324,13 @@ describe("handleSlackAction", () => { it.each([ { name: "JSON blocks", - blocks: JSON.stringify([{ type: "section", text: { type: "mrkdwn", text: "Updated" } }]), - expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "Updated" } }], + blocks: JSON.stringify([{ type: "divider" }]), + expectedBlocks: [{ type: "divider" }], }, { name: "array blocks", - blocks: [{ type: "divider" }], - expectedBlocks: [{ type: "divider" }], + blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], + expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], }, ])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => { await handleSlackAction( @@ -336,6 +338,7 @@ describe("handleSlackAction", () => { action: "editMessage", channelId: "C123", messageId: "123.456", + content: "", blocks, }, slackConfig(), @@ -360,40 +363,32 @@ describe("handleSlackAction", () => { }); it("auto-injects threadTs from context when replyToMode=all", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", - content: "Auto-threaded", + content: "Threaded reply", }, - cfg, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Auto-threaded", { - mediaUrl: undefined, - threadTs: "1111111111.111111", - blocks: undefined, - }); + expectLastSlackSend("Threaded reply", "1111111111.111111"); }); it("replyToMode=first threads first message then stops", async () => { - const { cfg, context, hasRepliedRef } = createReplyToFirstScenario(); + const { cfg, context } = createReplyToFirstScenario(); - // First message should be threaded await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "First" }, cfg, context, ); - expectLastSlackSend("First", "1111111111.111111"); - expect(hasRepliedRef.value).toBe(true); + expectLastSlackSend("First", "1111111111.111111"); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); @@ -405,73 +400,54 @@ describe("handleSlackAction", () => { action: "sendMessage", to: "channel:C123", content: "Explicit", - threadTs: "2222222222.222222", + threadTs: "9999999999.999999", }, cfg, context, ); - expectLastSlackSend("Explicit", "2222222222.222222"); - expect(hasRepliedRef.value).toBe(true); + expectLastSlackSend("Explicit", "9999999999.999999"); + expect(hasRepliedRef.value).toBe(true); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); it("replyToMode=first without hasRepliedRef does not thread", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); - await handleSlackAction({ action: "sendMessage", to: "channel:C123", content: "No ref" }, cfg, { - currentChannelId: "C123", - currentThreadTs: "1111111111.111111", - replyToMode: "first", - // no hasRepliedRef - }); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", { - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }); + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "No ref" }, + slackConfig(), + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first", + }, + ); + expectLastSlackSend("No ref"); }); it("does not auto-inject threadTs when replyToMode=off", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "channel:C123", - content: "Off mode", - }, - cfg, + { action: "sendMessage", to: "channel:C123", content: "No thread" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "off", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", { - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }); + expectLastSlackSend("No thread"); }); it("does not auto-inject threadTs when sending to different channel", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "channel:C999", - content: "Different channel", - }, - cfg, + { action: "sendMessage", to: "channel:C999", content: "Other channel" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Different channel", { + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", { mediaUrl: undefined, threadTs: undefined, blocks: undefined, @@ -479,46 +455,34 @@ describe("handleSlackAction", () => { }); it("explicit threadTs overrides context threadTs", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", - content: "Explicit thread", - threadTs: "2222222222.222222", + content: "Explicit wins", + threadTs: "9999999999.999999", }, - cfg, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Explicit thread", { - mediaUrl: undefined, - threadTs: "2222222222.222222", - blocks: undefined, - }); + expectLastSlackSend("Explicit wins", "9999999999.999999"); }); it("handles channel target without prefix when replyToMode=all", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "C123", - content: "No prefix", - }, - cfg, + { action: "sendMessage", to: "C123", content: "Bare target" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", { + expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", { mediaUrl: undefined, threadTs: "1111111111.111111", blocks: undefined, @@ -526,104 +490,164 @@ describe("handleSlackAction", () => { }); it("adds normalized timestamps to readMessages payloads", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; readSlackMessages.mockResolvedValueOnce({ - messages: [{ ts: "1735689600.456", text: "hi" }], + messages: [{ ts: "1712345678.123456", text: "hi" }], hasMore: false, }); - const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); - const payload = result.details as { - messages: Array<{ timestampMs?: number; timestampUtc?: string }>; - }; + const result = await handleSlackAction( + { action: "readMessages", channelId: "C1" }, + slackConfig(), + ); - const expectedMs = Math.round(1735689600.456 * 1000); - expect(payload.messages[0].timestampMs).toBe(expectedMs); - expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); + expect(result).toMatchObject({ + details: { + ok: true, + hasMore: false, + messages: [ + expect.objectContaining({ + ts: "1712345678.123456", + timestampMs: 1712345678123, + }), + ], + }, + }); }); it("passes threadId through to readSlackMessages", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - readSlackMessages.mockClear(); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); await handleSlackAction( - { action: "readMessages", channelId: "C1", threadId: "12345.6789" }, - cfg, + { action: "readMessages", channelId: "C1", threadId: "1712345678.123456" }, + slackConfig(), ); - const opts = readSlackMessages.mock.calls[0]?.[1] as { threadId?: string } | undefined; - expect(opts?.threadId).toBe("12345.6789"); + expect(readSlackMessages).toHaveBeenCalledWith("C1", { + threadId: "1712345678.123456", + limit: undefined, + before: undefined, + after: undefined, + }); }); it("adds normalized timestamps to pin payloads", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - listSlackPins.mockResolvedValueOnce([ - { - type: "message", - message: { ts: "1735689600.789", text: "pinned" }, + listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]); + + const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + pins: [ + { + message: expect.objectContaining({ + ts: "1712345678.123456", + timestampMs: 1712345678123, + }), + }, + ], }, - ]); - - const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg); - const payload = result.details as { - pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>; - }; - - const expectedMs = Math.round(1735689600.789 * 1000); - expect(payload.pins[0].message?.timestampMs).toBe(expectedMs); - expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); }); it("uses user token for reads when available", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, - } as OpenClawConfig; - expect(await resolveReadToken(cfg)).toBe("xoxp-1"); + const token = await resolveReadToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + userToken: "xoxp-user", + }, + }, + }), + ); + expect(token).toBe("xoxp-user"); }); it("falls back to bot token for reads when user token missing", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1" } }, - } as OpenClawConfig; - expect(await resolveReadToken(cfg)).toBeUndefined(); + const token = await resolveReadToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + }, + }, + }), + ); + expect(token).toBeUndefined(); }); it("uses bot token for writes when userTokenReadOnly is true", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, - } as OpenClawConfig; - expect(await resolveSendToken(cfg)).toBeUndefined(); + const token = await resolveSendToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + userToken: "xoxp-user", + userTokenReadOnly: true, + }, + }, + }), + ); + expect(token).toBeUndefined(); }); it("allows user token writes when bot token is missing", async () => { - const cfg = { + const token = await resolveSendToken({ channels: { - slack: { userToken: "xoxp-1", userTokenReadOnly: false }, + slack: { + accounts: { + default: { + userToken: "xoxp-user", + userTokenReadOnly: false, + }, + }, + }, }, - } as OpenClawConfig; - expect(await resolveSendToken(cfg)).toBe("xoxp-1"); + } as OpenClawConfig); + expect(token).toBe("xoxp-user"); }); it("returns all emojis when no limit is provided", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const emojiMap = { wave: "url1", smile: "url2", heart: "url3" }; - listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap }); - const result = await handleSlackAction({ action: "emojiList" }, cfg); - const payload = result.details as { ok: boolean; emojis: { emoji: Record } }; - expect(payload.ok).toBe(true); - expect(Object.keys(payload.emojis.emoji)).toHaveLength(3); + listSlackEmojis.mockResolvedValueOnce({ + ok: true, + emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, + }); + + const result = await handleSlackAction({ action: "emojiList" }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + emojis: { + emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, + }, + }, + }); }); it("applies limit to emoji-list results", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const emojiMap = { wave: "url1", smile: "url2", heart: "url3", fire: "url4", star: "url5" }; - listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap }); - const result = await handleSlackAction({ action: "emojiList", limit: 2 }, cfg); - const payload = result.details as { ok: boolean; emojis: { emoji: Record } }; - expect(payload.ok).toBe(true); - const emojiKeys = Object.keys(payload.emojis.emoji); - expect(emojiKeys).toHaveLength(2); - expect(emojiKeys.every((k) => k in emojiMap)).toBe(true); + listSlackEmojis.mockResolvedValueOnce({ + ok: true, + emoji: { + wave: "https://example.com/wave.png", + party: "https://example.com/party.png", + tada: "https://example.com/tada.png", + }, + }); + + const result = await handleSlackAction({ action: "emojiList", limit: 2 }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + emojis: { + emoji: { + party: "https://example.com/party.png", + tada: "https://example.com/tada.png", + }, + }, + }, + }); }); }); diff --git a/src/agents/tools/slack-actions.ts b/extensions/slack/src/action-runtime.ts similarity index 79% rename from src/agents/tools/slack-actions.ts rename to extensions/slack/src/action-runtime.ts index 11283394ec8..deb5eb0218e 100644 --- a/src/agents/tools/slack-actions.ts +++ b/extensions/slack/src/action-runtime.ts @@ -1,6 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSlackAccount } from "../../plugin-sdk/account-resolution.js"; +import { withNormalizedTimestamp } from "../../../src/agents/date-time.js"; +import { + createActionGate, + imageResultFromFile, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveSlackAccount } from "./accounts.js"; import { deleteSlackMessage, downloadSlackFile, @@ -16,22 +25,10 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../plugin-sdk/slack.js"; -import { - parseSlackBlocksInput, - parseSlackTarget, - recordSlackThreadParticipation, - resolveSlackChannelId, -} from "../../plugin-sdk/slack.js"; -import { withNormalizedTimestamp } from "../date-time.js"; -import { - createActionGate, - imageResultFromFile, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "./common.js"; +} from "./actions.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { recordSlackThreadParticipation } from "./sent-thread-cache.js"; +import { parseSlackTarget, resolveSlackChannelId } from "./targets.js"; const messagingActions = new Set([ "sendMessage", @@ -44,6 +41,25 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +export const slackActionRuntime = { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + parseSlackBlocksInput, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + recordSlackThreadParticipation, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, +}; + export type SlackActionContext = { /** Current channel ID for auto-threading. */ currentChannelId?: string; @@ -102,7 +118,7 @@ function resolveThreadTsFromContext( } function readSlackBlocksParam(params: Record) { - return parseSlackBlocksInput(params.blocks); + return slackActionRuntime.parseSlackBlocksInput(params.blocks); } export async function handleSlackAction( @@ -163,28 +179,28 @@ export async function handleSlackAction( }); if (remove) { if (writeOpts) { - await removeSlackReaction(channelId, messageId, emoji, writeOpts); + await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts); } else { - await removeSlackReaction(channelId, messageId, emoji); + await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = writeOpts - ? await removeOwnSlackReactions(channelId, messageId, writeOpts) - : await removeOwnSlackReactions(channelId, messageId); + ? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts) + : await slackActionRuntime.removeOwnSlackReactions(channelId, messageId); return jsonResult({ ok: true, removed }); } if (writeOpts) { - await reactSlackMessage(channelId, messageId, emoji, writeOpts); + await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts); } else { - await reactSlackMessage(channelId, messageId, emoji); + await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji); } return jsonResult({ ok: true, added: emoji }); } const reactions = readOpts - ? await listSlackReactions(channelId, messageId, readOpts) - : await listSlackReactions(channelId, messageId); + ? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts) + : await slackActionRuntime.listSlackReactions(channelId, messageId); return jsonResult({ ok: true, reactions }); } @@ -211,7 +227,7 @@ export async function handleSlackAction( to, context, ); - const result = await sendSlackMessage(to, content ?? "", { + const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", { ...writeOpts, mediaUrl: mediaUrl ?? undefined, mediaLocalRoots: context?.mediaLocalRoots, @@ -220,7 +236,11 @@ export async function handleSlackAction( }); if (threadTs && result.channelId && account.accountId) { - recordSlackThreadParticipation(account.accountId, result.channelId, threadTs); + slackActionRuntime.recordSlackThreadParticipation( + account.accountId, + result.channelId, + threadTs, + ); } // Keep "first" mode consistent even when the agent explicitly provided @@ -248,12 +268,12 @@ export async function handleSlackAction( throw new Error("Slack editMessage requires content or blocks."); } if (writeOpts) { - await editSlackMessage(channelId, messageId, content ?? "", { + await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", { ...writeOpts, blocks, }); } else { - await editSlackMessage(channelId, messageId, content ?? "", { + await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", { blocks, }); } @@ -265,9 +285,9 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await deleteSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts); } else { - await deleteSlackMessage(channelId, messageId); + await slackActionRuntime.deleteSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } @@ -279,7 +299,7 @@ export async function handleSlackAction( const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); const threadId = readStringParam(params, "threadId"); - const result = await readSlackMessages(channelId, { + const result = await slackActionRuntime.readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, @@ -302,7 +322,7 @@ export async function handleSlackAction( const maxBytes = account.config?.mediaMaxMb ? account.config.mediaMaxMb * 1024 * 1024 : 20 * 1024 * 1024; - const downloaded = await downloadSlackFile(fileId, { + const downloaded = await slackActionRuntime.downloadSlackFile(fileId, { ...readOpts, maxBytes, channelId, @@ -336,9 +356,9 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await pinSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts); } else { - await pinSlackMessage(channelId, messageId); + await slackActionRuntime.pinSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } @@ -347,15 +367,15 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await unpinSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts); } else { - await unpinSlackMessage(channelId, messageId); + await slackActionRuntime.unpinSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } const pins = writeOpts - ? await listSlackPins(channelId, readOpts) - : await listSlackPins(channelId); + ? await slackActionRuntime.listSlackPins(channelId, readOpts) + : await slackActionRuntime.listSlackPins(channelId); const normalizedPins = pins.map((pin) => { const message = pin.message ? withNormalizedTimestamp( @@ -374,8 +394,8 @@ export async function handleSlackAction( } const userId = readStringParam(params, "userId", { required: true }); const info = writeOpts - ? await getSlackMemberInfo(userId, readOpts) - : await getSlackMemberInfo(userId); + ? await slackActionRuntime.getSlackMemberInfo(userId, readOpts) + : await slackActionRuntime.getSlackMemberInfo(userId); return jsonResult({ ok: true, info }); } @@ -383,7 +403,9 @@ export async function handleSlackAction( if (!isActionEnabled("emojiList")) { throw new Error("Slack emoji list is disabled."); } - const result = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis(); + const result = readOpts + ? await slackActionRuntime.listSlackEmojis(readOpts) + : await slackActionRuntime.listSlackEmojis(); const limit = readNumberParam(params, "limit", { integer: true }); if (limit != null && limit > 0 && result.emoji != null) { const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b)); diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 8920923bc46..c9cf3e9d883 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -1,5 +1,8 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { handleSlackAction, type SlackActionContext } from "../../agents/tools/slack-actions.js"; +import { + handleSlackAction, + type SlackActionContext, +} from "../../../extensions/slack/runtime-api.js"; import { extractSlackToolSend, isSlackInteractiveRepliesEnabled, @@ -7,6 +10,7 @@ import { resolveSlackChannelId, handleSlackMessageAction, } from "../../plugin-sdk/slack.js"; +import { createLegacyMessageToolDiscoveryMethods } from "./message-tool-legacy.js"; import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js"; @@ -48,6 +52,7 @@ export function createSlackActions( return { describeMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMessageTool), extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 4b78d14480d..bb3dcfe7c59 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -80,4 +80,4 @@ export { export { recordSlackThreadParticipation } from "../../extensions/slack/api.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { createSlackActions } from "../channels/plugins/slack.actions.js"; -export type { SlackActionContext } from "../agents/tools/slack-actions.js"; +export type { SlackActionContext } from "../../extensions/slack/runtime-api.js"; diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 65b7ed9e884..8c06f2dda34 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -7,7 +7,7 @@ import { probeSlack as probeSlackImpl } from "../../../extensions/slack/runtime- import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; -import { handleSlackAction as handleSlackActionImpl } from "../../agents/tools/slack-actions.js"; +import { handleSlackAction as handleSlackActionImpl } from "../../../extensions/slack/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeSlackOps = Pick< diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index a346a2f8e3a..b3e46b35bb8 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -144,7 +144,7 @@ export type PluginRuntimeChannel = { resolveUserAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackUserAllowlist; sendMessageSlack: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; monitorSlackProvider: typeof import("../../../extensions/slack/runtime-api.js").monitorSlackProvider; - handleSlackAction: typeof import("../../agents/tools/slack-actions.js").handleSlackAction; + handleSlackAction: typeof import("../../../extensions/slack/runtime-api.js").handleSlackAction; }; telegram: { auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; From 8165db758bfa2fab16f71cfe31be40f2f9c8311e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:39 +0000 Subject: [PATCH 187/393] WhatsApp: move action runtime into extension --- extensions/whatsapp/runtime-api.ts | 1 + .../src/action-runtime-target-auth.ts | 8 +++---- .../whatsapp/src/action-runtime.test.ts | 20 +++++++---------- .../whatsapp/src/action-runtime.ts | 22 ++++++++++++++----- src/plugins/runtime/runtime-whatsapp.ts | 4 ++-- src/plugins/runtime/types-channel.ts | 2 +- 6 files changed, 32 insertions(+), 25 deletions(-) rename src/agents/tools/whatsapp-target-auth.ts => extensions/whatsapp/src/action-runtime-target-auth.ts (70%) rename src/agents/tools/whatsapp-actions.test.ts => extensions/whatsapp/src/action-runtime.test.ts (88%) rename src/agents/tools/whatsapp-actions.ts => extensions/whatsapp/src/action-runtime.ts (72%) diff --git a/extensions/whatsapp/runtime-api.ts b/extensions/whatsapp/runtime-api.ts index 24e269ad62f..531cee4b524 100644 --- a/extensions/whatsapp/runtime-api.ts +++ b/extensions/whatsapp/runtime-api.ts @@ -1,4 +1,5 @@ export * from "./src/active-listener.js"; +export * from "./src/action-runtime.js"; export * from "./src/agent-tools-login.js"; export * from "./src/auth-store.js"; export * from "./src/auto-reply.js"; diff --git a/src/agents/tools/whatsapp-target-auth.ts b/extensions/whatsapp/src/action-runtime-target-auth.ts similarity index 70% rename from src/agents/tools/whatsapp-target-auth.ts rename to extensions/whatsapp/src/action-runtime-target-auth.ts index edc0052fbab..8686ac24261 100644 --- a/src/agents/tools/whatsapp-target-auth.ts +++ b/extensions/whatsapp/src/action-runtime-target-auth.ts @@ -1,7 +1,7 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; -import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; -import { ToolAuthorizationError } from "./common.js"; +import { ToolAuthorizationError } from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; export function resolveAuthorizedWhatsAppOutboundTarget(params: { cfg: OpenClawConfig; diff --git a/src/agents/tools/whatsapp-actions.test.ts b/extensions/whatsapp/src/action-runtime.test.ts similarity index 88% rename from src/agents/tools/whatsapp-actions.test.ts rename to extensions/whatsapp/src/action-runtime.test.ts index 1fc195ffd1e..b8fb950281a 100644 --- a/src/agents/tools/whatsapp-actions.test.ts +++ b/extensions/whatsapp/src/action-runtime.test.ts @@ -1,17 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; -import { handleWhatsAppAction } from "./whatsapp-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { handleWhatsAppAction, whatsAppActionRuntime } from "./action-runtime.js"; -const { sendReactionWhatsApp, sendPollWhatsApp } = vi.hoisted(() => ({ - sendReactionWhatsApp: vi.fn(async () => undefined), - sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })), -})); - -vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ - sendReactionWhatsApp, - sendPollWhatsApp, -})); +const originalWhatsAppActionRuntime = { ...whatsAppActionRuntime }; +const sendReactionWhatsApp = vi.fn(async () => undefined); const enabledConfig = { channels: { whatsapp: { actions: { reactions: true } } }, @@ -20,6 +13,9 @@ const enabledConfig = { describe("handleWhatsAppAction", () => { beforeEach(() => { vi.clearAllMocks(); + Object.assign(whatsAppActionRuntime, originalWhatsAppActionRuntime, { + sendReactionWhatsApp, + }); }); it("adds reactions", async () => { diff --git a/src/agents/tools/whatsapp-actions.ts b/extensions/whatsapp/src/action-runtime.ts similarity index 72% rename from src/agents/tools/whatsapp-actions.ts rename to extensions/whatsapp/src/action-runtime.ts index a84dc0a3d5b..6a805440633 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -1,8 +1,18 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js"; -import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; -import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js"; +import { + createActionGate, + jsonResult, + readReactionParams, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js"; +import { sendReactionWhatsApp } from "./send.js"; + +export const whatsAppActionRuntime = { + resolveAuthorizedWhatsAppOutboundTarget, + sendReactionWhatsApp, +}; export async function handleWhatsAppAction( params: Record, @@ -26,7 +36,7 @@ export async function handleWhatsAppAction( const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined; // Resolve account + allowFrom via shared account logic so auth and routing stay aligned. - const resolved = resolveAuthorizedWhatsAppOutboundTarget({ + const resolved = whatsAppActionRuntime.resolveAuthorizedWhatsAppOutboundTarget({ cfg, chatJid, accountId, @@ -34,7 +44,7 @@ export async function handleWhatsAppAction( }); const resolvedEmoji = remove ? "" : emoji; - await sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, { + await whatsAppActionRuntime.sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, { verbose: false, fromMe, participant: participant ?? undefined, diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 5ca70688471..72bb3fd6af0 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -68,7 +68,7 @@ let webLoginQrPromise: Promise< > | null = null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< - typeof import("../../agents/tools/whatsapp-actions.js") + typeof import("../../../extensions/whatsapp/runtime-api.js") > | null = null; function loadWebLoginQr() { @@ -82,7 +82,7 @@ function loadWebChannel() { } function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js"); + whatsappActionsPromise ??= import("../../../extensions/whatsapp/runtime-api.js"); return whatsappActionsPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index b3e46b35bb8..6b0a0e3a8f6 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -217,7 +217,7 @@ export type PluginRuntimeChannel = { startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; - handleWhatsAppAction: typeof import("../../agents/tools/whatsapp-actions.js").handleWhatsAppAction; + handleWhatsAppAction: typeof import("../../../extensions/whatsapp/runtime-api.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { From b5c38b109580f44580669b28372d7c8ebebb1df5 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:51 +0000 Subject: [PATCH 188/393] Docs: point message runtime docs and tests at plugin-owned code --- docs/pi.md | 13 ++++-- docs/tools/plugin.md | 7 ++++ src/channels/plugins/actions/actions.test.ts | 6 +-- src/commands/message.test.ts | 41 ++++++++++++------- .../outbound/cfg-threading.guard.test.ts | 2 +- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/docs/pi.md b/docs/pi.md index 2689b480963..f12c687906c 100644 --- a/docs/pi.md +++ b/docs/pi.md @@ -119,19 +119,24 @@ src/agents/ │ ├── browser-tool.ts │ ├── canvas-tool.ts │ ├── cron-tool.ts -│ ├── discord-actions*.ts │ ├── gateway-tool.ts │ ├── image-tool.ts │ ├── message-tool.ts │ ├── nodes-tool.ts │ ├── session*.ts -│ ├── slack-actions.ts -│ ├── telegram-actions.ts │ ├── web-*.ts -│ └── whatsapp-actions.ts +│ └── ... └── ... ``` +Channel-specific message action runtimes now live in the plugin-owned extension +directories instead of under `src/agents/tools`, for example: + +- `extensions/discord/src/actions/runtime*.ts` +- `extensions/slack/src/action-runtime.ts` +- `extensions/telegram/src/action-runtime.ts` +- `extensions/whatsapp/src/action-runtime.ts` + ## Core Integration Flow ### 1. Running an Embedded Agent diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index af4cd1bf6ac..4dc95ae4fe6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -228,6 +228,13 @@ responsible for forwarding the current chat/session identity into the plugin discovery boundary so the shared `message` tool exposes the right channel-owned surface for the current turn. +For channel-owned execution helpers, bundled plugins should keep the execution +runtime inside their own extension modules. Core no longer owns the Discord, +Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`. +`agent-runtime` still re-exports the Discord and Telegram helpers for backward +compatibility, but we do not publish separate `plugin-sdk/*-action-runtime` +subpaths and new plugins should import their own local runtime code directly. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 1692e0f0754..f1ff9c36dfd 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -7,11 +7,11 @@ const sendReactionSignal = vi.fn(async (..._args: unknown[]) => ({ ok: true })); const removeReactionSignal = vi.fn(async (..._args: unknown[]) => ({ ok: true })); const handleSlackAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../../../agents/tools/discord-actions.js", () => ({ +vi.mock("../../../../extensions/discord/src/actions/runtime.js", () => ({ handleDiscordAction, })); -vi.mock("../../../agents/tools/telegram-actions.js", () => ({ +vi.mock("../../../../extensions/telegram/src/action-runtime.js", () => ({ handleTelegramAction, })); @@ -20,7 +20,7 @@ vi.mock("../../../../extensions/signal/src/send-reactions.js", () => ({ removeReactionSignal, })); -vi.mock("../../../agents/tools/slack-actions.js", () => ({ +vi.mock("../../../../extensions/slack/runtime-api.js", () => ({ handleSlackAction, })); diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 182946ba7ad..806dc2655d1 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -18,43 +18,54 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -const resolveCommandSecretRefsViaGateway = vi.fn(async ({ config }: { config: unknown }) => ({ - resolvedConfig: config, - diagnostics: [] as string[], +const { resolveCommandSecretRefsViaGateway, callGatewayMock } = vi.hoisted(() => ({ + resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({ + resolvedConfig: config, + diagnostics: [] as string[], + })), + callGatewayMock: vi.fn(), })); + vi.mock("../cli/command-secret-gateway.js", () => ({ resolveCommandSecretRefsViaGateway, })); -const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: callGatewayMock, callGatewayLeastPrivilege: callGatewayMock, randomIdempotencyKey: () => "idem-1", })); -const webAuthExists = vi.fn(async () => false); +const webAuthExists = vi.hoisted(() => vi.fn(async () => false)); vi.mock("../../extensions/whatsapp/src/session.js", () => ({ webAuthExists, })); -const handleDiscordAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/discord-actions.js", () => ({ +const handleDiscordAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/discord/src/actions/runtime.js", () => ({ handleDiscordAction, })); -const handleSlackAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/slack-actions.js", () => ({ +const handleSlackAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/slack/runtime-api.js", () => ({ handleSlackAction, })); -const handleTelegramAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/telegram-actions.js", () => ({ +const handleTelegramAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/telegram/src/action-runtime.js", () => ({ handleTelegramAction, })); -const handleWhatsAppAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/whatsapp-actions.js", () => ({ +const handleWhatsAppAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); @@ -66,10 +77,12 @@ const setRegistry = async (registry: ReturnType) => { }; beforeEach(async () => { + vi.resetModules(); envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN"]); process.env.TELEGRAM_BOT_TOKEN = ""; process.env.DISCORD_BOT_TOKEN = ""; testConfig = {}; + ({ messageCommand } = await import("./message.js")); await setRegistry(createTestRegistry([])); callGatewayMock.mockClear(); webAuthExists.mockClear().mockResolvedValue(false); @@ -184,7 +197,7 @@ const createTelegramPollPluginRegistration = () => ({ }), }); -const { messageCommand } = await import("./message.js"); +let messageCommand: typeof import("./message.js").messageCommand; function createTelegramSecretRawConfig() { return { diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 3fdbb68e10b..cfdbc892db4 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -61,7 +61,7 @@ function listExtensionFiles(): { function listHighRiskRuntimeCfgFiles(): string[] { return [ - "src/agents/tools/telegram-actions.ts", + "extensions/telegram/src/action-runtime.ts", "extensions/discord/src/monitor/reply-delivery.ts", "extensions/discord/src/monitor/thread-bindings.discord-api.ts", "extensions/discord/src/monitor/thread-bindings.manager.ts", From ed7269518fc783a1201f4e781bcb1a13c2246a67 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:12:53 +0000 Subject: [PATCH 189/393] Tlon: fix plugin-sdk import boundaries --- extensions/tlon/src/monitor/media.ts | 2 +- src/plugin-sdk/tlon.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index ea86328d2ce..8a17e982fad 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { fetchWithSsrFGuard } from "../api.js"; +import { fetchWithSsrFGuard } from "../../api.js"; import { getDefaultSsrFPolicy } from "../urbit/context.js"; // Default to OpenClaw workspace media directory diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 246c4b7093e..291834b9648 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -27,5 +27,5 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter } from "../../extensions/tlon/api.js"; -export { tlonSetupWizard } from "../../extensions/tlon/api.js"; +export { tlonSetupAdapter } from "../../extensions/tlon/src/setup-core.js"; +export { tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; From 1c6676cd57453ba8b29a9f908937df7b015beb95 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:17:40 +0000 Subject: [PATCH 190/393] Plugins: remove first-party legacy message discovery shims --- extensions/discord/src/channel-actions.ts | 2 -- extensions/discord/src/channel.ts | 6 ---- extensions/feishu/src/channel.ts | 6 +--- extensions/mattermost/src/channel.ts | 32 +++++++++------------ extensions/msteams/src/channel.ts | 6 +--- extensions/telegram/src/channel-actions.ts | 2 -- extensions/telegram/src/channel.ts | 6 ---- src/channels/plugins/message-tool-legacy.ts | 13 --------- src/channels/plugins/slack.actions.ts | 2 -- src/commands/channels/capabilities.ts | 20 ++++++++++--- src/plugin-sdk/agent-runtime.ts | 12 -------- src/plugin-sdk/channel-runtime.ts | 1 - 12 files changed, 32 insertions(+), 76 deletions(-) delete mode 100644 src/channels/plugins/message-tool-legacy.ts diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 960b08acdf6..c4be7728439 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,5 +1,4 @@ import { - createLegacyMessageToolDiscoveryMethods, createDiscordMessageToolComponentsSchema, createUnionActionGate, listTokenSourcedAccounts, @@ -133,7 +132,6 @@ function describeDiscordMessageTool({ export const discordMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeDiscordMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeDiscordMessageTool), extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action === "sendMessage") { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c555ff89382..58076e1e67d 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -79,12 +79,6 @@ function formatDiscordIntents(intents?: { const discordMessageActions: ChannelMessageActionAdapter = { describeMessageTool: (ctx) => getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null, - listActions: (ctx) => - getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], - getCapabilities: (ctx) => - getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [], - getToolSchema: (ctx) => - getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index da5cd8e4382..fda85f113e1 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,10 +1,7 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { - createLegacyMessageToolDiscoveryMethods, - createMessageToolCardSchema, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -453,7 +450,6 @@ export const feishuPlugin: ChannelPlugin = { }, actions: { describeMessageTool: describeFeishuMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeFeishuMessageTool), handleAction: async (ctx) => { const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined }); if ( diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 5688e13d8ae..4bc716ac27e 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -4,24 +4,8 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { - createLegacyMessageToolDiscoveryMethods, - createMessageToolButtonsSchema, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; -import { - buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, - createAccountStatusSink, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - setAccountEnabledInConfigSection, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, - type ChannelPlugin, -} from "./runtime-api.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -42,6 +26,19 @@ import { addMattermostReaction, removeMattermostReaction } from "./mattermost/re import { sendMessageMattermost } from "./mattermost/send.js"; import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; +import { + buildComputedAccountStatusSnapshot, + buildChannelConfigSchema, + createAccountStatusSink, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + setAccountEnabledInConfigSection, + type ChannelMessageActionAdapter, + type ChannelMessageActionName, + type ChannelPlugin, +} from "./runtime-api.js"; import { getMattermostRuntime } from "./runtime.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; @@ -88,7 +85,6 @@ function describeMattermostMessageTool({ const mattermostMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeMattermostMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeMattermostMessageTool), supportsAction: ({ action }) => { return action === "send" || action === "react"; }, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 7458389efb1..5f3a6aa0b59 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,9 +1,6 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { - createLegacyMessageToolDiscoveryMethods, - createMessageToolCardSchema, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -398,7 +395,6 @@ export const msteamsPlugin: ChannelPlugin = { }, actions: { describeMessageTool: describeMSTeamsMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeMSTeamsMessageTool), handleAction: async (ctx) => { // Handle send action with card parameter if (ctx.action === "send" && ctx.params.card) { diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index cd757688835..56d27817921 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -7,7 +7,6 @@ import { import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import { - createLegacyMessageToolDiscoveryMethods, createMessageToolButtonsSchema, createTelegramPollExtraToolSchemas, createUnionActionGate, @@ -178,7 +177,6 @@ function readTelegramMessageIdParam(params: Record): number { export const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeTelegramMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeTelegramMessageTool), extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 6d536fb8513..56a2256f9c0 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -250,12 +250,6 @@ function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.describeMessageTool?.(ctx) ?? null, - listActions: (ctx) => - getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], - getCapabilities: (ctx) => - getTelegramRuntime().channel.telegram.messageActions?.getCapabilities?.(ctx) ?? [], - getToolSchema: (ctx) => - getTelegramRuntime().channel.telegram.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { diff --git a/src/channels/plugins/message-tool-legacy.ts b/src/channels/plugins/message-tool-legacy.ts deleted file mode 100644 index 2c74213439f..00000000000 --- a/src/channels/plugins/message-tool-legacy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ChannelMessageActionAdapter } from "./types.js"; - -export function createLegacyMessageToolDiscoveryMethods( - describeMessageTool: NonNullable, -): Pick { - const describe = (ctx: Parameters[0]) => - describeMessageTool(ctx) ?? null; - return { - listActions: (ctx) => [...(describe(ctx)?.actions ?? [])], - getCapabilities: (ctx) => [...(describe(ctx)?.capabilities ?? [])], - getToolSchema: (ctx) => describe(ctx)?.schema ?? null, - }; -} diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index c9cf3e9d883..317b8a7d8db 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -10,7 +10,6 @@ import { resolveSlackChannelId, handleSlackMessageAction, } from "../../plugin-sdk/slack.js"; -import { createLegacyMessageToolDiscoveryMethods } from "./message-tool-legacy.js"; import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js"; @@ -52,7 +51,6 @@ export function createSlackActions( return { describeMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeMessageTool), extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index acd28137b30..eccd96824da 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -1,5 +1,9 @@ import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; +import { + createMessageActionDiscoveryContext, + resolveMessageActionDiscoveryForPlugin, +} from "../../channels/plugins/message-action-discovery.js"; import type { ChannelCapabilities, ChannelCapabilitiesDiagnostics, @@ -133,10 +137,6 @@ async function resolveChannelReports(params: { : [resolveChannelDefaultAccountId({ plugin, cfg, accountIds: ids })]; })(); const reports: ChannelCapabilitiesReport[] = []; - const listedActions = plugin.actions?.listActions?.({ cfg }) ?? []; - const actions = Array.from( - new Set(["send", "broadcast", ...listedActions.map((action) => String(action))]), - ); for (const accountId of accountIds) { const resolvedAccount = plugin.config.resolveAccount(cfg, accountId); @@ -169,6 +169,18 @@ async function resolveChannelReports(params: { target: params.target, }) : undefined; + const discoveredActions = resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext({ + cfg, + accountId, + }), + includeActions: true, + }).actions; + const actions = Array.from( + new Set(["send", "broadcast", ...discoveredActions.map((action) => String(action))]), + ); reports.push({ channel: plugin.id, diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 20ab0596a12..e267a458e16 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -23,15 +23,3 @@ export * from "../agents/vllm-defaults.js"; // Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../agents/agent-command.js"; export * from "../tts/tts.js"; -// Legacy channel action runtime re-exports. New bundled plugin code should use -// local extension-owned modules instead of adding more public SDK surface here. -export { - handleDiscordAction, - readDiscordParentIdParam, - isDiscordModerationAction, - readDiscordModerationCommand, -} from "../../extensions/discord/runtime-api.js"; -export { - handleTelegramAction, - readTelegramButtons, -} from "../../extensions/telegram/runtime-api.js"; diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 1460acba87d..089e10609af 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -34,7 +34,6 @@ export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; export * from "../channels/plugins/directory-config.js"; export * from "../channels/plugins/media-payload.js"; -export * from "../channels/plugins/message-tool-legacy.js"; export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; From fb0d04c8342c1ee12b7eaae159573699b9a4bdd9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:17:47 +0000 Subject: [PATCH 191/393] Tests: migrate channel action discovery to describeMessageTool --- docs/tools/plugin.md | 6 +- extensions/feishu/src/channel.test.ts | 8 ++- extensions/mattermost/src/channel.test.ts | 12 ++-- src/channels/plugins/actions/actions.test.ts | 28 +++++++-- src/channels/plugins/contracts/registry.ts | 30 +++++---- src/channels/plugins/contracts/suites.ts | 42 +++++++++++-- .../plugins/message-capability-matrix.test.ts | 62 ++++++++++--------- 7 files changed, 125 insertions(+), 63 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4dc95ae4fe6..7d49323892d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -231,9 +231,9 @@ surface for the current turn. For channel-owned execution helpers, bundled plugins should keep the execution runtime inside their own extension modules. Core no longer owns the Discord, Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`. -`agent-runtime` still re-exports the Discord and Telegram helpers for backward -compatibility, but we do not publish separate `plugin-sdk/*-action-runtime` -subpaths and new plugins should import their own local runtime code directly. +We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled +plugins should import their own local runtime code directly from their +extension-owned modules. ## Capability ownership model diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 7c4ae5d877a..df105f81919 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -54,6 +54,10 @@ vi.mock("./channel.runtime.js", () => ({ import { feishuPlugin } from "./channel.js"; +function getDescribedActions(cfg: OpenClawConfig): string[] { + return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])]; +} + describe("feishuPlugin.status.probeAccount", () => { it("uses current account credentials for multi-account config", async () => { const cfg = { @@ -112,7 +116,7 @@ describe("feishuPlugin actions", () => { }); it("advertises the expanded Feishu action surface", () => { - expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual([ + expect(getDescribedActions(cfg)).toEqual([ "send", "read", "edit", @@ -142,7 +146,7 @@ describe("feishuPlugin actions", () => { }, } as OpenClawConfig; - expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([ + expect(getDescribedActions(disabledCfg)).toEqual([ "send", "read", "edit", diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 5ac333b2e6c..29c4cc12e0e 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -17,6 +17,10 @@ import { withMockedGlobalFetch, } from "./mattermost/reactions.test-helpers.js"; +function getDescribedActions(cfg: OpenClawConfig): string[] { + return [...(mattermostPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])]; +} + describe("mattermostPlugin", () => { beforeEach(() => { sendMessageMattermostMock.mockReset(); @@ -132,7 +136,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).toContain("react"); expect(actions).toContain("send"); expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true); @@ -148,7 +152,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).toEqual([]); }); @@ -164,7 +168,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).not.toContain("react"); expect(actions).toContain("send"); }); @@ -187,7 +191,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).toContain("react"); }); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index f1ff9c36dfd..b4631d03f2c 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { ChannelMessageActionAdapter } from "../types.js"; const handleDiscordAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); const handleTelegramAction = vi.fn(async (..._args: unknown[]) => ({ ok: true })); @@ -30,6 +31,13 @@ let telegramMessageActions: typeof import("./telegram.js").telegramMessageAction let signalMessageActions: typeof import("./signal.js").signalMessageActions; let createSlackActions: typeof import("../slack.actions.js").createSlackActions; +function getDescribedActions(params: { + describeMessageTool?: ChannelMessageActionAdapter["describeMessageTool"]; + cfg: OpenClawConfig; +}) { + return [...(params.describeMessageTool?.({ cfg: params.cfg })?.actions ?? [])]; +} + function telegramCfg(): OpenClawConfig { return { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; } @@ -284,7 +292,10 @@ describe("discord message actions", () => { ] as const; for (const testCase of cases) { - const actions = discordMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: discordMessageActions.describeMessageTool, + cfg: testCase.cfg, + }); if (testCase.expectUploads) { expect(actions, testCase.name).toContain("emoji-upload"); expect(actions, testCase.name).toContain("sticker-upload"); @@ -629,7 +640,10 @@ describe("telegramMessageActions", () => { expectTopicEdit: true, }, ]) { - const actions = telegramMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: telegramMessageActions.describeMessageTool, + cfg: testCase.cfg, + }); if (testCase.expectPoll) { expect(actions, testCase.name).toContain("poll"); } else { @@ -680,7 +694,10 @@ describe("telegramMessageActions", () => { ] as const; for (const testCase of cases) { - const actions = telegramMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: telegramMessageActions.describeMessageTool, + cfg: testCase.cfg, + }); if (testCase.expectSticker) { expect(actions, testCase.name).toContain("sticker"); expect(actions, testCase.name).toContain("sticker-search"); @@ -903,7 +920,10 @@ describe("telegramMessageActions", () => { }, }, } as OpenClawConfig; - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: telegramMessageActions.describeMessageTool, + cfg, + }); expect(actions).toContain("sticker"); expect(actions).toContain("sticker-search"); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index fd2d84e8b70..8b203c9b541 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -174,17 +174,14 @@ function expectClearedSessionBinding(params: { ).toBeNull(); } -const telegramListActionsMock = vi.fn(); -const telegramGetCapabilitiesMock = vi.fn(); -const discordListActionsMock = vi.fn(); -const discordGetCapabilitiesMock = vi.fn(); +const telegramDescribeMessageToolMock = vi.fn(); +const discordDescribeMessageToolMock = vi.fn(); bundledChannelRuntimeSetters.setTelegramRuntime({ channel: { telegram: { messageActions: { - listActions: telegramListActionsMock, - getCapabilities: telegramGetCapabilitiesMock, + describeMessageTool: telegramDescribeMessageToolMock, }, }, }, @@ -194,8 +191,7 @@ bundledChannelRuntimeSetters.setDiscordRuntime({ channel: { discord: { messageActions: { - listActions: discordListActionsMock, - getCapabilities: discordGetCapabilitiesMock, + describeMessageTool: discordDescribeMessageToolMock, }, }, }, @@ -358,10 +354,11 @@ export const actionContractRegistry: ActionsContractEntry[] = [ expectedActions: ["send", "poll", "react"], expectedCapabilities: ["interactive", "buttons"], beforeTest: () => { - telegramListActionsMock.mockReset(); - telegramGetCapabilitiesMock.mockReset(); - telegramListActionsMock.mockReturnValue(["send", "poll", "react"]); - telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); + telegramDescribeMessageToolMock.mockReset(); + telegramDescribeMessageToolMock.mockReturnValue({ + actions: ["send", "poll", "react"], + capabilities: ["interactive", "buttons"], + }); }, }, ], @@ -376,10 +373,11 @@ export const actionContractRegistry: ActionsContractEntry[] = [ expectedActions: ["send", "react", "poll"], expectedCapabilities: ["interactive", "components"], beforeTest: () => { - discordListActionsMock.mockReset(); - discordGetCapabilitiesMock.mockReset(); - discordListActionsMock.mockReturnValue(["send", "react", "poll"]); - discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); + discordDescribeMessageToolMock.mockReset(); + discordDescribeMessageToolMock.mockReturnValue({ + actions: ["send", "react", "poll"], + capabilities: ["interactive", "components"], + }); }, }, ], diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index cc442b5ef20..58a62d62ed3 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -32,6 +32,30 @@ function sortStrings(values: readonly string[]) { return [...values].toSorted((left, right) => left.localeCompare(right)); } +function resolveContractMessageDiscovery(params: { + plugin: Pick; + cfg: OpenClawConfig; +}) { + const actions = params.plugin.actions; + if (!actions) { + return { + actions: [] as ChannelMessageActionName[], + capabilities: [] as readonly ChannelMessageCapability[], + }; + } + if (actions.describeMessageTool) { + const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null; + return { + actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [], + capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [], + }; + } + return { + actions: actions.listActions?.({ cfg: params.cfg }) ?? [], + capabilities: actions.getCapabilities?.({ cfg: params.cfg }) ?? [], + }; +} + const contractRuntime = createNonExitingRuntime(); function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) { expect(["user", "group", "channel"]).toContain(entry.kind); @@ -132,15 +156,22 @@ export function installChannelActionsContractSuite(params: { }) { it("exposes the base message actions contract", () => { expect(params.plugin.actions).toBeDefined(); - expect(typeof params.plugin.actions?.listActions).toBe("function"); + expect( + typeof params.plugin.actions?.describeMessageTool === "function" || + typeof params.plugin.actions?.listActions === "function", + ).toBe(true); }); for (const testCase of params.cases) { it(`actions contract: ${testCase.name}`, () => { testCase.beforeTest?.(); - const actions = params.plugin.actions?.listActions?.({ cfg: testCase.cfg }) ?? []; - const capabilities = params.plugin.actions?.getCapabilities?.({ cfg: testCase.cfg }) ?? []; + const discovery = resolveContractMessageDiscovery({ + plugin: params.plugin, + cfg: testCase.cfg, + }); + const actions = discovery.actions; + const capabilities = discovery.capabilities; expect(actions).toEqual([...new Set(actions)]); expect(capabilities).toEqual([...new Set(capabilities)]); @@ -192,7 +223,10 @@ export function installChannelSurfaceContractSuite(params: { it(`exposes the ${surface} surface contract`, () => { if (surface === "actions") { expect(plugin.actions).toBeDefined(); - expect(typeof plugin.actions?.listActions).toBe("function"); + expect( + typeof plugin.actions?.describeMessageTool === "function" || + typeof plugin.actions?.listActions === "function", + ).toBe(true); return; } diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index 9ab42ad4c51..459193d0792 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -1,15 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import type { ChannelMessageActionAdapter, ChannelPlugin } from "./types.js"; -const telegramGetCapabilitiesMock = vi.fn(); -const discordGetCapabilitiesMock = vi.fn(); +const telegramDescribeMessageToolMock = vi.fn(); +const discordDescribeMessageToolMock = vi.fn(); vi.mock("../../../extensions/telegram/src/runtime.js", () => ({ getTelegramRuntime: () => ({ channel: { telegram: { messageActions: { - getCapabilities: telegramGetCapabilitiesMock, + describeMessageTool: telegramDescribeMessageToolMock, }, }, }, @@ -21,7 +22,7 @@ vi.mock("../../../extensions/discord/src/runtime.js", () => ({ channel: { discord: { messageActions: { - getCapabilities: discordGetCapabilitiesMock, + describeMessageTool: discordDescribeMessageToolMock, }, }, }, @@ -38,10 +39,16 @@ const { zaloPlugin } = await import("../../../extensions/zalo/src/channel.js"); describe("channel action capability matrix", () => { afterEach(() => { - telegramGetCapabilitiesMock.mockReset(); - discordGetCapabilitiesMock.mockReset(); + telegramDescribeMessageToolMock.mockReset(); + discordDescribeMessageToolMock.mockReset(); }); + function getCapabilities(plugin: Pick, cfg: OpenClawConfig) { + const describeMessageTool: ChannelMessageActionAdapter["describeMessageTool"] | undefined = + plugin.actions?.describeMessageTool; + return [...(describeMessageTool?.({ cfg })?.capabilities ?? [])]; + } + it("exposes Slack blocks by default and interactive when enabled", () => { const baseCfg = { channels: { @@ -61,26 +68,27 @@ describe("channel action capability matrix", () => { }, } as OpenClawConfig; - expect(slackPlugin.actions?.getCapabilities?.({ cfg: baseCfg })).toEqual(["blocks"]); - expect(slackPlugin.actions?.getCapabilities?.({ cfg: interactiveCfg })).toEqual([ - "blocks", - "interactive", - ]); + expect(getCapabilities(slackPlugin, baseCfg)).toEqual(["blocks"]); + expect(getCapabilities(slackPlugin, interactiveCfg)).toEqual(["blocks", "interactive"]); }); it("forwards Telegram action capabilities through the channel wrapper", () => { - telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); + telegramDescribeMessageToolMock.mockReturnValue({ + capabilities: ["interactive", "buttons"], + }); - const result = telegramPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); + const result = getCapabilities(telegramPlugin, {} as OpenClawConfig); expect(result).toEqual(["interactive", "buttons"]); - expect(telegramGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); - discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); + expect(telegramDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} }); + discordDescribeMessageToolMock.mockReturnValue({ + capabilities: ["interactive", "components"], + }); - const discordResult = discordPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); + const discordResult = getCapabilities(discordPlugin, {} as OpenClawConfig); expect(discordResult).toEqual(["interactive", "components"]); - expect(discordGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); + expect(discordDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} }); }); it("exposes configured channel capabilities only when required credentials are present", () => { @@ -139,18 +147,12 @@ describe("channel action capability matrix", () => { }, } as OpenClawConfig; - expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual([ - "buttons", - ]); - expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: unconfiguredCfg })).toEqual([]); - expect(feishuPlugin.actions?.getCapabilities?.({ cfg: configuredFeishuCfg })).toEqual([ - "cards", - ]); - expect(feishuPlugin.actions?.getCapabilities?.({ cfg: disabledFeishuCfg })).toEqual([]); - expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: configuredMsteamsCfg })).toEqual([ - "cards", - ]); - expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: disabledMsteamsCfg })).toEqual([]); + expect(getCapabilities(mattermostPlugin, configuredCfg)).toEqual(["buttons"]); + expect(getCapabilities(mattermostPlugin, unconfiguredCfg)).toEqual([]); + expect(getCapabilities(feishuPlugin, configuredFeishuCfg)).toEqual(["cards"]); + expect(getCapabilities(feishuPlugin, disabledFeishuCfg)).toEqual([]); + expect(getCapabilities(msteamsPlugin, configuredMsteamsCfg)).toEqual(["cards"]); + expect(getCapabilities(msteamsPlugin, disabledMsteamsCfg)).toEqual([]); }); it("keeps Zalo actions on the empty capability set", () => { @@ -163,6 +165,6 @@ describe("channel action capability matrix", () => { }, } as OpenClawConfig; - expect(zaloPlugin.actions?.getCapabilities?.({ cfg })).toEqual([]); + expect(getCapabilities(zaloPlugin, cfg)).toEqual([]); }); }); From 8e98019b6af9d128cd32e2635c0a4386e386887b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:17:56 +0000 Subject: [PATCH 192/393] Nostr: remove plugin API import cycle --- extensions/nostr/src/channel.ts | 10 +++++----- extensions/nostr/src/config-schema.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 4296f71b9ac..21dfce3a9da 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,7 +1,3 @@ -import { - buildPassiveChannelStatusSummary, - buildTrafficStatusSummary, -} from "../../shared/channel-status-summary.js"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -10,7 +6,11 @@ import { formatPairingApproveHint, mapAllowFromEntries, type ChannelPlugin, -} from "../api.js"; +} from "openclaw/plugin-sdk/nostr"; +import { + buildPassiveChannelStatusSummary, + buildTrafficStatusSummary, +} from "../../shared/channel-status-summary.js"; import type { NostrProfile } from "./config-schema.js"; import { NostrConfigSchema } from "./config-schema.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 2746d518fe6..53346b0789d 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,6 @@ import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr"; import { z } from "zod"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "../api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) From 09de192b770e851f9b5810b0818972b56c62f19f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:18:02 +0000 Subject: [PATCH 193/393] Tlon: import channel account snapshot type --- extensions/tlon/src/channel.runtime.ts | 7 ++++++- extensions/tlon/src/channel.ts | 2 +- extensions/tlon/src/config-schema.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index c6523f61739..98da82480fa 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,6 +1,11 @@ import crypto from "node:crypto"; import { configureClient } from "@tloncorp/api"; -import type { ChannelOutboundAdapter, ChannelPlugin, OpenClawConfig } from "../api.js"; +import type { + ChannelAccountSnapshot, + ChannelOutboundAdapter, + ChannelPlugin, + OpenClawConfig, +} from "../api.js"; import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../api.js"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonSetupWizard } from "./setup-surface.js"; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 0e22d237589..92d22feedd5 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,4 +1,5 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { applyTlonSetupConfig, @@ -13,7 +14,6 @@ import { resolveTlonOutboundTarget, } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; -import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const TLON_CHANNEL_ID = "tlon" as const; diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 7f12949f30d..e7ec5ef2ecf 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -1,5 +1,5 @@ -import { buildChannelConfigSchema } from "../api.js"; import { z } from "zod"; +import { buildChannelConfigSchema } from "../api.js"; const ShipSchema = z.string().min(1); const ChannelNestSchema = z.string().min(1); From bb803a42acb6c7ef90b1c902a117f3bd27e99756 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:18:06 +0000 Subject: [PATCH 194/393] Mattermost: normalize plugin imports --- extensions/mattermost/src/config-schema.ts | 4 ++-- extensions/mattermost/src/group-mentions.ts | 2 +- extensions/mattermost/src/mattermost/directory.ts | 6 +----- extensions/mattermost/src/mattermost/interactions.ts | 6 +----- extensions/mattermost/src/mattermost/monitor-websocket.ts | 2 +- extensions/mattermost/src/mattermost/slash-http.ts | 2 +- extensions/mattermost/src/runtime.ts | 2 +- extensions/mattermost/src/setup-core.ts | 4 ++-- extensions/mattermost/src/setup-surface.ts | 8 ++++---- 9 files changed, 14 insertions(+), 22 deletions(-) diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index bd1f42dfd7f..e8e50371bd4 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { BlockStreamingCoalesceSchema, DmPolicySchema, @@ -5,8 +7,6 @@ import { MarkdownConfigSchema, requireOpenAllowFrom, } from "./runtime-api.js"; -import { z } from "zod"; -import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; const DmChannelRetrySchema = z diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 4996d115371..4d8d484d89c 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -1,6 +1,6 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import type { ChannelGroupContext } from "./runtime-api.js"; import { resolveMattermostAccount } from "./mattermost/accounts.js"; +import type { ChannelGroupContext } from "./runtime-api.js"; export function resolveMattermostGroupRequireMention( params: ChannelGroupContext & { requireMentionOverride?: boolean }, diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index 630ed7c7194..da6ce747f52 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -1,8 +1,4 @@ -import type { - ChannelDirectoryEntry, - OpenClawConfig, - RuntimeEnv, -} from "../runtime-api.js"; +import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "../runtime-api.js"; import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index a51002667f8..fe11d037396 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -1,10 +1,6 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { - isTrustedProxyAddress, - resolveClientIp, - type OpenClawConfig, -} from "../runtime-api.js"; +import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts index c04affbae1d..09a1248c8cf 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -1,5 +1,5 @@ -import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js"; import WebSocket from "ws"; +import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js"; import type { MattermostPost } from "./client.js"; import { rawDataToString } from "./monitor-helpers.js"; diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 401cc56172a..4d4d5f502a3 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -6,6 +6,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { buildModelsProviderData, createReplyPrefixOptions, @@ -17,7 +18,6 @@ import { type ReplyPayload, type RuntimeEnv, } from "../runtime-api.js"; -import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index e238fa963e2..1fb88e059b7 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "./runtime-api.js"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "./runtime-api.js"; const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = createPluginRuntimeStore("Mattermost runtime not initialized"); diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 13a4991fcd0..624a31a48c4 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,4 +1,6 @@ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { applyAccountNameToChannelSection, applySetupAccountConfigPatch, @@ -8,8 +10,6 @@ import { normalizeAccountId, type OpenClawConfig, } from "./runtime-api.js"; -import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; -import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; const channel = "mattermost" as const; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index 385c4dc75e3..a439dd15006 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,13 +1,13 @@ +import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup"; +import { listMattermostAccountIds } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { applySetupAccountConfigPatch, DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, } from "./runtime-api.js"; -import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "openclaw/plugin-sdk/setup"; -import { listMattermostAccountIds } from "./mattermost/accounts.js"; -import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { isMattermostConfigured, mattermostSetupAdapter, From 9e8b9aba1fc3f482e4fe12bb236ce42690597efd Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:20:57 +0000 Subject: [PATCH 195/393] WhatsApp: isolate lazy action runtime boundary --- extensions/whatsapp/action-runtime.runtime.ts | 1 + src/plugins/runtime/runtime-whatsapp.ts | 4 ++-- src/plugins/runtime/types-channel.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 extensions/whatsapp/action-runtime.runtime.ts diff --git a/extensions/whatsapp/action-runtime.runtime.ts b/extensions/whatsapp/action-runtime.runtime.ts new file mode 100644 index 00000000000..aeb44fc866b --- /dev/null +++ b/extensions/whatsapp/action-runtime.runtime.ts @@ -0,0 +1 @@ +export { handleWhatsAppAction } from "./src/action-runtime.js"; diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 72bb3fd6af0..ba653942550 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -68,7 +68,7 @@ let webLoginQrPromise: Promise< > | null = null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< - typeof import("../../../extensions/whatsapp/runtime-api.js") + typeof import("../../../extensions/whatsapp/action-runtime.runtime.js") > | null = null; function loadWebLoginQr() { @@ -82,7 +82,7 @@ function loadWebChannel() { } function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../../extensions/whatsapp/runtime-api.js"); + whatsappActionsPromise ??= import("../../../extensions/whatsapp/action-runtime.runtime.js"); return whatsappActionsPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 6b0a0e3a8f6..f13dd010c0e 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -217,7 +217,7 @@ export type PluginRuntimeChannel = { startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; - handleWhatsAppAction: typeof import("../../../extensions/whatsapp/runtime-api.js").handleWhatsAppAction; + handleWhatsAppAction: typeof import("../../../extensions/whatsapp/action-runtime.runtime.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { From fa73f5aeb5fb6f7befe1e50216dc46e7444452c0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:34:25 +0000 Subject: [PATCH 196/393] Polls: defer shared parsing until plugin fallback --- docs/tools/plugin.md | 11 +++ src/channels/plugins/types.adapters.ts | 4 + src/channels/plugins/types.core.ts | 4 + ...sage-action-runner.plugin-dispatch.test.ts | 93 +++++++++++++++++++ .../message-action-runner.poll.test.ts | 27 ++++-- src/infra/outbound/message-action-runner.ts | 74 ++++++++------- .../outbound/outbound-send-service.test.ts | 64 +++++++++---- src/infra/outbound/outbound-send-service.ts | 35 +++---- 8 files changed, 236 insertions(+), 76 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7d49323892d..b50797537a6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -235,6 +235,17 @@ We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled plugins should import their own local runtime code directly from their extension-owned modules. +For polls specifically, there are two execution paths: + +- `outbound.sendPoll` is the shared baseline for channels that fit the common + poll model +- `actions.handleAction("poll")` is the preferred path for channel-specific + poll semantics or extra poll parameters + +Core now defers shared poll parsing until after plugin poll dispatch declines +the action, so plugin-owned poll handlers can accept channel-specific poll +fields without being blocked by the generic poll parser first. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index c31d6057223..7274d612c7c 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -170,6 +170,10 @@ export type ChannelOutboundAdapter = { ) => Promise; sendText?: (ctx: ChannelOutboundContext) => Promise; sendMedia?: (ctx: ChannelOutboundContext) => Promise; + /** + * Shared outbound poll adapter for channels that fit the common poll model. + * Channels with extra poll semantics should prefer `actions.handleAction("poll")`. + */ sendPoll?: (ctx: ChannelPollContext) => Promise; }; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 1699b8024a5..24c5c96708e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -521,6 +521,10 @@ export type ChannelMessageActionAdapter = { toolContext?: ChannelThreadingToolContext; }) => boolean; extractToolSend?: (params: { args: Record }) => ChannelToolSend | null; + /** + * Prefer this for channel-specific poll semantics or extra poll parameters. + * Core only parses the shared poll model when falling back to `outbound.sendPoll`. + */ handleAction?: (ctx: ChannelMessageActionContext) => Promise>; }; 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 f875bb40487..55290b8d9d1 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -408,6 +408,99 @@ describe("runMessageAction plugin dispatch", () => { }); }); + describe("plugin-owned poll semantics", () => { + const handleAction = vi.fn(async ({ params }: { params: Record }) => + jsonResult({ + ok: true, + forwarded: { + to: params.to ?? null, + pollQuestion: params.pollQuestion ?? null, + pollOption: params.pollOption ?? null, + pollDurationSeconds: params.pollDurationSeconds ?? null, + pollPublic: params.pollPublic ?? null, + }, + }), + ); + + const discordPollPlugin: ChannelPlugin = { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord plugin-owned poll test plugin.", + }, + capabilities: { chatTypes: ["direct"] }, + config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + actions: { + supportsAction: ({ action }) => action === "poll", + handleAction, + }, + }; + + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: discordPollPlugin, + }, + ]), + ); + handleAction.mockClear(); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + vi.clearAllMocks(); + }); + + it("lets non-telegram plugins own extra poll fields", async () => { + const result = await runMessageAction({ + cfg: { + channels: { + discord: { + token: "tok", + }, + }, + } as OpenClawConfig, + action: "poll", + params: { + channel: "discord", + target: "channel:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + }, + dryRun: false, + }); + + expect(result.kind).toBe("poll"); + expect(result.handledBy).toBe("plugin"); + expect(handleAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + channel: "discord", + params: expect.objectContaining({ + to: "channel:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + }), + }), + ); + }); + }); + describe("components parsing", () => { const handleAction = vi.fn(async ({ params }: { params: Record }) => jsonResult({ diff --git a/src/infra/outbound/message-action-runner.poll.test.ts b/src/infra/outbound/message-action-runner.poll.test.ts index ed1beb91f5d..a46e66dd872 100644 --- a/src/infra/outbound/message-action-runner.poll.test.ts +++ b/src/infra/outbound/message-action-runner.poll.test.ts @@ -34,15 +34,24 @@ async function runPollAction(params: { params: params.actionParams as never, toolContext: params.toolContext as never, }); - return mocks.executePollAction.mock.calls[0]?.[0] as + const call = mocks.executePollAction.mock.calls[0]?.[0] as | { - durationSeconds?: number; - maxSelections?: number; - threadId?: string; - isAnonymous?: boolean; + resolveCorePoll?: () => { + durationSeconds?: number; + maxSelections?: number; + threadId?: string; + isAnonymous?: boolean; + }; ctx?: { params?: Record }; } | undefined; + if (!call) { + return undefined; + } + return { + ...call.resolveCorePoll?.(), + ctx: call.ctx, + }; } describe("runMessageAction poll handling", () => { beforeEach(async () => { @@ -55,11 +64,11 @@ describe("runMessageAction poll handling", () => { telegramConfig, } = await import("./message-action-runner.test-helpers.js")); installMessageActionRunnerTestRegistry(); - mocks.executePollAction.mockResolvedValue({ + mocks.executePollAction.mockImplementation(async (input) => ({ handledBy: "core", - payload: { ok: true }, + payload: { ok: true, corePoll: input.resolveCorePoll() }, pollResult: { ok: true }, - }); + })); }); afterEach(() => { @@ -105,7 +114,7 @@ describe("runMessageAction poll handling", () => { }, ])("$name", async ({ getCfg, actionParams, message }) => { await expect(runPollAction({ cfg: getCfg(), actionParams })).rejects.toThrow(message); - expect(mocks.executePollAction).not.toHaveBeenCalled(); + expect(mocks.executePollAction).toHaveBeenCalledTimes(1); }); it("passes Telegram durationSeconds, visibility, and auto threadId to executePollAction", async () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 70646a288a2..1777fbb32e3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -591,34 +591,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const options = readStringArrayParam(params, "pollOption", { required: true }); + if (options.length < 2) { + throw new Error("pollOption requires at least two values"); + } + const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; + const pollAnonymous = readBooleanParam(params, "pollAnonymous"); + const pollPublic = readBooleanParam(params, "pollPublic"); + const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + const durationSeconds = readNumberParam(params, "pollDurationSeconds", { + integer: true, + strict: true, + }); + + if (durationSeconds !== undefined && channel !== "telegram") { + throw new Error("pollDurationSeconds is only supported for Telegram polls"); + } + if (isAnonymous !== undefined && channel !== "telegram") { + throw new Error("pollAnonymous/pollPublic are only supported for Telegram polls"); + } + + return { + to, + question, + options, + maxSelections: resolvePollMaxSelections(options.length, allowMultiselect), + durationSeconds: durationSeconds ?? undefined, + durationHours: durationHours ?? undefined, + threadId: resolvedThreadId ?? undefined, + isAnonymous, + }; + }, }); return { diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 3f3fd0f2fcc..6f0cf32e6e5 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -143,16 +143,44 @@ describe("executeSendAction", () => { params: {}, dryRun: false, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }), }); expect(result.handledBy).toBe("plugin"); expect(mocks.sendPoll).not.toHaveBeenCalled(); }); + it("does not invoke shared poll parsing before plugin poll dispatch", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin")); + const resolveCorePoll = vi.fn(() => { + throw new Error("shared poll fallback should not run"); + }); + + const result = await executePollAction({ + ctx: { + cfg: {}, + channel: "discord", + params: { + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 90, + pollPublic: true, + }, + dryRun: false, + }, + resolveCorePoll, + }); + + expect(result.handledBy).toBe("plugin"); + expect(resolveCorePoll).not.toHaveBeenCalled(); + expect(mocks.sendPoll).not.toHaveBeenCalled(); + }); + it("passes agent-scoped media local roots to plugin dispatch", async () => { mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); @@ -270,13 +298,15 @@ describe("executeSendAction", () => { accountId: "acc-1", dryRun: false, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - durationSeconds: 300, - threadId: "thread-1", - isAnonymous: true, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + durationSeconds: 300, + threadId: "thread-1", + isAnonymous: true, + }), }); expect(mocks.sendPoll).toHaveBeenCalledWith( @@ -321,11 +351,13 @@ describe("executeSendAction", () => { mode: GATEWAY_CLIENT_MODES.BACKEND, }, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - durationHours: 6, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + durationHours: 6, + }), }); expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled(); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 5d518798afa..b56fade5923 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -152,14 +152,16 @@ export async function executeSendAction(params: { export async function executePollAction(params: { ctx: OutboundSendContext; - to: string; - question: string; - options: string[]; - maxSelections: number; - durationSeconds?: number; - durationHours?: number; - threadId?: string; - isAnonymous?: boolean; + resolveCorePoll: () => { + to: string; + question: string; + options: string[]; + maxSelections: number; + durationSeconds?: number; + durationHours?: number; + threadId?: string; + isAnonymous?: boolean; + }; }): Promise<{ handledBy: "plugin" | "core"; payload: unknown; @@ -174,19 +176,20 @@ export async function executePollAction(params: { return pluginHandled; } + const corePoll = params.resolveCorePoll(); const result: MessagePollResult = await sendPoll({ cfg: params.ctx.cfg, - to: params.to, - question: params.question, - options: params.options, - maxSelections: params.maxSelections, - durationSeconds: params.durationSeconds ?? undefined, - durationHours: params.durationHours ?? undefined, + to: corePoll.to, + question: corePoll.question, + options: corePoll.options, + maxSelections: corePoll.maxSelections, + durationSeconds: corePoll.durationSeconds ?? undefined, + durationHours: corePoll.durationHours ?? undefined, channel: params.ctx.channel, accountId: params.ctx.accountId ?? undefined, - threadId: params.threadId ?? undefined, + threadId: corePoll.threadId ?? undefined, silent: params.ctx.silent ?? undefined, - isAnonymous: params.isAnonymous ?? undefined, + isAnonymous: corePoll.isAnonymous ?? undefined, dryRun: params.ctx.dryRun, gateway: params.ctx.gateway, }); From d8b95d2315eb54a6163f83185ad14f00d3aa73be Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:34:33 +0000 Subject: [PATCH 197/393] Polls: scope Telegram poll extras to plugin schema --- src/agents/tools/message-tool.ts | 7 ++---- src/channels/plugins/message-tool-schema.ts | 1 - src/poll-params.ts | 25 ++++++++++++++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 77703d8ee75..6c1718fd8eb 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -18,7 +18,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; -import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; +import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -197,11 +197,8 @@ function buildPollSchema() { ), ), }; - for (const name of POLL_CREATION_PARAM_NAMES) { + for (const name of SHARED_POLL_CREATION_PARAM_NAMES) { const def = POLL_CREATION_PARAM_DEFS[name]; - if (def.telegramOnly) { - continue; - } switch (def.kind) { case "string": props[name] = Type.Optional(Type.String()); diff --git a/src/channels/plugins/message-tool-schema.ts b/src/channels/plugins/message-tool-schema.ts index 790b2118ee9..008fdf08f81 100644 --- a/src/channels/plugins/message-tool-schema.ts +++ b/src/channels/plugins/message-tool-schema.ts @@ -153,7 +153,6 @@ export function createSlackMessageToolBlocksSchema(): TSchema { export function createTelegramPollExtraToolSchemas(): Record { return { - pollDurationHours: Type.Optional(Type.Number()), pollDurationSeconds: Type.Optional(Type.Number()), pollAnonymous: Type.Optional(Type.Boolean()), pollPublic: Type.Optional(Type.Boolean()), diff --git a/src/poll-params.ts b/src/poll-params.ts index f6fc5546548..cc78fadbe73 100644 --- a/src/poll-params.ts +++ b/src/poll-params.ts @@ -4,22 +4,37 @@ export type PollCreationParamKind = "string" | "stringArray" | "number" | "boole export type PollCreationParamDef = { kind: PollCreationParamKind; - telegramOnly?: boolean; }; -export const POLL_CREATION_PARAM_DEFS: Record = { +const SHARED_POLL_CREATION_PARAM_DEFS = { pollQuestion: { kind: "string" }, pollOption: { kind: "stringArray" }, pollDurationHours: { kind: "number" }, pollMulti: { kind: "boolean" }, - pollDurationSeconds: { kind: "number", telegramOnly: true }, - pollAnonymous: { kind: "boolean", telegramOnly: true }, - pollPublic: { kind: "boolean", telegramOnly: true }, +} satisfies Record; + +const TELEGRAM_POLL_CREATION_PARAM_DEFS = { + pollDurationSeconds: { kind: "number" }, + pollAnonymous: { kind: "boolean" }, + pollPublic: { kind: "boolean" }, +} satisfies Record; + +export const POLL_CREATION_PARAM_DEFS: Record = { + ...SHARED_POLL_CREATION_PARAM_DEFS, + ...TELEGRAM_POLL_CREATION_PARAM_DEFS, }; +export type SharedPollCreationParamName = keyof typeof SHARED_POLL_CREATION_PARAM_DEFS; +export type TelegramPollCreationParamName = keyof typeof TELEGRAM_POLL_CREATION_PARAM_DEFS; export type PollCreationParamName = keyof typeof POLL_CREATION_PARAM_DEFS; export const POLL_CREATION_PARAM_NAMES = Object.keys(POLL_CREATION_PARAM_DEFS); +export const SHARED_POLL_CREATION_PARAM_NAMES = Object.keys( + SHARED_POLL_CREATION_PARAM_DEFS, +) as SharedPollCreationParamName[]; +export const TELEGRAM_POLL_CREATION_PARAM_NAMES = Object.keys( + TELEGRAM_POLL_CREATION_PARAM_DEFS, +) as TelegramPollCreationParamName[]; function readPollParamRaw(params: Record, key: string): unknown { return readSnakeCaseParamRaw(params, key); From 01ae1601083d1a1a71b58d1b0c9482f5a89bbf6d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 02:40:59 +0000 Subject: [PATCH 198/393] chore: checkpoint ci triage --- docs/.generated/config-baseline.json | 233 +++++++++++++++++- docs/.generated/config-baseline.jsonl | 31 ++- extensions/chutes/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 59 +++-- .../contracts/inbound.contract.test.ts | 74 ++---- .../contracts/catalog.contract.test.ts | 14 +- .../contracts/runtime.contract.test.ts | 12 +- src/plugins/contracts/wizard.contract.test.ts | 5 +- 9 files changed, 338 insertions(+), 94 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index dabe2cf9837..7229f7e07cc 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -23200,6 +23200,56 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.maxRetries", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.timeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.mattermost.accounts.*.dmPolicy", "kind": "channel", @@ -23709,6 +23759,56 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.mattermost.dmChannelRetry", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.mattermost.dmChannelRetry.initialDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.dmChannelRetry.maxDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.dmChannelRetry.maxRetries", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.dmChannelRetry.timeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.mattermost.dmPolicy", "kind": "channel", @@ -37601,12 +37701,13 @@ "path": "channels.zalouser.accounts.*.groupPolicy", "kind": "channel", "type": "string", - "required": false, + "required": true, "enumValues": [ "open", "disabled", "allowlist" ], + "defaultValue": "allowlist", "deprecated": false, "sensitive": false, "tags": [], @@ -37903,12 +38004,13 @@ "path": "channels.zalouser.groupPolicy", "kind": "channel", "type": "string", - "required": false, + "required": true, "enumValues": [ "open", "disabled", "allowlist" ], + "defaultValue": "allowlist", "deprecated": false, "sensitive": false, "tags": [], @@ -39699,7 +39801,7 @@ "network" ], "label": "Control UI Allowed Origins", - "help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.", + "help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.", "hasChildren": true }, { @@ -41038,7 +41140,7 @@ "access" ], "label": "Hooks Allowed Agent IDs", - "help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.", + "help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.", "hasChildren": true }, { @@ -42156,7 +42258,7 @@ "security" ], "label": "Hooks Auth Token", - "help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", + "help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", "hasChildren": false }, { @@ -46269,6 +46371,127 @@ "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", "hasChildren": false }, + { + "path": "plugins.entries.chutes", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/chutes-provider", + "help": "OpenClaw Chutes.ai provider plugin (plugin: chutes)", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/chutes-provider Config", + "help": "Plugin-defined config payload for chutes.", + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/chutes-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.cloudflare-ai-gateway", "kind": "plugin", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 7e76ecdcd3a..fb570a6e18a 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5476} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2086,6 +2086,11 @@ {"recordType":"path","path":"channels.mattermost.accounts.*.commands.nativeSkills","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2130,6 +2135,11 @@ {"recordType":"path","path":"channels.mattermost.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Config Writes","help":"Allow Mattermost to write config in response to channel events/commands (default: true).","hasChildren":false} {"recordType":"path","path":"channels.mattermost.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -3398,7 +3408,7 @@ {"recordType":"path","path":"channels.zalouser.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3426,7 +3436,7 @@ {"recordType":"path","path":"channels.zalouser.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3563,7 +3573,7 @@ {"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false} {"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false} {"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true} -{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true} +{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.","hasChildren":true} {"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"gateway.controlUi.allowInsecureAuth","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","advanced","network","security"],"label":"Insecure Control UI Auth Toggle","help":"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.","hasChildren":false} {"recordType":"path","path":"gateway.controlUi.basePath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network","storage"],"label":"Control UI Base Path","help":"Optional URL prefix where the Control UI is served (e.g. /openclaw).","hasChildren":false} @@ -3667,7 +3677,7 @@ {"recordType":"path","path":"gateway.trustedProxies","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Trusted Proxy CIDRs","help":"CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.","hasChildren":true} {"recordType":"path","path":"gateway.trustedProxies.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"hooks","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks","help":"Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.","hasChildren":true} -{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.","hasChildren":true} +{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.","hasChildren":true} {"recordType":"path","path":"hooks.allowedAgentIds.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"hooks.allowedSessionKeyPrefixes","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","storage"],"label":"Hooks Allowed Session Key Prefixes","help":"Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.","hasChildren":true} {"recordType":"path","path":"hooks.allowedSessionKeyPrefixes.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3754,7 +3764,7 @@ {"recordType":"path","path":"hooks.path","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Endpoint Path","help":"HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.","hasChildren":false} {"recordType":"path","path":"hooks.presets","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks Presets","help":"Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.","hasChildren":true} {"recordType":"path","path":"hooks.presets.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false} +{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false} {"recordType":"path","path":"hooks.transformsDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Transforms Directory","help":"Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.","hasChildren":false} {"recordType":"path","path":"logging","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Logging","help":"Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.","hasChildren":true} {"recordType":"path","path":"logging.consoleLevel","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Console Log Level","help":"Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.","hasChildren":false} @@ -4093,6 +4103,15 @@ {"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} {"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider","help":"OpenClaw Chutes.ai provider plugin (plugin: chutes)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider Config","help":"Plugin-defined config payload for chutes.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/chutes-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false} diff --git a/extensions/chutes/package.json b/extensions/chutes/package.json index be860172a27..38f45fe3e54 100644 --- a/extensions/chutes/package.json +++ b/extensions/chutes/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/chutes-provider", - "version": "2026.3.17", + "version": "2026.3.14", "private": true, "description": "OpenClaw Chutes.ai provider plugin", "type": "module", diff --git a/package.json b/package.json index 32f107da7cc..0fecff9952d 100644 --- a/package.json +++ b/package.json @@ -710,7 +710,7 @@ "overrides": { "hono": "4.12.7", "@hono/node-server": "1.19.10", - "fast-xml-parser": "5.3.8", + "fast-xml-parser": "5.5.6", "request": "npm:@cypress/request@3.0.10", "request-promise": "npm:@cypress/request-promise@5.0.0", "file-type": "21.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46365a29362..a7e2219fa5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: hono: 4.12.7 '@hono/node-server': 1.19.10 - fast-xml-parser: 5.3.8 + fast-xml-parser: 5.5.6 request: npm:@cypress/request@3.0.10 request-promise: npm:@cypress/request-promise@5.0.0 file-type: 21.3.2 @@ -1411,8 +1411,8 @@ packages: peerDependencies: hono: 4.12.7 - '@huggingface/jinja@0.5.5': - resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} + '@huggingface/jinja@0.5.6': + resolution: {integrity: sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==} engines: {node: '>=18'} '@img/colour@1.0.0': @@ -3823,8 +3823,8 @@ packages: audio-decode@2.2.3: resolution: {integrity: sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==} - audio-type@2.2.1: - resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==} + audio-type@2.4.0: + resolution: {integrity: sha512-ugYMgxLpH6gyWUhFWFl2HCJboFL5z/GoqSdonx8ZycfNP8JDHBhRNzYWzrCRa/6htOWfvJAq7qpRloxvx06sRA==} engines: {node: '>=14'} aws-sign2@0.7.0: @@ -4504,8 +4504,11 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-parser@5.3.8: - resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.6: + resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==} hasBin: true fastq@1.20.1: @@ -5426,8 +5429,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.6: - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + nanoid@5.1.7: + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} engines: {node: ^18 || >=20} hasBin: true @@ -5717,6 +5720,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-expression-matcher@1.1.3: + resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -6206,8 +6213,8 @@ packages: peerDependencies: signal-polyfill: ^0.2.0 - simple-git@3.32.3: - resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} + simple-git@3.33.0: + resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} simple-yenc@1.0.4: resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==} @@ -7788,13 +7795,13 @@ snapshots: '@aws-sdk/xml-builder@3.972.11': dependencies: '@smithy/types': 4.13.1 - fast-xml-parser: 5.3.8 + fast-xml-parser: 5.5.6 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.8': dependencies: '@smithy/types': 4.13.0 - fast-xml-parser: 5.3.8 + fast-xml-parser: 5.5.6 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.3': {} @@ -8244,7 +8251,7 @@ snapshots: dependencies: hono: 4.12.7 - '@huggingface/jinja@0.5.5': {} + '@huggingface/jinja@0.5.6': {} '@img/colour@1.0.0': {} @@ -10942,14 +10949,14 @@ snapshots: '@wasm-audio-decoders/flac': 0.2.10 '@wasm-audio-decoders/ogg-vorbis': 0.1.20 audio-buffer: 5.0.0 - audio-type: 2.2.1 + audio-type: 2.4.0 mpg123-decoder: 1.0.3 node-wav: 0.0.2 ogg-opus-decoder: 1.7.3 qoa-format: 1.0.1 optional: true - audio-type@2.2.1: + audio-type@2.4.0: optional: true aws-sign2@0.7.0: {} @@ -11667,8 +11674,14 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-parser@5.3.8: + fast-xml-builder@1.1.4: dependencies: + path-expression-matcher: 1.1.3 + + fast-xml-parser@5.5.6: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.1.3 strnum: 2.2.0 fastq@1.20.1: @@ -12681,7 +12694,7 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.6: {} + nanoid@5.1.7: {} negotiator@0.6.3: {} @@ -12717,7 +12730,7 @@ snapshots: node-llama-cpp@3.16.2(typescript@5.9.3): dependencies: - '@huggingface/jinja': 0.5.5 + '@huggingface/jinja': 0.5.6 async-retry: 1.3.3 bytes: 3.1.2 chalk: 5.6.2 @@ -12732,14 +12745,14 @@ snapshots: is-unicode-supported: 2.1.0 lifecycle-utils: 3.1.1 log-symbols: 7.0.1 - nanoid: 5.1.6 + nanoid: 5.1.7 node-addon-api: 8.6.0 octokit: 5.0.5 ora: 9.3.0 pretty-ms: 9.3.0 proper-lockfile: 4.1.2 semver: 7.7.4 - simple-git: 3.32.3 + simple-git: 3.33.0 slice-ansi: 8.0.0 stdout-update: 4.0.1 strip-ansi: 7.2.0 @@ -13109,6 +13122,8 @@ snapshots: partial-json@0.1.7: {} + path-expression-matcher@1.1.3: {} + path-is-absolute@1.0.1: optional: true @@ -13727,7 +13742,7 @@ snapshots: dependencies: signal-polyfill: 0.2.2 - simple-git@3.32.3: + simple-git@3.33.0: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts index f4f3ffa0a87..4c036ad6cd2 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -1,42 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; -import type { MsgContext } from "../../../auto-reply/templating.js"; import type { OpenClawConfig } from "../../../config/config.js"; -import { inboundCtxCapture } from "./inbound-testkit.js"; import { expectChannelInboundContextContract } from "./suites.js"; -const dispatchInboundMessageMock = vi.hoisted(() => - vi.fn( - async (params: { - ctx: MsgContext; - replyOptions?: { onReplyStart?: () => void | Promise }; - }) => { - await Promise.resolve(params.replyOptions?.onReplyStart?.()); - return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; - }, - ), -); - -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchInboundMessage: vi.fn(async (params: { ctx: MsgContext }) => { - inboundCtxCapture.ctx = params.ctx; - return await dispatchInboundMessageMock(params); - }), - dispatchInboundMessageWithDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { - inboundCtxCapture.ctx = params.ctx; - return await dispatchInboundMessageMock(params); - }), - dispatchInboundMessageWithBufferedDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { - inboundCtxCapture.ctx = params.ctx; - return await dispatchInboundMessageMock(params); - }), - }; -}); - vi.mock("../../../../extensions/signal/src/send.js", () => ({ sendMessageSignal: vi.fn(), sendTypingSignal: vi.fn(async () => true), @@ -62,10 +29,6 @@ vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => deliverWebReply: vi.fn(async () => {}), })); -const { processDiscordMessage } = - await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); -const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = - await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const { prepareSlackMessage } = await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); @@ -100,22 +63,31 @@ function createSlackMessage(overrides: Partial): SlackMessage } describe("channel inbound contract", () => { - beforeEach(() => { - inboundCtxCapture.ctx = undefined; - dispatchInboundMessageMock.mockClear(); - }); - it("keeps Discord inbound context finalized", async () => { - const messageCtx = await createBaseDiscordMessageContext({ - cfg: { messages: {} }, - ackReactionScope: "direct", - ...createDiscordDirectMessageContextOverrides(), + const ctx = finalizeInboundContext({ + Body: "Alice: hi", + BodyForAgent: "hi", + RawBody: "hi", + CommandBody: "hi", + BodyForCommands: "hi", + From: "discord:U1", + To: "channel:c1", + SessionKey: "agent:main:discord:direct:u1", + AccountId: "default", + ChatType: "direct", + ConversationLabel: "Alice", + SenderName: "Alice", + SenderId: "U1", + SenderUsername: "alice", + Provider: "discord", + Surface: "discord", + MessageSid: "m1", + OriginatingChannel: "discord", + OriginatingTo: "channel:c1", + CommandAuthorized: true, }); - await processDiscordMessage(messageCtx); - - expect(inboundCtxCapture.ctx).toBeTruthy(); - expectChannelInboundContextContract(inboundCtxCapture.ctx!); + expectChannelInboundContextContract(ctx); }); it("keeps Signal inbound context finalized", async () => { diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index a87e632ac45..0d12217be26 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -4,11 +4,6 @@ import { expectCodexBuiltInSuppression, expectCodexMissingAuthHint, } from "../provider-runtime.test-support.js"; -import { - resolveProviderContractPluginIdsForProvider, - resolveProviderContractProvidersForPluginIds, - uniqueProviderContractProviders, -} from "./registry.js"; type ResolvePluginProviders = typeof import("../providers.js").resolvePluginProviders; type ResolveOwningPluginIdsForProvider = @@ -16,6 +11,10 @@ type ResolveOwningPluginIdsForProvider = type ResolveNonBundledProviderPluginIds = typeof import("../providers.js").resolveNonBundledProviderPluginIds; +let resolveProviderContractPluginIdsForProvider: typeof import("./registry.js").resolveProviderContractPluginIdsForProvider; +let resolveProviderContractProvidersForPluginIds: typeof import("./registry.js").resolveProviderContractProvidersForPluginIds; +let uniqueProviderContractProviders: typeof import("./registry.js").uniqueProviderContractProviders; + const resolvePluginProvidersMock = vi.hoisted(() => vi.fn((_) => uniqueProviderContractProviders), ); @@ -44,6 +43,11 @@ let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.j describe("provider catalog contract", () => { beforeEach(async () => { vi.resetModules(); + ({ + resolveProviderContractPluginIdsForProvider, + resolveProviderContractProvidersForPluginIds, + uniqueProviderContractProviders, + } = await import("./registry.js")); ({ augmentModelCatalogWithProviderPlugins, buildProviderMissingAuthMessageWithPlugin, diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 4009d31886a..1afd8356fd9 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -1,10 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; import type { ProviderRuntimeModel } from "../types.js"; -import { requireProviderContractProvider } from "./registry.js"; const getOAuthApiKeyMock = vi.hoisted(() => vi.fn()); const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn()); @@ -21,6 +20,8 @@ vi.mock("../../providers/qwen-portal-oauth.js", () => ({ refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, })); +let requireProviderContractProvider: typeof import("./registry.js").requireProviderContractProvider; + function createModel(overrides: Partial & Pick) { return { id: overrides.id, @@ -37,6 +38,13 @@ function createModel(overrides: Partial & Pick { + beforeEach(async () => { + vi.resetModules(); + ({ requireProviderContractProvider } = await import("./registry.js")); + getOAuthApiKeyMock.mockReset(); + refreshQwenPortalCredentialsMock.mockReset(); + }); + describe("anthropic", () => { it("owns anthropic 4.6 forward-compat resolution", () => { const provider = requireProviderContractProvider("anthropic"); diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 1e0ca6e49be..934a42ce59a 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin } from "../types.js"; -import { providerContractPluginIds, uniqueProviderContractProviders } from "./registry.js"; const resolvePluginProvidersMock = vi.fn(); @@ -9,9 +8,11 @@ vi.mock("../providers.js", () => ({ })); let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice; +let providerContractPluginIds: typeof import("./registry.js").providerContractPluginIds; let resolveProviderModelPickerEntries: typeof import("../provider-wizard.js").resolveProviderModelPickerEntries; let resolveProviderPluginChoice: typeof import("../provider-wizard.js").resolveProviderPluginChoice; let resolveProviderWizardOptions: typeof import("../provider-wizard.js").resolveProviderWizardOptions; +let uniqueProviderContractProviders: typeof import("./registry.js").uniqueProviderContractProviders; function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) { const values: string[] = []; @@ -72,6 +73,8 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { describe("provider wizard contract", () => { beforeEach(async () => { vi.resetModules(); + ({ providerContractPluginIds, uniqueProviderContractProviders } = + await import("./registry.js")); ({ buildProviderPluginMethodChoice, resolveProviderModelPickerEntries, From 841b1a59d7c0d2ab7677de8d65e313ddcebc4b8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 19:40:56 -0700 Subject: [PATCH 199/393] docs: unify unreleased changelog sections --- CHANGELOG.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4592c1ae307..a3231ee56d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,12 +40,6 @@ Docs: https://docs.openclaw.ai - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. -### Breaking - -- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. -- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. -- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. - ### Fixes - Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. @@ -128,9 +122,6 @@ Docs: https://docs.openclaw.ai - Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr. - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. - Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. - -### Fixes - - Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus. - Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc. - macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67. @@ -141,6 +132,12 @@ Docs: https://docs.openclaw.ai - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. +### Breaking + +- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. +- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. +- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. + ## 2026.3.13 ### Changes From 6f060d7e6c2b3fcbe8574d0948f0f5731d0f53a1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 19:43:15 -0700 Subject: [PATCH 200/393] Deps: bump fast-xml-parser audit override (#49367) * Deps: bump fast-xml-parser audit override * Changelog: note fast-xml-parser audit fix [skip ci] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3231ee56d7..21db6c873bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,6 +215,7 @@ Docs: https://docs.openclaw.ai - Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) - Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy. - Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar. +- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc. - Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen. ## 2026.3.12 From 44521d6b20e31d4e48b4acbf7762081684256a09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 02:44:24 +0000 Subject: [PATCH 201/393] test: stabilize plugin contract mocks --- .../contracts/catalog.contract.test.ts | 32 +++++++------ .../contracts/runtime.contract.test.ts | 47 +++++++++++++++++-- src/plugins/contracts/wizard.contract.test.ts | 12 ++--- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 0d12217be26..4b775bd8061 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -27,14 +27,6 @@ const resolveNonBundledProviderPluginIdsMock = vi.hoisted(() => vi.fn((_) => [] as string[]), ); -vi.mock("../providers.js", () => ({ - resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), - resolveOwningPluginIdsForProvider: (params: unknown) => - resolveOwningPluginIdsForProviderMock(params as never), - resolveNonBundledProviderPluginIds: (params: unknown) => - resolveNonBundledProviderPluginIdsMock(params as never), -})); - let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins; let buildProviderMissingAuthMessageWithPlugin: typeof import("../provider-runtime.js").buildProviderMissingAuthMessageWithPlugin; let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest; @@ -43,18 +35,12 @@ let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.j describe("provider catalog contract", () => { beforeEach(async () => { vi.resetModules(); + vi.doUnmock("../providers.js"); ({ resolveProviderContractPluginIdsForProvider, resolveProviderContractProvidersForPluginIds, uniqueProviderContractProviders, } = await import("./registry.js")); - ({ - augmentModelCatalogWithProviderPlugins, - buildProviderMissingAuthMessageWithPlugin, - resetProviderRuntimeHookCacheForTest, - resolveProviderBuiltInModelSuppression, - } = await import("../provider-runtime.js")); - resetProviderRuntimeHookCacheForTest(); resolveOwningPluginIdsForProviderMock.mockReset(); resolveOwningPluginIdsForProviderMock.mockImplementation((params) => @@ -72,6 +58,22 @@ describe("provider catalog contract", () => { } return resolveProviderContractProvidersForPluginIds(onlyPluginIds); }); + + vi.doMock("../providers.js", () => ({ + resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), + })); + + ({ + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, + resetProviderRuntimeHookCacheForTest, + resolveProviderBuiltInModelSuppression, + } = await import("../provider-runtime.js")); + resetProviderRuntimeHookCacheForTest(); }); it("keeps codex-only missing-auth hints wired through the provider runtime", () => { diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 1afd8356fd9..385bfe8a3bd 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; +import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; import type { ProviderRuntimeModel } from "../types.js"; const getOAuthApiKeyMock = vi.hoisted(() => vi.fn()); @@ -16,11 +18,17 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => { }; }); -vi.mock("../../providers/qwen-portal-oauth.js", () => ({ - refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, -})); +vi.mock("openclaw/plugin-sdk/qwen-portal-auth", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/qwen-portal-auth"); + return { + ...actual, + refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, + }; +}); -let requireProviderContractProvider: typeof import("./registry.js").requireProviderContractProvider; +let requireBundledProviderContractProvider: typeof import("./registry.js").requireProviderContractProvider; +let openAIPlugin: (typeof import("../../../extensions/openai/index.js"))["default"]; +let qwenPortalPlugin: (typeof import("../../../extensions/qwen-portal-auth/index.js"))["default"]; function createModel(overrides: Partial & Pick) { return { @@ -37,10 +45,39 @@ function createModel(overrides: Partial & Pick) { + const captured = createCapturedPluginRegistration(); + for (const plugin of plugins) { + plugin.register(captured.api); + } + return captured.providers; +} + +function requireProvider(providers: ProviderPlugin[], providerId: string) { + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + throw new Error(`provider ${providerId} missing`); + } + return provider; +} + +function requireProviderContractProvider(providerId: string): ProviderPlugin { + if (providerId === "openai-codex") { + return requireProvider(registerProviders(openAIPlugin), providerId); + } + if (providerId === "qwen-portal") { + return requireProvider(registerProviders(qwenPortalPlugin), providerId); + } + return requireBundledProviderContractProvider(providerId); +} + describe("provider runtime contract", () => { beforeEach(async () => { vi.resetModules(); - ({ requireProviderContractProvider } = await import("./registry.js")); + ({ requireProviderContractProvider: requireBundledProviderContractProvider } = + await import("./registry.js")); + openAIPlugin = (await import("../../../extensions/openai/index.js")).default; + qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; getOAuthApiKeyMock.mockReset(); refreshQwenPortalCredentialsMock.mockReset(); }); diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 934a42ce59a..6e97556d91e 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -3,10 +3,6 @@ import type { ProviderPlugin } from "../types.js"; const resolvePluginProvidersMock = vi.fn(); -vi.mock("../providers.js", () => ({ - resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), -})); - let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice; let providerContractPluginIds: typeof import("./registry.js").providerContractPluginIds; let resolveProviderModelPickerEntries: typeof import("../provider-wizard.js").resolveProviderModelPickerEntries; @@ -73,16 +69,20 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { describe("provider wizard contract", () => { beforeEach(async () => { vi.resetModules(); + vi.doUnmock("../providers.js"); ({ providerContractPluginIds, uniqueProviderContractProviders } = await import("./registry.js")); + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); + vi.doMock("../providers.js", () => ({ + resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), + })); ({ buildProviderPluginMethodChoice, resolveProviderModelPickerEntries, resolveProviderPluginChoice, resolveProviderWizardOptions, } = await import("../provider-wizard.js")); - resolvePluginProvidersMock.mockReset(); - resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); }); it("exposes every registered provider setup choice through the shared wizard layer", () => { From b942dacf488553e2d466b3fd7e22e579e9716101 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:41:52 +0000 Subject: [PATCH 202/393] Sessions: move session target shaping to plugins --- extensions/discord/src/channel.ts | 1 + extensions/slack/src/channel.ts | 1 + .../tools/sessions-send-helpers.test.ts | 100 ++++++++++++++++++ src/agents/tools/sessions-send-helpers.ts | 22 ++-- src/channels/plugins/types.core.ts | 5 + 5 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 src/agents/tools/sessions-send-helpers.test.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 58076e1e67d..a99ba1c3e0c 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -360,6 +360,7 @@ export const discordPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, + resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`), parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw), inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType, buildCrossContextComponents: buildDiscordCrossContextComponents, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5e25f0187b1..3b346c07d48 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -417,6 +417,7 @@ export const slackPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, + resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`), parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw), inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType, resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params), diff --git a/src/agents/tools/sessions-send-helpers.test.ts b/src/agents/tools/sessions-send-helpers.test.ts new file mode 100644 index 00000000000..400c4dba2f5 --- /dev/null +++ b/src/agents/tools/sessions-send-helpers.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; + +describe("resolveAnnounceTargetFromKey", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "slack", + source: "test", + plugin: { + id: "slack", + meta: { + id: "slack", + label: "Slack", + selectionLabel: "Slack", + docsPath: "/channels/slack", + blurb: "Slack test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "telegram", + source: "test", + plugin: { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test stub.", + }, + capabilities: { chatTypes: ["direct", "group", "thread"] }, + messaging: { + normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]), + ); + }); + + it("lets plugins own session-derived target shapes", () => { + expect(resolveAnnounceTargetFromKey("agent:main:discord:group:dev")).toEqual({ + channel: "discord", + to: "channel:dev", + threadId: undefined, + }); + expect(resolveAnnounceTargetFromKey("agent:main:slack:group:C123")).toEqual({ + channel: "slack", + to: "channel:C123", + threadId: undefined, + }); + }); + + it("keeps generic topic extraction and plugin normalization for other channels", () => { + expect(resolveAnnounceTargetFromKey("agent:main:telegram:group:-100123:topic:99")).toEqual({ + channel: "telegram", + to: "-100123", + threadId: "99", + }); + }); +}); diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index d987932bb60..edec41f6f1d 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -51,21 +51,17 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget } const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw); const channel = normalizedChannel ?? channelRaw.toLowerCase(); - const kindTarget = (() => { - if (!normalizedChannel) { - return id; - } - if (normalizedChannel === "discord" || normalizedChannel === "slack") { - return `channel:${id}`; - } - return kind === "channel" ? `channel:${id}` : `group:${id}`; - })(); - const normalized = normalizedChannel - ? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget) - : undefined; + const plugin = normalizedChannel ? getChannelPlugin(normalizedChannel) : null; + const genericTarget = kind === "channel" ? `channel:${id}` : `group:${id}`; + const normalized = + plugin?.messaging?.resolveSessionTarget?.({ + kind, + id, + threadId, + }) ?? plugin?.messaging?.normalizeTarget?.(genericTarget); return { channel, - to: normalized ?? kindTarget, + to: normalized ?? (normalizedChannel ? genericTarget : id), threadId, }; } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 24c5c96708e..15b66bd6456 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -382,6 +382,11 @@ export type ChannelThreadingToolContext = { export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + resolveSessionTarget?: (params: { + kind: "group" | "channel"; + id: string; + threadId?: string | null; + }) => string | undefined; parseExplicitTarget?: (params: { raw: string }) => { to: string; threadId?: string | number; From 1313767825cb06f29305e53bc89ff91ee4ddf28f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 19:45:24 -0700 Subject: [PATCH 203/393] refactor: enforce plugin boundary seams --- extensions/discord/src/actions/runtime.guild.ts | 4 ++-- .../discord/src/actions/runtime.messaging.ts | 12 ++++++------ .../src/actions/runtime.moderation-shared.ts | 2 +- .../discord/src/actions/runtime.moderation.ts | 4 ++-- .../discord/src/actions/runtime.presence.ts | 4 ++-- extensions/discord/src/actions/runtime.shared.ts | 2 +- extensions/discord/src/actions/runtime.ts | 3 +-- extensions/line/api.ts | 3 +-- extensions/line/setup-api.ts | 2 ++ extensions/line/src/setup-core.ts | 14 +++++++++----- extensions/line/src/setup-surface.ts | 4 ++-- extensions/nostr/api.ts | 2 +- extensions/nostr/setup-api.ts | 1 + extensions/slack/src/action-runtime.ts | 6 +++--- extensions/synology-chat/api.ts | 2 +- extensions/synology-chat/setup-api.ts | 1 + extensions/telegram/src/action-runtime.ts | 8 ++++---- extensions/tlon/api.ts | 3 +-- extensions/tlon/setup-api.ts | 2 ++ .../whatsapp/src/action-runtime-target-auth.ts | 8 +++++--- extensions/whatsapp/src/action-runtime.ts | 4 ++-- package.json | 4 ++++ scripts/lib/plugin-sdk-entrypoints.json | 1 + src/channels/plugins/contracts/registry.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 11 +++++++++++ src/plugin-sdk/discord-core.ts | 14 ++++++++++++++ src/plugin-sdk/line-core.ts | 16 ++++++++++++++++ src/plugin-sdk/line.ts | 3 +-- src/plugin-sdk/nostr.ts | 2 +- src/plugin-sdk/slack-core.ts | 9 +++++++++ src/plugin-sdk/synology-chat.ts | 2 +- src/plugin-sdk/telegram-core.ts | 10 ++++++++++ src/plugin-sdk/tlon.ts | 3 +-- src/plugin-sdk/whatsapp-core.ts | 9 +++++++++ 34 files changed, 129 insertions(+), 48 deletions(-) create mode 100644 extensions/line/setup-api.ts create mode 100644 extensions/nostr/setup-api.ts create mode 100644 extensions/synology-chat/setup-api.ts create mode 100644 extensions/tlon/setup-api.ts create mode 100644 src/plugin-sdk/line-core.ts diff --git a/extensions/discord/src/actions/runtime.guild.ts b/extensions/discord/src/actions/runtime.guild.ts index 5b3ed54dc83..71618b073d8 100644 --- a/extensions/discord/src/actions/runtime.guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -6,8 +6,8 @@ import { readNumberParam, readStringArrayParam, readStringParam, -} from "../../../../src/agents/tools/common.js"; -import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; + type DiscordActionConfig, +} from "openclaw/plugin-sdk/discord-core"; import { getPresence } from "../monitor/presence-cache.js"; import { addRoleDiscord, diff --git a/extensions/discord/src/actions/runtime.messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts index 92ef443cf44..d5cf900207b 100644 --- a/extensions/discord/src/actions/runtime.messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -1,18 +1,18 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; -import { withNormalizedTimestamp } from "../../../../src/agents/date-time.js"; -import { assertMediaNotDataUrl } from "../../../../src/agents/sandbox-paths.js"; import { type ActionGate, + assertMediaNotDataUrl, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringParam, -} from "../../../../src/agents/tools/common.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; -import { resolvePollMaxSelections } from "../../../../src/polls.js"; + resolvePollMaxSelections, + type DiscordActionConfig, + type OpenClawConfig, + withNormalizedTimestamp, +} from "openclaw/plugin-sdk/discord-core"; import { readDiscordComponentSpec } from "../components.js"; import { createThreadDiscord, diff --git a/extensions/discord/src/actions/runtime.moderation-shared.ts b/extensions/discord/src/actions/runtime.moderation-shared.ts index 7b6ef95d8f1..9fd7334fce6 100644 --- a/extensions/discord/src/actions/runtime.moderation-shared.ts +++ b/extensions/discord/src/actions/runtime.moderation-shared.ts @@ -1,5 +1,5 @@ import { PermissionFlagsBits } from "discord-api-types/v10"; -import { readNumberParam, readStringParam } from "../../../../src/agents/tools/common.js"; +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/discord-core"; export type DiscordModerationAction = "timeout" | "kick" | "ban"; diff --git a/extensions/discord/src/actions/runtime.moderation.ts b/extensions/discord/src/actions/runtime.moderation.ts index 3278daa6532..f44a39082b9 100644 --- a/extensions/discord/src/actions/runtime.moderation.ts +++ b/extensions/discord/src/actions/runtime.moderation.ts @@ -3,8 +3,8 @@ import { type ActionGate, jsonResult, readStringParam, -} from "../../../../src/agents/tools/common.js"; -import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; + type DiscordActionConfig, +} from "openclaw/plugin-sdk/discord-core"; import { banMemberDiscord, hasAnyGuildPermissionDiscord, diff --git a/extensions/discord/src/actions/runtime.presence.ts b/extensions/discord/src/actions/runtime.presence.ts index 6d3a9f15bc2..6691f214c98 100644 --- a/extensions/discord/src/actions/runtime.presence.ts +++ b/extensions/discord/src/actions/runtime.presence.ts @@ -4,8 +4,8 @@ import { type ActionGate, jsonResult, readStringParam, -} from "../../../../src/agents/tools/common.js"; -import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; + type DiscordActionConfig, +} from "openclaw/plugin-sdk/discord-core"; import { getGateway } from "../monitor/gateway-registry.js"; const ACTIVITY_TYPE_MAP: Record = { diff --git a/extensions/discord/src/actions/runtime.shared.ts b/extensions/discord/src/actions/runtime.shared.ts index bd2ce7a08d6..0f5b0bb7152 100644 --- a/extensions/discord/src/actions/runtime.shared.ts +++ b/extensions/discord/src/actions/runtime.shared.ts @@ -1,4 +1,4 @@ -import { readStringParam } from "../../../../src/agents/tools/common.js"; +import { readStringParam } from "openclaw/plugin-sdk/discord-core"; export function readDiscordParentIdParam( params: Record, diff --git a/extensions/discord/src/actions/runtime.ts b/extensions/discord/src/actions/runtime.ts index 7efa5a1536f..6a8e03d99ef 100644 --- a/extensions/discord/src/actions/runtime.ts +++ b/extensions/discord/src/actions/runtime.ts @@ -1,6 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { readStringParam } from "../../../../src/agents/tools/common.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { readStringParam, type OpenClawConfig } from "openclaw/plugin-sdk/discord-core"; import { createDiscordActionGate } from "../accounts.js"; import { handleDiscordGuildAction } from "./runtime.guild.js"; import { handleDiscordMessagingAction } from "./runtime.messaging.js"; diff --git a/extensions/line/api.ts b/extensions/line/api.ts index c4150b2a242..5fdc62bdfb4 100644 --- a/extensions/line/api.ts +++ b/extensions/line/api.ts @@ -1,3 +1,2 @@ export * from "openclaw/plugin-sdk/line"; -export * from "./src/setup-core.js"; -export * from "./src/setup-surface.js"; +export * from "./setup-api.js"; diff --git a/extensions/line/setup-api.ts b/extensions/line/setup-api.ts new file mode 100644 index 00000000000..fb50302ead1 --- /dev/null +++ b/extensions/line/setup-api.ts @@ -0,0 +1,2 @@ +export { lineSetupAdapter } from "./src/setup-core.js"; +export { lineSetupWizard } from "./src/setup-surface.js"; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 95554e0a835..d6e8612cd92 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,7 +1,11 @@ -import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; -import { normalizeAccountId, resolveLineAccount } from "../../../src/line/accounts.js"; -import type { LineConfig } from "../../../src/line/types.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + resolveLineAccount, + type ChannelSetupAdapter, + type LineConfig, + type OpenClawConfig, +} from "openclaw/plugin-sdk/line-core"; const channel = "line" as const; @@ -154,4 +158,4 @@ export const lineSetupAdapter: ChannelSetupAdapter = { }, }; -export { listLineAccountIds } from "../../../src/line/accounts.js"; +export { listLineAccountIds } from "openclaw/plugin-sdk/line-core"; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 1d994ebb128..154419d7527 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,13 +1,13 @@ import { DEFAULT_ACCOUNT_ID, formatDocsLink, + resolveLineAccount, setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "openclaw/plugin-sdk/setup"; -import { resolveLineAccount } from "../../../src/line/accounts.js"; +} from "openclaw/plugin-sdk/line-core"; import { isLineConfigured, listLineAccountIds, diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts index f2914e34190..2de81f11142 100644 --- a/extensions/nostr/api.ts +++ b/extensions/nostr/api.ts @@ -1,2 +1,2 @@ export * from "openclaw/plugin-sdk/nostr"; -export * from "./src/setup-surface.js"; +export * from "./setup-api.js"; diff --git a/extensions/nostr/setup-api.ts b/extensions/nostr/setup-api.ts new file mode 100644 index 00000000000..f9824d063b4 --- /dev/null +++ b/extensions/nostr/setup-api.ts @@ -0,0 +1 @@ +export { nostrSetupAdapter, nostrSetupWizard } from "./src/setup-surface.js"; diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index deb5eb0218e..1e42eacef28 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -1,5 +1,4 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { withNormalizedTimestamp } from "../../../src/agents/date-time.js"; import { createActionGate, imageResultFromFile, @@ -7,8 +6,9 @@ import { readNumberParam, readReactionParams, readStringParam, -} from "../../../src/agents/tools/common.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; + type OpenClawConfig, + withNormalizedTimestamp, +} from "openclaw/plugin-sdk/slack-core"; import { resolveSlackAccount } from "./accounts.js"; import { deleteSlackMessage, diff --git a/extensions/synology-chat/api.ts b/extensions/synology-chat/api.ts index 1707865e258..4ff5241bd49 100644 --- a/extensions/synology-chat/api.ts +++ b/extensions/synology-chat/api.ts @@ -1,2 +1,2 @@ export * from "openclaw/plugin-sdk/synology-chat"; -export * from "./src/setup-surface.js"; +export * from "./setup-api.js"; diff --git a/extensions/synology-chat/setup-api.ts b/extensions/synology-chat/setup-api.ts new file mode 100644 index 00000000000..7166027bfea --- /dev/null +++ b/extensions/synology-chat/setup-api.ts @@ -0,0 +1 @@ +export { synologyChatSetupAdapter, synologyChatSetupWizard } from "./src/setup-surface.js"; diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index e6e56e9eb3a..f4083481026 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -7,10 +7,10 @@ import { readStringArrayParam, readStringOrNumberParam, readStringParam, -} from "../../../src/agents/tools/common.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; -import { resolvePollMaxSelections } from "../../../src/polls.js"; + resolvePollMaxSelections, + type OpenClawConfig, + type TelegramActionConfig, +} from "openclaw/plugin-sdk/telegram-core"; import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js"; import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js"; import { diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts index ca61d62ee69..bccfa85fbac 100644 --- a/extensions/tlon/api.ts +++ b/extensions/tlon/api.ts @@ -1,3 +1,2 @@ export * from "openclaw/plugin-sdk/tlon"; -export * from "./src/setup-core.js"; -export * from "./src/setup-surface.js"; +export * from "./setup-api.js"; diff --git a/extensions/tlon/setup-api.ts b/extensions/tlon/setup-api.ts new file mode 100644 index 00000000000..cf444e388fc --- /dev/null +++ b/extensions/tlon/setup-api.ts @@ -0,0 +1,2 @@ +export { tlonSetupAdapter } from "./src/setup-core.js"; +export { tlonSetupWizard } from "./src/setup-surface.js"; diff --git a/extensions/whatsapp/src/action-runtime-target-auth.ts b/extensions/whatsapp/src/action-runtime-target-auth.ts index 8686ac24261..d641e004df6 100644 --- a/extensions/whatsapp/src/action-runtime-target-auth.ts +++ b/extensions/whatsapp/src/action-runtime-target-auth.ts @@ -1,6 +1,8 @@ -import { ToolAuthorizationError } from "../../../src/agents/tools/common.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js"; +import { + ToolAuthorizationError, + resolveWhatsAppOutboundTarget, + type OpenClawConfig, +} from "openclaw/plugin-sdk/whatsapp-core"; import { resolveWhatsAppAccount } from "./accounts.js"; export function resolveAuthorizedWhatsAppOutboundTarget(params: { diff --git a/extensions/whatsapp/src/action-runtime.ts b/extensions/whatsapp/src/action-runtime.ts index 6a805440633..c6046e4eaa4 100644 --- a/extensions/whatsapp/src/action-runtime.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -4,8 +4,8 @@ import { jsonResult, readReactionParams, readStringParam, -} from "../../../src/agents/tools/common.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/whatsapp-core"; import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js"; import { sendReactionWhatsApp } from "./send.js"; diff --git a/package.json b/package.json index 0fecff9952d..2a0431f0281 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,10 @@ "types": "./dist/plugin-sdk/line.d.ts", "default": "./dist/plugin-sdk/line.js" }, + "./plugin-sdk/line-core": { + "types": "./dist/plugin-sdk/line-core.d.ts", + "default": "./dist/plugin-sdk/line-core.js" + }, "./plugin-sdk/msteams": { "types": "./dist/plugin-sdk/msteams.d.ts", "default": "./dist/plugin-sdk/msteams.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 72de88ed3ca..c59e7930ba2 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -42,6 +42,7 @@ "whatsapp", "whatsapp-core", "line", + "line-core", "msteams", "acpx", "bluebubbles", diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 8b203c9b541..134d8dddfb1 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -4,7 +4,7 @@ import { createThreadBindingManager as createDiscordThreadBindingManager, } from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; -import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js"; +import { setMatrixRuntime } from "../../../../extensions/matrix/index.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index b953d4d974a..e9a9e9b1b59 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -5,10 +5,12 @@ import { describe, expect, it } from "vitest"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const ALLOWED_EXTENSION_PUBLIC_SEAMS = new Set([ + "action-runtime.runtime.js", "api.js", "index.js", "login-qr-api.js", "runtime-api.js", + "setup-api.js", "setup-entry.js", ]); const GUARDED_CHANNEL_EXTENSIONS = new Set([ @@ -328,6 +330,15 @@ describe("channel import guardrails", () => { } }); + it("keeps extension production files off direct core src imports", () => { + for (const file of collectExtensionSourceFiles()) { + const text = readFileSync(file, "utf8"); + expect(text, `${file} should not import ../../src/* core internals directly`).not.toMatch( + /["'][^"']*(?:\.\.\/){2,}src\//, + ); + } + }); + it("keeps core production files off extension private src imports", () => { for (const file of collectCoreSourceFiles()) { const text = readFileSync(file, "utf8"); diff --git a/src/plugin-sdk/discord-core.ts b/src/plugin-sdk/discord-core.ts index 3e87e17ef42..5a3a20cea28 100644 --- a/src/plugin-sdk/discord-core.ts +++ b/src/plugin-sdk/discord-core.ts @@ -1,3 +1,17 @@ export type { ChannelPlugin } from "./channel-plugin-common.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { DiscordActionConfig } from "../config/types.js"; +export { withNormalizedTimestamp } from "../agents/date-time.js"; +export { assertMediaNotDataUrl } from "../agents/sandbox-paths.js"; +export { + type ActionGate, + jsonResult, + parseAvailableTags, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../agents/tools/common.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; +export { resolvePollMaxSelections } from "../polls.js"; diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts new file mode 100644 index 00000000000..8f2b9f1949d --- /dev/null +++ b/src/plugin-sdk/line-core.ts @@ -0,0 +1,16 @@ +export type { OpenClawConfig } from "../config/config.js"; +export type { LineConfig } from "../line/types.js"; +export { + DEFAULT_ACCOUNT_ID, + formatDocsLink, + normalizeAccountId, + setSetupChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, + splitSetupEntries, +} from "./setup.js"; +export type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js"; +export { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "../line/accounts.js"; diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index b6617199472..16a6c235ac3 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -32,8 +32,7 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; -export { lineSetupAdapter } from "../../extensions/line/src/setup-core.js"; -export { lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; +export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/setup-api.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index a2997c5702c..4c8abc0f15a 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -19,4 +19,4 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; -export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js"; +export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/setup-api.js"; diff --git a/src/plugin-sdk/slack-core.ts b/src/plugin-sdk/slack-core.ts index 8df7ad669a7..1eaa6eb02e7 100644 --- a/src/plugin-sdk/slack-core.ts +++ b/src/plugin-sdk/slack-core.ts @@ -1,4 +1,13 @@ export type { OpenClawConfig } from "../config/config.js"; export type { ChannelPlugin } from "./channel-plugin-common.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export { withNormalizedTimestamp } from "../agents/date-time.js"; +export { + createActionGate, + imageResultFromFile, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "../agents/tools/common.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index f5fae73fbb2..1b10e475f67 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -20,4 +20,4 @@ export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { synologyChatSetupAdapter, synologyChatSetupWizard, -} from "../../extensions/synology-chat/src/setup-surface.js"; +} from "../../extensions/synology-chat/setup-api.js"; diff --git a/src/plugin-sdk/telegram-core.ts b/src/plugin-sdk/telegram-core.ts index a020a333fd3..6745072c497 100644 --- a/src/plugin-sdk/telegram-core.ts +++ b/src/plugin-sdk/telegram-core.ts @@ -1,5 +1,15 @@ export type { OpenClawConfig } from "../config/config.js"; +export type { TelegramActionConfig } from "../config/types.js"; export type { ChannelPlugin } from "./channel-plugin-common.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; export { normalizeAccountId } from "../routing/session-key.js"; +export { + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, +} from "../agents/tools/common.js"; export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; +export { resolvePollMaxSelections } from "../polls.js"; diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 291834b9648..1bcd9078292 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -27,5 +27,4 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter } from "../../extensions/tlon/src/setup-core.js"; -export { tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; +export { tlonSetupAdapter, tlonSetupWizard } from "../../extensions/tlon/setup-api.js"; diff --git a/src/plugin-sdk/whatsapp-core.ts b/src/plugin-sdk/whatsapp-core.ts index 036fda6a5a9..d2045f007d9 100644 --- a/src/plugin-sdk/whatsapp-core.ts +++ b/src/plugin-sdk/whatsapp-core.ts @@ -1,4 +1,5 @@ export type { ChannelPlugin } from "./channel-plugin-common.js"; +export type { OpenClawConfig } from "../config/config.js"; export { DEFAULT_ACCOUNT_ID, buildChannelConfigSchema, @@ -14,5 +15,13 @@ export { resolveWhatsAppGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; export { resolveWhatsAppGroupIntroHint } from "../channels/plugins/whatsapp-shared.js"; +export { + ToolAuthorizationError, + createActionGate, + jsonResult, + readReactionParams, + readStringParam, +} from "../agents/tools/common.js"; export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; export { normalizeE164 } from "../utils.js"; +export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; From 889bb8a78a87fe138453022728eefa963aab26bd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 19:33:18 -0700 Subject: [PATCH 204/393] Plugins: internalize matrix and feishu SDK imports --- extensions/feishu/runtime-api.ts | 1 + extensions/feishu/src/accounts.ts | 2 +- extensions/feishu/src/bitable.ts | 2 +- extensions/feishu/src/bot.ts | 8 ++++---- extensions/feishu/src/card-action.ts | 2 +- extensions/feishu/src/card-ux-launcher.ts | 2 +- extensions/feishu/src/channel.ts | 8 ++++---- extensions/feishu/src/chat.ts | 2 +- extensions/feishu/src/dedup.ts | 2 +- extensions/feishu/src/directory.static.ts | 2 +- extensions/feishu/src/directory.ts | 2 +- extensions/feishu/src/docx.ts | 2 +- extensions/feishu/src/drive.ts | 2 +- extensions/feishu/src/dynamic-agent.ts | 2 +- extensions/feishu/src/media.ts | 2 +- extensions/feishu/src/monitor.account.ts | 2 +- extensions/feishu/src/monitor.startup.ts | 2 +- extensions/feishu/src/monitor.state.ts | 2 +- extensions/feishu/src/monitor.transport.ts | 2 +- extensions/feishu/src/monitor.ts | 2 +- extensions/feishu/src/monitor.webhook.test-helpers.ts | 2 +- extensions/feishu/src/outbound.ts | 2 +- extensions/feishu/src/perm.ts | 2 +- extensions/feishu/src/pins.ts | 2 +- extensions/feishu/src/policy.ts | 8 ++------ extensions/feishu/src/reactions.ts | 2 +- extensions/feishu/src/reply-dispatcher.ts | 2 +- extensions/feishu/src/runtime.ts | 2 +- extensions/feishu/src/secret-input.ts | 2 +- extensions/feishu/src/send-target.ts | 2 +- extensions/feishu/src/send.ts | 2 +- extensions/feishu/src/streaming-card.ts | 2 +- extensions/feishu/src/subagent-hooks.ts | 2 +- extensions/feishu/src/tool-account.ts | 2 +- extensions/feishu/src/tool-factory-test-harness.ts | 2 +- extensions/feishu/src/types.ts | 2 +- extensions/feishu/src/typing.ts | 2 +- extensions/feishu/src/wiki.ts | 2 +- extensions/matrix/runtime-api.ts | 1 + extensions/matrix/src/actions.ts | 2 +- extensions/matrix/src/channel.ts | 4 ++-- extensions/matrix/src/config-schema.ts | 2 +- extensions/matrix/src/directory-live.ts | 2 +- extensions/matrix/src/group-mentions.ts | 2 +- extensions/matrix/src/matrix/accounts.ts | 2 +- extensions/matrix/src/matrix/client/config.ts | 2 +- extensions/matrix/src/matrix/deps.ts | 2 +- extensions/matrix/src/matrix/monitor/access-policy.ts | 2 +- extensions/matrix/src/matrix/monitor/allowlist.ts | 2 +- extensions/matrix/src/matrix/monitor/auto-join.ts | 2 +- extensions/matrix/src/matrix/monitor/events.ts | 2 +- extensions/matrix/src/matrix/monitor/handler.ts | 2 +- extensions/matrix/src/matrix/monitor/index.ts | 2 +- extensions/matrix/src/matrix/monitor/location.ts | 2 +- extensions/matrix/src/matrix/monitor/replies.ts | 2 +- extensions/matrix/src/matrix/monitor/rooms.ts | 2 +- extensions/matrix/src/matrix/poll-types.ts | 2 +- extensions/matrix/src/matrix/probe.ts | 2 +- extensions/matrix/src/matrix/send.ts | 2 +- extensions/matrix/src/outbound.ts | 2 +- extensions/matrix/src/resolve-targets.ts | 2 +- extensions/matrix/src/runtime.ts | 2 +- extensions/matrix/src/secret-input.ts | 2 +- extensions/matrix/src/tool-actions.ts | 2 +- extensions/matrix/src/types.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 2 ++ 66 files changed, 75 insertions(+), 75 deletions(-) create mode 100644 extensions/feishu/runtime-api.ts create mode 100644 extensions/matrix/runtime-api.ts diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts new file mode 100644 index 00000000000..1257d4a7f00 --- /dev/null +++ b/extensions/feishu/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/feishu"; diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts index 40400445935..ede2be08635 100644 --- a/extensions/feishu/src/accounts.ts +++ b/extensions/feishu/src/accounts.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig } from "../runtime-api.js"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js"; import type { FeishuConfig, diff --git a/extensions/feishu/src/bitable.ts b/extensions/feishu/src/bitable.ts index e7d027694d1..451839edb1f 100644 --- a/extensions/feishu/src/bitable.ts +++ b/extensions/feishu/src/bitable.ts @@ -1,6 +1,6 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuToolClient } from "./tool-account.js"; diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index bc47d6d934f..9d8ef82a177 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -3,7 +3,9 @@ import { resolveConfiguredBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; -import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; +import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { buildAgentMediaPayload, buildPendingHistoryContextFromMap, @@ -18,9 +20,7 @@ import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "openclaw/plugin-sdk/feishu"; -import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; -import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +} from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { buildFeishuConversationId } from "./conversation-id.js"; diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index d664b8d6af2..6b1b542010e 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js"; diff --git a/extensions/feishu/src/card-ux-launcher.ts b/extensions/feishu/src/card-ux-launcher.ts index 3303bc2ed77..91f05d8dfe6 100644 --- a/extensions/feishu/src/card-ux-launcher.ts +++ b/extensions/feishu/src/card-ux-launcher.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js"; import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index fda85f113e1..5bac3945608 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -6,7 +6,8 @@ import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "../runtime-api.js"; import { buildChannelConfigSchema, buildProbeChannelStatusSummary, @@ -15,9 +16,8 @@ import { createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, -} from "openclaw/plugin-sdk/feishu"; -import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu"; -import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +} from "../runtime-api.js"; +import type { ChannelMessageActionName } from "../runtime-api.js"; import { resolveFeishuAccount, resolveFeishuCredentials, diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts index 9c62e5648b2..b32dfb41230 100644 --- a/extensions/feishu/src/chat.ts +++ b/extensions/feishu/src/chat.ts @@ -1,5 +1,5 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js"; import { createFeishuClient } from "./client.js"; diff --git a/extensions/feishu/src/dedup.ts b/extensions/feishu/src/dedup.ts index fc3e9baad65..22944814f8e 100644 --- a/extensions/feishu/src/dedup.ts +++ b/extensions/feishu/src/dedup.ts @@ -4,7 +4,7 @@ import { createDedupeCache, createPersistentDedupe, readJsonFileWithFallback, -} from "openclaw/plugin-sdk/feishu"; +} from "../runtime-api.js"; // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects. const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; diff --git a/extensions/feishu/src/directory.static.ts b/extensions/feishu/src/directory.static.ts index 4adefe2ae0f..643d12ae6bc 100644 --- a/extensions/feishu/src/directory.static.ts +++ b/extensions/feishu/src/directory.static.ts @@ -2,7 +2,7 @@ import { listDirectoryGroupEntriesFromMapKeysAndAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, } from "openclaw/plugin-sdk/directory-runtime"; -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { normalizeFeishuTarget } from "./targets.js"; diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts index af6ed8859cf..a591f3d0fe5 100644 --- a/extensions/feishu/src/directory.ts +++ b/extensions/feishu/src/directory.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 8c6a4b6cd02..7debd446a14 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -4,7 +4,7 @@ import { isAbsolute } from "node:path"; import { basename } from "node:path"; import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js"; diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index 227c30fbbb7..495b6aaaef9 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -1,5 +1,5 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; diff --git a/extensions/feishu/src/dynamic-agent.ts b/extensions/feishu/src/dynamic-agent.ts index 6f22683294c..7f8e04cd061 100644 --- a/extensions/feishu/src/dynamic-agent.ts +++ b/extensions/feishu/src/dynamic-agent.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import type { DynamicAgentCreationConfig } from "./types.js"; export type MaybeCreateDynamicAgentResult = { diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 617bc504756..a55ed6774e6 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -1,8 +1,8 @@ import fs from "fs"; import path from "path"; import { Readable } from "stream"; -import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { mediaKindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { withTempDownloadPath, type ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 241376ac0ba..a15240075d6 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -1,6 +1,6 @@ import * as crypto from "crypto"; import * as Lark from "@larksuiteoapi/node-sdk"; -import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { raceWithTimeoutAndAbort } from "./async.js"; import { diff --git a/extensions/feishu/src/monitor.startup.ts b/extensions/feishu/src/monitor.startup.ts index 42f3639c1de..df3d2b1195f 100644 --- a/extensions/feishu/src/monitor.startup.ts +++ b/extensions/feishu/src/monitor.startup.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import type { RuntimeEnv } from "../runtime-api.js"; import { probeFeishu } from "./probe.js"; import type { ResolvedFeishuAccount } from "./types.js"; diff --git a/extensions/feishu/src/monitor.state.ts b/extensions/feishu/src/monitor.state.ts index 30cada26821..55b8d0dea5a 100644 --- a/extensions/feishu/src/monitor.state.ts +++ b/extensions/feishu/src/monitor.state.ts @@ -6,7 +6,7 @@ import { type RuntimeEnv, WEBHOOK_ANOMALY_COUNTER_DEFAULTS as WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK, WEBHOOK_RATE_LIMIT_DEFAULTS as WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK, -} from "openclaw/plugin-sdk/feishu"; +} from "../runtime-api.js"; export const wsClients = new Map(); export const httpServers = new Map(); diff --git a/extensions/feishu/src/monitor.transport.ts b/extensions/feishu/src/monitor.transport.ts index caab5468378..06ffc2c17e9 100644 --- a/extensions/feishu/src/monitor.transport.ts +++ b/extensions/feishu/src/monitor.transport.ts @@ -6,7 +6,7 @@ import { readJsonBodyWithLimit, type RuntimeEnv, installRequestBodyLimitGuard, -} from "openclaw/plugin-sdk/feishu"; +} from "../runtime-api.js"; import { createFeishuWSClient } from "./client.js"; import { botNames, diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index 50241d36baa..67be9c259f6 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js"; import { monitorSingleAccount, diff --git a/extensions/feishu/src/monitor.webhook.test-helpers.ts b/extensions/feishu/src/monitor.webhook.test-helpers.ts index b9de2150bd4..7295ee98fee 100644 --- a/extensions/feishu/src/monitor.webhook.test-helpers.ts +++ b/extensions/feishu/src/monitor.webhook.test-helpers.ts @@ -1,7 +1,7 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { vi } from "vitest"; +import type { ClawdbotConfig } from "../runtime-api.js"; import type { monitorFeishuProvider } from "./monitor.js"; export async function getFreePort(): Promise { diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index fa121e88178..fd79bff869f 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu"; +import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { sendMediaFeishu } from "./media.js"; import { getFeishuRuntime } from "./runtime.js"; diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index a031bb015ef..a9d2e062eec 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -1,5 +1,5 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; diff --git a/extensions/feishu/src/pins.ts b/extensions/feishu/src/pins.ts index 0205acf3aa3..d5bf82aaeb8 100644 --- a/extensions/feishu/src/pins.ts +++ b/extensions/feishu/src/pins.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index 50eff937269..faee6675127 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -1,9 +1,5 @@ -import type { - AllowlistMatch, - ChannelGroupContext, - GroupToolPolicyConfig, -} from "openclaw/plugin-sdk/feishu"; -import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/feishu"; +import type { AllowlistMatch, ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js"; +import { evaluateSenderGroupAccessForPolicy } from "../runtime-api.js"; import { normalizeFeishuTarget } from "./targets.js"; import type { FeishuConfig, FeishuGroupConfig } from "./types.js"; diff --git a/extensions/feishu/src/reactions.ts b/extensions/feishu/src/reactions.ts index 951b3d03c6b..b6d7f328b70 100644 --- a/extensions/feishu/src/reactions.ts +++ b/extensions/feishu/src/reactions.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 00f5f576af2..8c2d533fbfa 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -6,7 +6,7 @@ import { type OutboundIdentity, type ReplyPayload, type RuntimeEnv, -} from "openclaw/plugin-sdk/feishu"; +} from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { sendMediaFeishu } from "./media.js"; diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts index aad0a41c50a..8d6ffe9c51d 100644 --- a/extensions/feishu/src/runtime.ts +++ b/extensions/feishu/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/feishu"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../runtime-api.js"; const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } = createPluginRuntimeStore("Feishu runtime not initialized"); diff --git a/extensions/feishu/src/secret-input.ts b/extensions/feishu/src/secret-input.ts index 37dda74f2eb..ad5746ffc31 100644 --- a/extensions/feishu/src/secret-input.ts +++ b/extensions/feishu/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/feishu"; +} from "../runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/feishu/src/send-target.ts b/extensions/feishu/src/send-target.ts index cc1780e9223..b0a3d6793c8 100644 --- a/extensions/feishu/src/send-target.ts +++ b/extensions/feishu/src/send-target.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 09015ee593b..7ea5395010c 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import type { MentionTarget } from "./mention.js"; diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index bd2908218a6..c7ca0c4a445 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -3,7 +3,7 @@ */ import type { Client } from "@larksuiteoapi/node-sdk"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu"; +import { fetchWithSsrFGuard } from "../runtime-api.js"; import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js"; import type { FeishuDomain } from "./types.js"; diff --git a/extensions/feishu/src/subagent-hooks.ts b/extensions/feishu/src/subagent-hooks.ts index 6b048f8fbcf..c6d14240160 100644 --- a/extensions/feishu/src/subagent-hooks.ts +++ b/extensions/feishu/src/subagent-hooks.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js"; import { normalizeFeishuTarget } from "./targets.js"; import { getFeishuThreadBindingManager } from "./thread-bindings.js"; diff --git a/extensions/feishu/src/tool-account.ts b/extensions/feishu/src/tool-account.ts index cf8a7e62286..dff71b424dc 100644 --- a/extensions/feishu/src/tool-account.ts +++ b/extensions/feishu/src/tool-account.ts @@ -1,5 +1,5 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { resolveToolsConfig } from "./tools-config.js"; diff --git a/extensions/feishu/src/tool-factory-test-harness.ts b/extensions/feishu/src/tool-factory-test-harness.ts index f5bd19672dd..4c564e25052 100644 --- a/extensions/feishu/src/tool-factory-test-harness.ts +++ b/extensions/feishu/src/tool-factory-test-harness.ts @@ -1,4 +1,4 @@ -import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { AnyAgentTool, OpenClawPluginApi } from "../runtime-api.js"; type ToolContextLike = { agentAccountId?: string; diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index 05293a7ff1d..4f365c3ae00 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/feishu"; +import type { BaseProbeResult } from "../runtime-api.js"; import type { FeishuConfigSchema, FeishuGroupSchema, diff --git a/extensions/feishu/src/typing.ts b/extensions/feishu/src/typing.ts index f32996003bb..d3f25297a79 100644 --- a/extensions/feishu/src/typing.ts +++ b/extensions/feishu/src/typing.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { getFeishuRuntime } from "./runtime.js"; diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index e701f57b3aa..a2df89ff0fe 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -1,5 +1,5 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; import { diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts new file mode 100644 index 00000000000..f9079d7430a --- /dev/null +++ b/extensions/matrix/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/matrix"; diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 9e7e0a0653e..7e555526c39 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -6,7 +6,7 @@ import { type ChannelMessageActionContext, type ChannelMessageActionName, type ChannelToolSend, -} from "openclaw/plugin-sdk/matrix"; +} from "../runtime-api.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { handleMatrixAction } from "./tool-actions.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index a7cc18208c3..e31d4ae2488 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -8,6 +8,7 @@ import { collectAllowlistProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { buildChannelConfigSchema, buildProbeChannelStatusSummary, @@ -15,8 +16,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, type ChannelPlugin, -} from "openclaw/plugin-sdk/matrix"; -import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; +} from "../runtime-api.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; import { diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 18d05d69336..22a8e3c3aec 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -4,8 +4,8 @@ import { DmPolicySchema, GroupPolicySchema, } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix"; import { z } from "zod"; +import { MarkdownConfigSchema, ToolPolicySchema } from "../runtime-api.js"; import { buildSecretInputSchema } from "./secret-input.js"; const matrixActionSchema = z diff --git a/extensions/matrix/src/directory-live.ts b/extensions/matrix/src/directory-live.ts index b915915fdcd..68f1cf15b0c 100644 --- a/extensions/matrix/src/directory-live.ts +++ b/extensions/matrix/src/directory-live.ts @@ -1,4 +1,4 @@ -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix"; +import type { ChannelDirectoryEntry } from "../runtime-api.js"; import { resolveMatrixAuth } from "./matrix/client.js"; type MatrixUserResult = { diff --git a/extensions/matrix/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts index 71b49f59b20..1e83b2df568 100644 --- a/extensions/matrix/src/group-mentions.ts +++ b/extensions/matrix/src/group-mentions.ts @@ -1,4 +1,4 @@ -import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk/matrix"; +import type { ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js"; import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 52fba376200..c507e613dfb 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -1,5 +1,5 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers } from "openclaw/plugin-sdk/matrix"; +import { createAccountListHelpers } from "../../runtime-api.js"; import { hasConfiguredSecretInput } from "../secret-input.js"; import type { CoreConfig, MatrixConfig } from "../types.js"; import { resolveMatrixConfigForAccount } from "./client.js"; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 2867af33f03..d5da7d4556d 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/matrix"; +import { fetchWithSsrFGuard } from "../../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import { normalizeResolvedSecretInputString, diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 25c0ead4c48..6b2ff09cbe7 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk/matrix"; +import { runPluginCommandWithTimeout, type RuntimeEnv } from "../../runtime-api.js"; const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; const MATRIX_CRYPTO_DOWNLOAD_HELPER = "@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js"; diff --git a/extensions/matrix/src/matrix/monitor/access-policy.ts b/extensions/matrix/src/matrix/monitor/access-policy.ts index cace7070fd6..8553b38c131 100644 --- a/extensions/matrix/src/matrix/monitor/access-policy.ts +++ b/extensions/matrix/src/matrix/monitor/access-policy.ts @@ -4,7 +4,7 @@ import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, resolveSenderScopedGroupPolicy, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../runtime-api.js"; import { normalizeMatrixAllowList, resolveMatrixAllowListMatch, diff --git a/extensions/matrix/src/matrix/monitor/allowlist.ts b/extensions/matrix/src/matrix/monitor/allowlist.ts index a48fe63bdb0..120db03f479 100644 --- a/extensions/matrix/src/matrix/monitor/allowlist.ts +++ b/extensions/matrix/src/matrix/monitor/allowlist.ts @@ -3,7 +3,7 @@ import { normalizeStringEntries, resolveCompiledAllowlistMatch, type AllowlistMatch, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../runtime-api.js"; function normalizeAllowList(list?: Array) { return normalizeStringEntries(list); diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index 221e1df504a..bce1efc8b79 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,5 +1,5 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix"; +import type { RuntimeEnv } from "../../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; import { loadMatrixSdk } from "../sdk-runtime.js"; diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index edc9e2f5edd..17e3c99c95d 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,5 +1,5 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import type { PluginRuntime, RuntimeLogger } from "../../../runtime-api.js"; import type { MatrixAuth } from "../client.js"; import { sendReadReceiptMatrix } from "../send.js"; import type { MatrixRawEvent } from "./types.js"; diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 22ee16275cf..ddd8232280a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -14,7 +14,7 @@ import { type PluginRuntime, type RuntimeEnv, type RuntimeLogger, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../runtime-api.js"; import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; import { fetchEventSummary } from "../actions/summary.js"; import { diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 1634a75502b..12091aaeded 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -7,7 +7,7 @@ import { summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, type RuntimeEnv, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../runtime-api.js"; import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig, MatrixConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts index ff80ea82b5a..8d4351a6f5a 100644 --- a/extensions/matrix/src/matrix/monitor/location.ts +++ b/extensions/matrix/src/matrix/monitor/location.ts @@ -3,7 +3,7 @@ import { formatLocationText, toLocationContext, type NormalizedLocation, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../runtime-api.js"; import { EventType } from "./types.js"; export type MatrixLocationPayload = { diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 5f501139dfa..004701edae4 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,5 +1,5 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; +import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import { sendMessageMatrix } from "../send.js"; diff --git a/extensions/matrix/src/matrix/monitor/rooms.ts b/extensions/matrix/src/matrix/monitor/rooms.ts index 215a3f3811e..270320f6e12 100644 --- a/extensions/matrix/src/matrix/monitor/rooms.ts +++ b/extensions/matrix/src/matrix/monitor/rooms.ts @@ -1,4 +1,4 @@ -import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix"; +import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../../../runtime-api.js"; import type { MatrixRoomConfig } from "../../types.js"; export type MatrixRoomConfigResolved = { diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts index 068b5fafd99..bae8905c4e7 100644 --- a/extensions/matrix/src/matrix/poll-types.ts +++ b/extensions/matrix/src/matrix/poll-types.ts @@ -7,7 +7,7 @@ * - m.poll.end - Closes a poll */ -import type { PollInput } from "openclaw/plugin-sdk/matrix"; +import type { PollInput } from "../../runtime-api.js"; export const M_POLL_START = "m.poll.start" as const; export const M_POLL_RESPONSE = "m.poll.response" as const; diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index 2919d9d9c2f..7a5d2a98bce 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix"; +import type { BaseProbeResult } from "../../runtime-api.js"; import { createMatrixClient, isBunRuntime } from "./client.js"; export type MatrixProbe = BaseProbeResult & { diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 6aea822f882..8820b2fbbc1 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,5 +1,5 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import type { PollInput } from "openclaw/plugin-sdk/matrix"; +import type { PollInput } from "../../runtime-api.js"; import { getMatrixRuntime } from "../runtime.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; import { enqueueSend } from "./send-queue.js"; diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 09374b7746e..9cdf8d412bf 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,5 +1,5 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix"; +import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { getMatrixRuntime } from "./runtime.js"; diff --git a/extensions/matrix/src/resolve-targets.ts b/extensions/matrix/src/resolve-targets.ts index 79b794e1806..2589595ba12 100644 --- a/extensions/matrix/src/resolve-targets.ts +++ b/extensions/matrix/src/resolve-targets.ts @@ -4,7 +4,7 @@ import type { ChannelResolveKind, ChannelResolveResult, RuntimeEnv, -} from "openclaw/plugin-sdk/matrix"; +} from "../runtime-api.js"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; function findExactDirectoryMatches( diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index f57cd92a017..09e0fa1da14 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../runtime-api.js"; const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = createPluginRuntimeStore("Matrix runtime not initialized"); diff --git a/extensions/matrix/src/secret-input.ts b/extensions/matrix/src/secret-input.ts index c0827573480..ad5746ffc31 100644 --- a/extensions/matrix/src/secret-input.ts +++ b/extensions/matrix/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/matrix"; +} from "../runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 28c8d5676d1..4a0b49dc7fe 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -5,7 +5,7 @@ import { readNumberParam, readReactionParams, readStringParam, -} from "openclaw/plugin-sdk/matrix"; +} from "../runtime-api.js"; import { deleteMatrixMessage, editMatrixMessage, diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index e6feaf9f619..c5a75eccf53 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -1,4 +1,4 @@ -import type { DmPolicy, GroupPolicy, SecretInput } from "openclaw/plugin-sdk/matrix"; +import type { DmPolicy, GroupPolicy, SecretInput } from "../runtime-api.js"; export type { DmPolicy, GroupPolicy }; export type ReplyToMode = "off" | "first" | "all"; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index e9a9e9b1b59..5af6aeeb01a 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -122,8 +122,10 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "device-pair", "diagnostics-otel", "diffs", + "feishu", "llm-task", "line", + "matrix", "mattermost", "memory-lancedb", "nextcloud-talk", From 055632460d07d906cb52b994b97ed6150008e976 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 19:51:51 -0700 Subject: [PATCH 205/393] docs: reorder changelog sections by interest --- CHANGELOG.md | 240 +++++++++++++++++++++++++-------------------------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21db6c873bd..1144b4fcd6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,10 +152,6 @@ Docs: https://docs.openclaw.ai - Cron/sessions: add `sessionTarget: "current"` and `session:` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF. - Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent. -### Breaking - -- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei. - ### Fixes - Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev. @@ -218,6 +214,10 @@ Docs: https://docs.openclaw.ai - Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc. - Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen. +### Breaking + +- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei. + ## 2026.3.12 ### Changes @@ -318,10 +318,6 @@ Docs: https://docs.openclaw.ai ## 2026.3.11 -### Security - -- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286) - ### Changes - OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven. @@ -343,10 +339,6 @@ Docs: https://docs.openclaw.ai - Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix. - iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman. -### Breaking - -- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky. - ### Fixes - Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies. @@ -459,6 +451,14 @@ Docs: https://docs.openclaw.ai - Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322. - Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025) +### Security + +- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286) + +### Breaking + +- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky. + ## 2026.3.8 ### Changes @@ -571,10 +571,6 @@ Docs: https://docs.openclaw.ai - Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs. - Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100. -### Breaking - -- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant. - ### Fixes - Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`. @@ -900,6 +896,10 @@ Docs: https://docs.openclaw.ai - Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix. - Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk. +### Breaking + +- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant. + ## 2026.3.2 ### Changes @@ -928,13 +928,6 @@ Docs: https://docs.openclaw.ai - Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc. - Zalo Personal plugin (`@openclaw/zalouser`): keep canonical DM routing while preserving legacy DM session continuity on upgrade, and preserve provider-native `g-`/`u-` target ids in outbound send and directory flows so #33992 lands without breaking existing sessions or stored targets. (#33992) Thanks @darkamenosa. -### Breaking - -- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured. -- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents -- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`. -- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path. - ### Fixes - Feishu/Outbound render mode: respect Feishu account `renderMode` in outbound sends so card mode (and auto-detected markdown tables/code blocks) uses markdown card delivery instead of always sending plain text. (#31562) Thanks @arkyu2077. @@ -1121,6 +1114,13 @@ Docs: https://docs.openclaw.ai - Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff. - Control UI/markdown recursion fallback: catch markdown parser failures and safely render escaped plain-text fallback instead of crashing the Control UI on pathological markdown history payloads. (#36445, fixes #36213) Thanks @BinHPdev. +### Breaking + +- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured. +- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents +- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`. +- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path. + ## 2026.3.1 ### Changes @@ -1148,11 +1148,6 @@ Docs: https://docs.openclaw.ai - OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control. - Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`. -### Breaking - -- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected. -- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`). - ### Fixes - Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing. @@ -1247,6 +1242,11 @@ Docs: https://docs.openclaw.ai - Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin. - Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo. +### Breaking + +- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected. +- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`). + ## 2026.2.27 ### Changes @@ -1522,10 +1522,6 @@ Docs: https://docs.openclaw.ai - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. - Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. -### Breaking - -- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override). - ### Fixes - Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong. @@ -1589,6 +1585,10 @@ Docs: https://docs.openclaw.ai - Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. - Feishu/WebSocket proxy: pass a proxy agent to Feishu WS clients from standard proxy environment variables and include plugin-local runtime dependency wiring so websocket mode works in proxy-constrained installs. (#26397) Thanks @colin719. +### Breaking + +- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override). + ## 2026.2.24 ### Changes @@ -1599,11 +1599,6 @@ Docs: https://docs.openclaw.ai - Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). - Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned. -### Breaking - -- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. -- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. - ### Fixes - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. @@ -1683,6 +1678,11 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. +### Breaking + +- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. +- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. + ## 2026.2.23 ### Changes @@ -1697,10 +1697,6 @@ Docs: https://docs.openclaw.ai - Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. - Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. -### Breaking - -- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. - ### Fixes - Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. @@ -1746,6 +1742,10 @@ Docs: https://docs.openclaw.ai - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. - Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. +### Breaking + +- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. + ## 2026.2.22 ### Changes @@ -1770,14 +1770,6 @@ Docs: https://docs.openclaw.ai - Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead. - Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz. -### Breaking - -- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. -- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. -- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. -- **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. -- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. - ### Fixes - Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. @@ -2004,6 +1996,14 @@ Docs: https://docs.openclaw.ai - Gateway/Daemon: verify gateway health after daemon restart. - Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam. +### Breaking + +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. +- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. +- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. +- **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. +- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. + ## 2026.2.21 ### Changes @@ -2652,10 +2652,6 @@ Docs: https://docs.openclaw.ai - Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp. - Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy. -### Breaking - -- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly. - ### Fixes - Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline. @@ -2758,6 +2754,10 @@ Docs: https://docs.openclaw.ai - Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. - Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras. +### Breaking + +- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly. + ## 2026.2.12 ### Changes @@ -2769,10 +2769,6 @@ Docs: https://docs.openclaw.ai - Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat. - Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino. -### Breaking - -- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting. - ### Fixes - Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates. @@ -2855,6 +2851,10 @@ Docs: https://docs.openclaw.ai - Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik. - Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`. +### Breaking + +- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting. + ## 2026.2.9 ### Added @@ -2944,6 +2944,12 @@ Docs: https://docs.openclaw.ai ## 2026.2.6 +### Added + +- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204. +- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204. +- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204. + ### Changes - Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204. @@ -2959,12 +2965,6 @@ Docs: https://docs.openclaw.ai - CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr. - Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids. -### Added - -- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204. -- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204. -- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204. - ### Fixes - TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393) @@ -3263,10 +3263,6 @@ Docs: https://docs.openclaw.ai - Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99. - Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar. -### Breaking - -- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). - ### Fixes - Skills: update session-logs paths to use ~/.openclaw. (#4502) Thanks @bonald. @@ -3319,6 +3315,10 @@ Docs: https://docs.openclaw.ai - Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present. - Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags. +### Breaking + +- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). + ## 2026.1.24-3 ### Fixes @@ -3550,11 +3550,6 @@ Docs: https://docs.openclaw.ai - Docs: add /model allowlist troubleshooting note. (#1405) - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. -### Breaking - -- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http -- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. - ### Fixes - Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman. @@ -3577,6 +3572,11 @@ Docs: https://docs.openclaw.ai - macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc. - Embedded runner: forward sender identity into attempt execution so Feishu doc auto-grant receives requester context again. (#32915) Thanks @cszhouwei. +### Breaking + +- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http +- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. + ## 2026.1.20 ### Changes @@ -3658,10 +3658,6 @@ Docs: https://docs.openclaw.ai - macOS: stop syncing Peekaboo in postinstall. - Swabble: use the tagged Commander Swift package release. -### Breaking - -- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any. - ### Fixes - Discovery: shorten Bonjour DNS-SD service type to `_moltbot-gw._tcp` and update discovery clients/docs. @@ -3760,6 +3756,10 @@ Docs: https://docs.openclaw.ai Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x. +### Breaking + +- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any. + ## 2026.1.16-2 ### Changes @@ -3778,15 +3778,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.openclaw.ai/concepts/session - Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.openclaw.ai/tools/web -### Breaking - -- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan. -- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. -- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`. -- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups. -- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks -- **BREAKING:** `openclaw plugins install ` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading). - ### Changes - Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO. @@ -3878,6 +3869,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Discord: preserve whitespace when chunking long lines so message splits keep spacing intact. - Skills: fix skills watcher ignored list typing (tsc). +### Breaking + +- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan. +- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. +- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`. +- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups. +- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks +- **BREAKING:** `openclaw plugins install ` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading). + ## 2026.1.15 ### Highlights @@ -3887,11 +3887,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf. - Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs). -### Breaking - -- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702) -- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`. - ### Changes - UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow. @@ -3964,6 +3959,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash. - Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998) +### Breaking + +- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702) +- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`. + ## 2026.1.14-1 ### Highlights @@ -4100,10 +4100,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool. - Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal. -### Installer - -- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected. - ### Fixes - Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds. @@ -4129,6 +4125,10 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow. - Connections UI: polish multi-account account cards. (#816) — thanks @steipete. +### Installer + +- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected. + ### Maintenance - Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai. @@ -4180,15 +4180,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests. - macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present. -### Installer - -- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests. -- Postinstall: skip pnpm patch fallback when the new patcher is active. -- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped. -- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git. -- Installer UX: add `install.sh --help` with flags/env and git install hint. -- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm). - ### Fixes - Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias). @@ -4227,6 +4218,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Sandbox/Gateway: treat `agent::main` as a main-session alias when `session.mainKey` is customized (backwards compatible). - Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model). +### Installer + +- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests. +- Postinstall: skip pnpm patch fallback when the new patcher is active. +- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped. +- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git. +- Installer UX: add `install.sh --help` with flags/env and git install hint. +- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm). + ## 2026.1.10 ### Highlights @@ -4335,11 +4335,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting. - Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX. -### Breaking - -- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured. -- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`. - ### New Features and Changes - Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff. @@ -4381,6 +4376,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag. - Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck +### Breaking + +- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured. +- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`. + ### Maintenance - Dependencies: bump pi-\* stack to 0.42.2. @@ -4400,6 +4400,18 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes. - CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded. +### Fixes + +- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints. +- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking. +- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification. +- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram. +- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification. +- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth. +- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes. +- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install. +- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs. + ### Breaking - **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack. @@ -4415,18 +4427,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides). - CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops. -### Fixes - -- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints. -- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking. -- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification. -- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram. -- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification. -- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth. -- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes. -- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install. -- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs. - ### Maintenance - Skills additions (Himalaya email, CodexBar, 1Password). From 5b2c5ee2bc13c7fa0496c1b4b0d337d23cec3bde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 19:48:47 -0700 Subject: [PATCH 206/393] refactor: remove remaining extension src imports --- docs/tools/plugin.md | 7 ++ .../discord/src/actions/runtime.messaging.ts | 2 +- .../firecrawl/src/firecrawl-scrape-tool.ts | 2 +- extensions/line/src/setup-core.ts | 6 +- extensions/tlon/src/channel.runtime.ts | 9 +- extensions/tlon/src/channel.ts | 3 +- extensions/tlon/src/config-schema.ts | 2 +- extensions/tlon/src/runtime.ts | 2 +- extensions/tlon/src/types.ts | 2 +- extensions/tlon/src/urbit/base-url.ts | 2 +- package.json | 3 +- scripts/check-no-extension-src-imports.ts | 88 +++++++++++++++++++ src/plugin-sdk/agent-runtime.ts | 2 + src/plugin-sdk/channel-runtime.ts | 1 + src/plugin-sdk/core.ts | 6 ++ src/plugin-sdk/discord-core.ts | 1 + src/plugin-sdk/line-core.ts | 3 + src/plugin-sdk/subpaths.test.ts | 8 ++ src/plugin-sdk/telegram-core.ts | 1 + src/plugin-sdk/whatsapp-core.ts | 2 +- 20 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 scripts/check-no-extension-src-imports.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index b50797537a6..e9f33b00ab5 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1147,11 +1147,18 @@ authoring plugins: - Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`, `openclaw/plugin-sdk/channel-config-schema`, `openclaw/plugin-sdk/channel-policy`, + `openclaw/plugin-sdk/channel-runtime`, + `openclaw/plugin-sdk/config-runtime`, + `openclaw/plugin-sdk/agent-runtime`, `openclaw/plugin-sdk/lazy-runtime`, `openclaw/plugin-sdk/reply-history`, `openclaw/plugin-sdk/routing`, `openclaw/plugin-sdk/runtime-store`, and `openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers. +- Narrow channel-core subpaths such as `openclaw/plugin-sdk/discord-core`, + `openclaw/plugin-sdk/telegram-core`, `openclaw/plugin-sdk/whatsapp-core`, + and `openclaw/plugin-sdk/line-core` for channel-specific primitives that + should stay smaller than the full channel helper barrels. - `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older external plugins. Bundled plugins should not use it, and non-test imports emit a one-time deprecation warning outside test environments. diff --git a/extensions/discord/src/actions/runtime.messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts index d5cf900207b..e954fd3534e 100644 --- a/extensions/discord/src/actions/runtime.messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -1,8 +1,8 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { - type ActionGate, assertMediaNotDataUrl, + type ActionGate, jsonResult, readNumberParam, readReactionParams, diff --git a/extensions/firecrawl/src/firecrawl-scrape-tool.ts b/extensions/firecrawl/src/firecrawl-scrape-tool.ts index 70f0691d3d7..88f36769210 100644 --- a/extensions/firecrawl/src/firecrawl-scrape-tool.ts +++ b/extensions/firecrawl/src/firecrawl-scrape-tool.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import { optionalStringEnum } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; import { runFirecrawlScrape } from "./firecrawl-client.js"; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index d6e8612cd92..363b4dcb2a1 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,11 +1,11 @@ import { DEFAULT_ACCOUNT_ID, + listLineAccountIds, normalizeAccountId, resolveLineAccount, - type ChannelSetupAdapter, type LineConfig, - type OpenClawConfig, } from "openclaw/plugin-sdk/line-core"; +import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; const channel = "line" as const; @@ -158,4 +158,4 @@ export const lineSetupAdapter: ChannelSetupAdapter = { }, }; -export { listLineAccountIds } from "openclaw/plugin-sdk/line-core"; +export { listLineAccountIds }; diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 98da82480fa..a657768db6e 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,12 +1,13 @@ import crypto from "node:crypto"; import { configureClient } from "@tloncorp/api"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelAccountSnapshot, ChannelOutboundAdapter, - ChannelPlugin, - OpenClawConfig, -} from "../api.js"; -import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../api.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { createLoggerBackedRuntime } from "openclaw/plugin-sdk/runtime"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonSetupWizard } from "./setup-surface.js"; import { diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 92d22feedd5..ea23ab19815 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,5 +1,6 @@ +import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { applyTlonSetupConfig, diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index e7ec5ef2ecf..9bf07b94720 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -1,5 +1,5 @@ +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core"; import { z } from "zod"; -import { buildChannelConfigSchema } from "../api.js"; const ShipSchema = z.string().min(1); const ChannelNestSchema = z.string().min(1); diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index bf284e214a8..8544684dc14 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "../api.js"; const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = createPluginRuntimeStore("Tlon runtime not initialized"); diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts index 7aa0690c14f..ce4deaeca68 100644 --- a/extensions/tlon/src/types.ts +++ b/extensions/tlon/src/types.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "../api.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; export type TlonResolvedAccount = { accountId: string; diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts index 15321d3e391..61674b23321 100644 --- a/extensions/tlon/src/urbit/base-url.ts +++ b/extensions/tlon/src/urbit/base-url.ts @@ -1,4 +1,4 @@ -import { isBlockedHostnameOrIp } from "../../api.js"; +import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/infra-runtime"; export type UrbitBaseUrlValidation = | { ok: true; baseUrl: string; hostname: string } diff --git a/package.json b/package.json index 2a0431f0281..03c12ff3b45 100644 --- a/package.json +++ b/package.json @@ -487,7 +487,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", @@ -539,6 +539,7 @@ "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", + "lint:plugins:no-extension-src-imports": "node --import tsx scripts/check-no-extension-src-imports.ts", "lint:plugins:no-extension-test-core-imports": "node --import tsx scripts/check-no-extension-test-core-imports.ts", "lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts", "lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs", diff --git a/scripts/check-no-extension-src-imports.ts b/scripts/check-no-extension-src-imports.ts new file mode 100644 index 00000000000..e6399f45048 --- /dev/null +++ b/scripts/check-no-extension-src-imports.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; + +const FORBIDDEN_REPO_SRC_IMPORT = /["'](?:\.\.\/)+(?:src\/)[^"']+["']/; + +function isSourceFile(filePath: string): boolean { + if (filePath.endsWith(".d.ts")) { + return false; + } + return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath); +} + +function isProductionExtensionFile(filePath: string): boolean { + return !( + filePath.includes(".test.") || + filePath.includes(".spec.") || + filePath.includes(".fixture.") || + filePath.includes(".snap") || + filePath.includes("test-harness") || + filePath.includes("test-support") || + filePath.includes("/__tests__/") || + filePath.includes("/coverage/") || + filePath.includes("/dist/") || + filePath.includes("/node_modules/") + ); +} + +function collectExtensionSourceFiles(rootDir: string): string[] { + const files: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && isSourceFile(fullPath) && isProductionExtensionFile(fullPath)) { + files.push(fullPath); + } + } + } + return files; +} + +function main() { + const extensionsDir = path.join(process.cwd(), "extensions"); + const files = collectExtensionSourceFiles(extensionsDir); + const offenders: string[] = []; + + for (const file of files) { + const content = fs.readFileSync(file, "utf8"); + if (FORBIDDEN_REPO_SRC_IMPORT.test(content)) { + offenders.push(file); + } + } + + if (offenders.length > 0) { + console.error("Production extension files must not import the repo src/ tree directly."); + for (const offender of offenders.toSorted()) { + const relative = path.relative(process.cwd(), offender) || offender; + console.error(`- ${relative}`); + } + console.error( + "Publish a focused openclaw/plugin-sdk/ seam or use the extension's own public barrel instead.", + ); + process.exit(1); + } + + console.log( + `OK: production extension files avoid direct repo src/ imports (${files.length} checked).`, + ); +} + +main(); diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index e267a458e16..c5313f681cc 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -3,6 +3,7 @@ export * from "../agents/agent-scope.js"; export * from "../agents/auth-profiles.js"; export * from "../agents/current-time.js"; +export * from "../agents/date-time.js"; export * from "../agents/defaults.js"; export * from "../agents/identity-avatar.js"; export * from "../agents/identity.js"; @@ -13,6 +14,7 @@ export * from "../agents/model-selection.js"; export * from "../agents/pi-embedded-block-chunker.js"; export * from "../agents/pi-embedded-utils.js"; export * from "../agents/provider-id.js"; +export * from "../agents/sandbox-paths.js"; export * from "../agents/schema/typebox.js"; export * from "../agents/sglang-defaults.js"; export * from "../agents/tools/common.js"; diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 089e10609af..cf916194580 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -42,6 +42,7 @@ export * from "../channels/plugins/outbound/interactive.js"; export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; +export * from "../polls.js"; export * from "../utils/message-channel.js"; export * from "./channel-lifecycle.js"; export type { diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 56f0bdafa26..3571edf9772 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -72,6 +72,12 @@ export { export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; +export { + channelTargetSchema, + channelTargetsSchema, + optionalStringEnum, + stringEnum, +} from "../agents/schema/typebox.js"; export { DEFAULT_SECRET_FILE_MAX_BYTES, loadSecretFileSync, diff --git a/src/plugin-sdk/discord-core.ts b/src/plugin-sdk/discord-core.ts index 5a3a20cea28..a82a1a1cf38 100644 --- a/src/plugin-sdk/discord-core.ts +++ b/src/plugin-sdk/discord-core.ts @@ -1,4 +1,5 @@ export type { ChannelPlugin } from "./channel-plugin-common.js"; +export type { DiscordActionConfig } from "../config/types.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordActionConfig } from "../config/types.js"; diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index 8f2b9f1949d..5c2ec5dce15 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -11,6 +11,9 @@ export { export type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js"; export { listLineAccountIds, + normalizeAccountId, resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; +export type { ResolvedLineAccount } from "../line/types.js"; +export { LineConfigSchema } from "../line/config-schema.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 4e73ce9c26e..dc72414a1f1 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -9,6 +9,7 @@ import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; import * as lineSdk from "openclaw/plugin-sdk/line"; +import * as lineCoreSdk from "openclaw/plugin-sdk/line-core"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; @@ -67,6 +68,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof coreSdk.definePluginEntry).toBe("function"); expect(typeof coreSdk.defineChannelPluginEntry).toBe("function"); expect(typeof coreSdk.defineSetupPluginEntry).toBe("function"); + expect(typeof coreSdk.optionalStringEnum).toBe("function"); expect("runPassiveAccountLifecycle" in asExports(coreSdk)).toBe(false); expect("createLoggerBackedRuntime" in asExports(coreSdk)).toBe(false); expect("registerSandboxBackend" in asExports(coreSdk)).toBe(false); @@ -207,6 +209,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof lineSdk.lineSetupAdapter).toBe("object"); }); + it("exports narrow LINE core helpers", () => { + expect(typeof lineCoreSdk.resolveLineAccount).toBe("function"); + expect(typeof lineCoreSdk.listLineAccountIds).toBe("function"); + expect(typeof lineCoreSdk.LineConfigSchema).toBe("object"); + }); + it("exports Microsoft Teams helpers", () => { expect(typeof msteamsSdk.resolveControlCommandGate).toBe("function"); expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function"); diff --git a/src/plugin-sdk/telegram-core.ts b/src/plugin-sdk/telegram-core.ts index 6745072c497..2314a4cf2b5 100644 --- a/src/plugin-sdk/telegram-core.ts +++ b/src/plugin-sdk/telegram-core.ts @@ -1,6 +1,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { TelegramActionConfig } from "../config/types.js"; export type { ChannelPlugin } from "./channel-plugin-common.js"; +export type { TelegramActionConfig } from "../config/types.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; export { normalizeAccountId } from "../routing/session-key.js"; export { diff --git a/src/plugin-sdk/whatsapp-core.ts b/src/plugin-sdk/whatsapp-core.ts index d2045f007d9..03a396e38f4 100644 --- a/src/plugin-sdk/whatsapp-core.ts +++ b/src/plugin-sdk/whatsapp-core.ts @@ -23,5 +23,5 @@ export { readStringParam, } from "../agents/tools/common.js"; export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; -export { normalizeE164 } from "../utils.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; +export { normalizeE164 } from "../utils.js"; From fa34cb887d5170dd7583bb9e60bd47762b184c68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 19:53:25 -0700 Subject: [PATCH 207/393] fix: resolve rebase export collisions --- src/plugin-sdk/discord-core.ts | 1 - src/plugin-sdk/line-core.ts | 1 - src/plugin-sdk/telegram-core.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/plugin-sdk/discord-core.ts b/src/plugin-sdk/discord-core.ts index a82a1a1cf38..4de83bafb7d 100644 --- a/src/plugin-sdk/discord-core.ts +++ b/src/plugin-sdk/discord-core.ts @@ -2,7 +2,6 @@ export type { ChannelPlugin } from "./channel-plugin-common.js"; export type { DiscordActionConfig } from "../config/types.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; export type { OpenClawConfig } from "../config/config.js"; -export type { DiscordActionConfig } from "../config/types.js"; export { withNormalizedTimestamp } from "../agents/date-time.js"; export { assertMediaNotDataUrl } from "../agents/sandbox-paths.js"; export { diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index 5c2ec5dce15..c60886bb177 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -3,7 +3,6 @@ export type { LineConfig } from "../line/types.js"; export { DEFAULT_ACCOUNT_ID, formatDocsLink, - normalizeAccountId, setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, splitSetupEntries, diff --git a/src/plugin-sdk/telegram-core.ts b/src/plugin-sdk/telegram-core.ts index 2314a4cf2b5..6745072c497 100644 --- a/src/plugin-sdk/telegram-core.ts +++ b/src/plugin-sdk/telegram-core.ts @@ -1,7 +1,6 @@ export type { OpenClawConfig } from "../config/config.js"; export type { TelegramActionConfig } from "../config/types.js"; export type { ChannelPlugin } from "./channel-plugin-common.js"; -export type { TelegramActionConfig } from "../config/types.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; export { normalizeAccountId } from "../routing/session-key.js"; export { From 25e6cd38b653fb7a274b13acc3487652b5ab0670 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:56:50 -0500 Subject: [PATCH 208/393] UI: mute sidebar and chat input accent colors (#49390) * Refactor CSS styles: replace hardcoded colors with CSS variables for accent colors and optimize spacing rules in layout files. * Update CSS styles: streamline selectors, enhance hover effects, and adjust focus states for chat components and layout elements. * Enhance focus styles for chat components: update border colors and box-shadow effects for improved accessibility and visual consistency. --- ui/src/styles/chat/layout.css | 22 +++++++++++----------- ui/src/styles/components.css | 31 +++++++++++++++++++++---------- ui/src/styles/layout.css | 22 ++++++++++------------ ui/src/styles/layout.mobile.css | 4 ++-- 4 files changed, 44 insertions(+), 35 deletions(-) diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 2726d7041f6..ee8cfaf2850 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -63,7 +63,7 @@ background: transparent; } -.chat-thread-inner > :first-child { +.chat-thread-inner> :first-child { margin-top: 0 !important; } @@ -320,7 +320,7 @@ } /* Hide the "Message" label - keep textarea only */ -.chat-compose__field > span { +.chat-compose__field>span { display: none; } @@ -380,8 +380,8 @@ } .agent-chat__input:focus-within { - border-color: color-mix(in srgb, var(--accent) 40%, transparent); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent); + border-color: var(--border-strong); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--border-strong) 24%, transparent); } @supports (backdrop-filter: blur(1px)) { @@ -391,7 +391,7 @@ } } -.agent-chat__input > textarea { +.agent-chat__input>textarea { width: 100%; min-height: 40px; max-height: 150px; @@ -407,7 +407,7 @@ box-sizing: border-box; } -.agent-chat__input > textarea::placeholder { +.agent-chat__input>textarea::placeholder { color: var(--muted); } @@ -494,8 +494,8 @@ height: 30px; border-radius: var(--radius-md); border: none; - background: var(--accent); - color: var(--accent-foreground); + background: var(--muted-strong); + color: var(--text-strong); cursor: pointer; flex-shrink: 0; transition: @@ -515,8 +515,8 @@ } .chat-send-btn:hover:not(:disabled) { - background: var(--accent-hover); - box-shadow: 0 2px 10px rgba(255, 92, 92, 0.25); + background: var(--muted); + box-shadow: none; } .chat-send-btn:disabled { @@ -549,7 +549,7 @@ scrollbar-width: thin; } -.slash-menu-group + .slash-menu-group { +.slash-menu-group+.slash-menu-group { margin-top: 4px; padding-top: 4px; border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index e1373744be3..95fbd539f36 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -173,9 +173,11 @@ opacity: 0.7; transition: opacity 0.15s; } + .update-banner__close:hover { opacity: 1; } + .update-banner__close svg { width: 16px; height: 16px; @@ -1017,11 +1019,11 @@ position: relative; } -.cron-filter-dropdown__details > summary { +.cron-filter-dropdown__details>summary { list-style: none; } -.cron-filter-dropdown__details > summary::-webkit-details-marker { +.cron-filter-dropdown__details>summary::-webkit-details-marker { display: none; } @@ -1643,6 +1645,7 @@ } @media (max-width: 1100px) { + .table-head, .table-row { grid-template-columns: 1fr; @@ -1650,6 +1653,7 @@ } @container (max-width: 1100px) { + .table-head, .table-row { grid-template-columns: 1fr; @@ -2302,10 +2306,12 @@ } @keyframes chatStreamPulse { + 0%, 100% { border-color: var(--border); } + 50% { border-color: var(--accent); } @@ -2335,7 +2341,7 @@ height: 12px; } -.chat-reading-indicator__dots > span { +.chat-reading-indicator__dots>span { display: inline-block; width: 6px; height: 6px; @@ -2347,21 +2353,23 @@ will-change: transform, opacity; } -.chat-reading-indicator__dots > span:nth-child(2) { +.chat-reading-indicator__dots>span:nth-child(2) { animation-delay: 0.15s; } -.chat-reading-indicator__dots > span:nth-child(3) { +.chat-reading-indicator__dots>span:nth-child(3) { animation-delay: 0.3s; } @keyframes chatReadingDot { + 0%, 80%, 100% { opacity: 0.4; transform: translateY(0); } + 40% { opacity: 1; transform: translateY(-3px); @@ -2369,7 +2377,7 @@ } @media (prefers-reduced-motion: reduce) { - .chat-reading-indicator__dots > span { + .chat-reading-indicator__dots>span { animation: none; opacity: 0.6; } @@ -2603,8 +2611,8 @@ } .chat-compose__field textarea:focus { - border-color: var(--ring); - box-shadow: var(--focus-ring); + border-color: var(--border-strong); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--border-strong) 24%, transparent); } .chat-compose__field textarea:disabled { @@ -3055,7 +3063,7 @@ min-width: 0; } -.agent-kv > div { +.agent-kv>div { min-width: 0; overflow-wrap: anywhere; word-break: break-word; @@ -3310,7 +3318,7 @@ gap: 8px; } -.agent-skills-header > span:last-child { +.agent-skills-header>span:last-child { margin-left: auto; } @@ -3573,12 +3581,15 @@ .ov-cards .ov-card:nth-child(1) { animation-delay: 0ms; } + .ov-cards .ov-card:nth-child(2) { animation-delay: 50ms; } + .ov-cards .ov-card:nth-child(3) { animation-delay: 100ms; } + .ov-cards .ov-card:nth-child(4) { animation-delay: 150ms; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index ac87e1b106c..559cb919098 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -70,7 +70,7 @@ padding-top: 0; } -.shell--chat-focus .content > * + * { +.shell--chat-focus .content>*+* { margin-top: 0; } @@ -682,18 +682,16 @@ bottom: 10px; width: 3px; border-radius: 999px; - background: color-mix(in srgb, #2de3d1 86%, transparent); - box-shadow: 0 0 14px color-mix(in srgb, #2de3d1 34%, transparent); + background: color-mix(in srgb, var(--accent) 86%, transparent); + box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 34%, transparent); } .sidebar--collapsed .nav-item.active, .sidebar--collapsed .nav-item--active { - background: linear-gradient( - 180deg, - color-mix(in srgb, #0b2f34 84%, var(--bg-elevated) 16%) 0%, - color-mix(in srgb, #081f25 90%, var(--bg) 10%) 100% - ); - border-color: color-mix(in srgb, #1ed2c2 18%, var(--border) 82%); + background: linear-gradient(180deg, + color-mix(in srgb, var(--accent) 14%, var(--bg-elevated) 86%) 0%, + color-mix(in srgb, var(--accent) 8%, var(--bg) 92%) 100%); + border-color: color-mix(in srgb, var(--accent) 18%, var(--border) 82%); box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent), 0 10px 20px color-mix(in srgb, black 18%, transparent); @@ -855,7 +853,7 @@ overflow-x: hidden; } -.content > * + * { +.content>*+* { margin-top: 20px; } @@ -871,7 +869,7 @@ padding-bottom: 0; } -.content--chat > * + * { +.content--chat>*+* { margin-top: 0; } @@ -930,7 +928,7 @@ padding-bottom: 0; } -.content--chat .content-header > div:first-child { +.content--chat .content-header>div:first-child { text-align: left; } diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index cb5818190bd..d9fc3768603 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -191,8 +191,8 @@ bottom: 10px; width: 3px; border-radius: 999px; - background: color-mix(in srgb, #2de3d1 86%, transparent); - box-shadow: 0 0 14px color-mix(in srgb, #2de3d1 34%, transparent); + background: color-mix(in srgb, var(--accent) 86%, transparent); + box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 34%, transparent); } .sidebar--collapsed .sidebar-shell__footer { From 870f2607722264b54659366f1e8e9c91b9c77292 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 19:59:01 -0700 Subject: [PATCH 209/393] Gateway: cover trusted-proxy scope regression (#49372) * Gateway: cover trusted-proxy scope regression * Changelog: note trusted-proxy regression coverage * Gateway: format trusted-proxy regression test --- CHANGELOG.md | 1 + src/gateway/server.auth.control-ui.suite.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1144b4fcd6d..6e031c51f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,7 @@ Docs: https://docs.openclaw.ai - Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing. - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. +- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc. ### Breaking diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 9452c26eb33..294fb0dcad8 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -112,6 +112,12 @@ export function registerControlUiAndPairingSuite(): void { expect(talk.error?.message).toBe("missing scope: operator.read"); }; + const expectDevicePairApproveDenied = async (ws: WebSocket, requestId: string) => { + const approve = await rpcReq(ws, "device.pair.approve", { requestId }); + expect(approve.ok).toBe(false); + expect(approve.error?.message).toBe("missing scope: operator.admin"); + }; + const connectControlUiWithoutDeviceAndExpectOk = async (params: { ws: WebSocket; token?: string; @@ -244,6 +250,17 @@ export function registerControlUiAndPairingSuite(): void { test("clears self-declared scopes for trusted-proxy control ui without device identity", async () => { await configureTrustedProxyControlUiAuth(); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { requestDevicePairing } = await import("../infra/device-pairing.js"); + const { identity } = await createOperatorIdentityFixture("openclaw-control-ui-trusted-proxy-"); + const pendingRequest = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + role: "operator", + scopes: ["operator.admin"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + }); await withGatewayServer(async ({ port }) => { const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); try { @@ -259,6 +276,7 @@ export function registerControlUiAndPairingSuite(): void { await expectStatusMissingScopeButHealthOk(ws); await expectAdminRpcDenied(ws); await expectTalkSecretsDenied(ws); + await expectDevicePairApproveDenied(ws, pendingRequest.request.requestId); } finally { ws.close(); } From 682f4d1ca32213d06ccf024d4c3d43adad12b16b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:02:02 +0000 Subject: [PATCH 210/393] Plugin SDK: require unified message discovery --- extensions/bluebubbles/src/actions.test.ts | 14 +- extensions/bluebubbles/src/actions.ts | 6 +- extensions/bluebubbles/src/probe.ts | 2 +- extensions/googlechat/src/actions.ts | 6 +- extensions/googlechat/src/channel.ts | 2 +- extensions/matrix/src/actions.ts | 6 +- extensions/mattermost/src/channel.test.ts | 2 +- extensions/signal/src/channel.ts | 3 +- extensions/twitch/src/actions.ts | 2 +- extensions/whatsapp/src/channel.ts | 6 +- extensions/zalo/src/actions.ts | 7 +- extensions/zalouser/src/channel.test.ts | 7 +- extensions/zalouser/src/channel.ts | 6 +- src/agents/channel-tools.test.ts | 13 +- src/agents/tools/message-tool.test.ts | 130 +++++++++--------- src/channels/plugins/actions/actions.test.ts | 2 +- src/channels/plugins/actions/signal.ts | 8 +- src/channels/plugins/contracts/suites.ts | 22 +-- .../plugins/message-action-discovery.ts | 111 ++------------- .../plugins/message-actions.security.test.ts | 2 +- src/channels/plugins/message-actions.test.ts | 32 ++--- src/channels/plugins/types.core.ts | 30 +--- ...channels.config-only-status-output.test.ts | 2 +- .../channels.status.command-flow.test.ts | 2 +- src/commands/channels/capabilities.test.ts | 2 +- src/commands/message.test.ts | 6 +- .../channels.mattermost-token-summary.test.ts | 2 +- src/infra/channel-summary.test.ts | 8 +- .../message-action-runner.media.test.ts | 2 +- ...sage-action-runner.plugin-dispatch.test.ts | 11 +- .../channel-plugin-test-fixtures.ts | 2 +- 31 files changed, 155 insertions(+), 301 deletions(-) diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index a7a9e549051..02cda25b5bc 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -46,7 +46,7 @@ vi.mock("./probe.js", () => ({ })); describe("bluebubblesMessageActions", () => { - const listActions = bluebubblesMessageActions.listActions!; + const describeMessageTool = bluebubblesMessageActions.describeMessageTool!; const supportsAction = bluebubblesMessageActions.supportsAction!; const extractToolSend = bluebubblesMessageActions.extractToolSend!; const handleAction = bluebubblesMessageActions.handleAction!; @@ -74,12 +74,12 @@ describe("bluebubblesMessageActions", () => { vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); - describe("listActions", () => { + describe("describeMessageTool", () => { it("returns empty array when account is not enabled", () => { const cfg: OpenClawConfig = { channels: { bluebubbles: { enabled: false } }, }; - const actions = listActions({ cfg }); + const actions = describeMessageTool({ cfg })?.actions ?? []; expect(actions).toEqual([]); }); @@ -87,7 +87,7 @@ describe("bluebubblesMessageActions", () => { const cfg: OpenClawConfig = { channels: { bluebubbles: { enabled: true } }, }; - const actions = listActions({ cfg }); + const actions = describeMessageTool({ cfg })?.actions ?? []; expect(actions).toEqual([]); }); @@ -101,7 +101,7 @@ describe("bluebubblesMessageActions", () => { }, }, }; - const actions = listActions({ cfg }); + const actions = describeMessageTool({ cfg })?.actions ?? []; expect(actions).toContain("react"); }); @@ -116,7 +116,7 @@ describe("bluebubblesMessageActions", () => { }, }, }; - const actions = listActions({ cfg }); + const actions = describeMessageTool({ cfg })?.actions ?? []; expect(actions).not.toContain("react"); // Other actions should still be present expect(actions).toContain("edit"); @@ -134,7 +134,7 @@ describe("bluebubblesMessageActions", () => { }, }, }; - const actions = listActions({ cfg }); + const actions = describeMessageTool({ cfg })?.actions ?? []; expect(actions).toContain("sendAttachment"); expect(actions).not.toContain("react"); expect(actions).not.toContain("reply"); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 78cffcd2414..aeb99e8ddd3 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -67,10 +67,10 @@ const PRIVATE_API_ACTIONS = new Set([ ]); export const bluebubblesMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg, currentChannelId }) => { + describeMessageTool: ({ cfg, currentChannelId }) => { const account = resolveBlueBubblesAccount({ cfg: cfg }); if (!account.enabled || !account.configured) { - return []; + return null; } const gate = createActionGate(cfg.channels?.bluebubbles?.actions); const actions = new Set(); @@ -107,7 +107,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { } } } - return Array.from(actions); + return { actions: Array.from(actions) }; }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index 135423bc0fc..8e12a621e41 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -73,7 +73,7 @@ export async function fetchBlueBubblesServerInfo(params: { } /** - * Get cached server info synchronously (for use in listActions). + * Get cached server info synchronously (for use in describeMessageTool). * Returns null if not cached or expired. */ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null { diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts index 4685ac0bd26..463967bcd54 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -51,10 +51,10 @@ function resolveAppUserNames(account: { config: { botUser?: string | null } }) { } export const googlechatMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + describeMessageTool: ({ cfg }) => { const accounts = listEnabledAccounts(cfg); if (accounts.length === 0) { - return []; + return null; } const actions = new Set([]); actions.add("send"); @@ -62,7 +62,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { actions.add("react"); actions.add("reactions"); } - return Array.from(actions); + return { actions: Array.from(actions) }; }, extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 95aeccfbac2..c4ee5364643 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -98,7 +98,7 @@ const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver googlechatMessageActions.listActions?.(ctx) ?? [], + describeMessageTool: (ctx) => googlechatMessageActions.describeMessageTool?.(ctx) ?? null, extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { if (!googlechatMessageActions.handleAction) { diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 7e555526c39..e3ef491213f 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -12,10 +12,10 @@ import { handleMatrixAction } from "./tool-actions.js"; import type { CoreConfig } from "./types.js"; export const matrixMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + describeMessageTool: ({ cfg }) => { const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); if (!account.enabled || !account.configured) { - return []; + return null; } const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions); const actions = new Set(["send", "poll"]); @@ -39,7 +39,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { if (gate("channelInfo")) { actions.add("channel-info"); } - return Array.from(actions); + return { actions: Array.from(actions) }; }, supportsAction: ({ action }) => action !== "poll", extractToolSend: ({ args }): ChannelToolSend | null => { diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 29c4cc12e0e..f8e8d86ee74 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -173,7 +173,7 @@ describe("mattermostPlugin", () => { expect(actions).toContain("send"); }); - it("respects per-account actions.reactions in listActions", () => { + it("respects per-account actions.reactions in message discovery", () => { const cfg: OpenClawConfig = { channels: { mattermost: { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 17b97c96f25..80519620cc6 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -36,7 +36,8 @@ import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; const signalMessageActions: ChannelMessageActionAdapter = { - listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], + describeMessageTool: (ctx) => + getSignalRuntime().channel.signal.messageActions?.describeMessageTool?.(ctx) ?? null, supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions?.supportsAction?.(ctx) ?? false, handleAction: async (ctx) => { diff --git a/extensions/twitch/src/actions.ts b/extensions/twitch/src/actions.ts index 076610a652c..d67ee334d40 100644 --- a/extensions/twitch/src/actions.ts +++ b/extensions/twitch/src/actions.ts @@ -68,7 +68,7 @@ export const twitchMessageActions: ChannelMessageActionAdapter = { /** * List available actions for this channel. */ - listActions: () => [...TWITCH_ACTIONS], + describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }), /** * Check if an action is supported. diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e7f79ad5f2a..89883742a46 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -108,9 +108,9 @@ export const whatsappPlugin: ChannelPlugin = { listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params), }, actions: { - listActions: ({ cfg }) => { + describeMessageTool: ({ cfg }) => { if (!cfg.channels?.whatsapp) { - return []; + return null; } const gate = createActionGate(cfg.channels.whatsapp.actions); const actions = new Set(); @@ -120,7 +120,7 @@ export const whatsappPlugin: ChannelPlugin = { if (gate("polls")) { actions.add("poll"); } - return Array.from(actions); + return { actions: Array.from(actions) }; }, supportsAction: ({ action }) => action === "react", handleAction: async ({ action, params, cfg, accountId }) => { diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 201838f0b04..b741d358c5a 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -21,15 +21,14 @@ function listEnabledAccounts(cfg: OpenClawConfig) { } export const zaloMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + describeMessageTool: ({ cfg }) => { const accounts = listEnabledAccounts(cfg); if (accounts.length === 0) { - return []; + return null; } const actions = new Set(["send"]); - return Array.from(actions); + return { actions: Array.from(actions), capabilities: [] }; }, - getCapabilities: () => [], extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId }) => { if (action === "send") { diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index 321df502b38..23ef1809e25 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -131,9 +131,10 @@ describe("zalouser channel policies", () => { it("handles react action", async () => { const actions = zalouserPlugin.actions; - expect(actions?.listActions?.({ cfg: { channels: { zalouser: { enabled: true } } } })).toEqual([ - "react", - ]); + expect( + actions?.describeMessageTool?.({ cfg: { channels: { zalouser: { enabled: true } } } }) + ?.actions, + ).toEqual(["react"]); const result = await actions?.handleAction?.({ channel: "zalouser", action: "react", diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 4822ecb3f3e..61318d84e20 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -218,14 +218,14 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { } const zalouserMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + describeMessageTool: ({ cfg }) => { const accounts = listZalouserAccountIds(cfg) .map((accountId) => resolveZalouserAccountSync({ cfg, accountId })) .filter((account) => account.enabled); if (accounts.length === 0) { - return []; + return null; } - return ["react"]; + return { actions: ["react"] }; }, supportsAction: ({ action }) => action === "react", handleAction: async ({ action, params, cfg, accountId, toolContext }) => { diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index 0dad6dc3a7c..5686f46aa4a 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -29,7 +29,7 @@ describe("channel tools", () => { resolveAccount: () => ({}), }, actions: { - listActions: () => { + describeMessageTool: () => { throw new Error("boom"); }, }, @@ -70,7 +70,7 @@ describe("channel tools", () => { resolveAccount: () => ({}), }, actions: { - listActions: () => [], + describeMessageTool: () => ({ actions: [] }), }, outbound: { deliveryMode: "gateway", @@ -102,7 +102,7 @@ describe("channel tools", () => { resolveAccount: () => ({}), }, actions: { - listActions: () => ["react"], + describeMessageTool: () => ({ actions: ["react"] }), }, }; @@ -112,10 +112,7 @@ describe("channel tools", () => { expect(listChannelSupportedActions({ cfg, channel: "tg" })).toEqual(["react"]); }); - it("uses unified message tool discovery when available", () => { - const listActions = vi.fn(() => { - throw new Error("legacy listActions should not run"); - }); + it("uses unified message tool discovery", () => { const plugin: ChannelPlugin = { id: "telegram", meta: { @@ -134,7 +131,6 @@ describe("channel tools", () => { describeMessageTool: () => ({ actions: ["react"], }), - listActions, }, }; @@ -142,6 +138,5 @@ describe("channel tools", () => { const cfg = {} as OpenClawConfig; expect(listChannelSupportedActions({ cfg, channel: "telegram" })).toEqual(["react"]); - expect(listActions).not.toHaveBeenCalled(); }); }); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index d6c03cabf75..9d6f252a256 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -16,6 +16,12 @@ let createMessageTool: CreateMessageTool; let setActivePluginRegistry: SetActivePluginRegistry; let createTestRegistry: CreateTestRegistry; +type DescribeMessageTool = NonNullable< + NonNullable["describeMessageTool"] +>; +type MessageToolDiscoveryContext = Parameters[0]; +type MessageToolSchema = NonNullable>["schema"]; + const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), loadConfig: vi.fn(() => ({})), @@ -88,12 +94,11 @@ function createChannelPlugin(params: { blurb: string; aliases?: string[]; actions?: ChannelMessageActionName[]; - listActions?: NonNullable["listActions"]>; capabilities?: readonly ChannelMessageCapability[]; - toolSchema?: NonNullable["getToolSchema"]>; + toolSchema?: MessageToolSchema | ((params: MessageToolDiscoveryContext) => MessageToolSchema); + describeMessageTool?: DescribeMessageTool; messaging?: ChannelPlugin["messaging"]; }): ChannelPlugin { - const actionCapabilities = params.capabilities; return { id: params.id as ChannelPlugin["id"], meta: { @@ -111,15 +116,17 @@ function createChannelPlugin(params: { }, ...(params.messaging ? { messaging: params.messaging } : {}), actions: { - listActions: - params.listActions ?? - (() => { - return (params.actions ?? []) as never; + describeMessageTool: + params.describeMessageTool ?? + ((ctx) => { + const schema = + typeof params.toolSchema === "function" ? params.toolSchema(ctx) : params.toolSchema; + return { + actions: params.actions ?? [], + capabilities: params.capabilities, + ...(schema ? { schema } : {}), + }; }), - ...(actionCapabilities - ? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities } - : {}), - ...(params.toolSchema ? { getToolSchema: params.toolSchema } : {}), }, }; } @@ -398,30 +405,29 @@ describe("message tool schema scoping", () => { label: "Telegram", docsPath: "/channels/telegram", blurb: "Telegram test plugin.", - listActions: ({ cfg }) => { + describeMessageTool: ({ cfg }) => { const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } }) .channels?.telegram; - return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"]; - }, - capabilities: ["interactive", "buttons"], - toolSchema: ({ cfg }) => { - const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } }) - .channels?.telegram; - return [ - { - properties: { - buttons: createMessageToolButtonsSchema(), + return { + actions: + telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"], + capabilities: ["interactive", "buttons"], + schema: [ + { + properties: { + buttons: createMessageToolButtonsSchema(), + }, }, - }, - ...(telegramCfg?.actions?.poll === false - ? [] - : [ - { - properties: createTelegramPollExtraToolSchemas(), - visibility: "all-configured" as const, - }, - ]), - ]; + ...(telegramCfg?.actions?.poll === false + ? [] + : [ + { + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured" as const, + }, + ]), + ], + }; }, }); @@ -458,13 +464,11 @@ describe("message tool schema scoping", () => { label: "Telegram", docsPath: "/channels/telegram", blurb: "Telegram test plugin.", - actions: ["send"], - toolSchema: () => null, + describeMessageTool: ({ accountId }) => ({ + actions: ["send"], + capabilities: accountId === "ops" ? ["interactive"] : [], + }), }); - scopedInteractivePlugin.actions = { - ...scopedInteractivePlugin.actions, - getCapabilities: ({ accountId }) => (accountId === "ops" ? ["interactive"] : []), - }; setActivePluginRegistry( createTestRegistry([ @@ -499,12 +503,10 @@ describe("message tool schema scoping", () => { label: "Telegram", docsPath: "/channels/telegram", blurb: "Telegram test plugin.", - actions: ["send"], + describeMessageTool: ({ accountId }) => ({ + actions: accountId === "ops" ? ["react"] : [], + }), }); - scopedOtherPlugin.actions = { - ...scopedOtherPlugin.actions, - listActions: ({ accountId }) => (accountId === "ops" ? ["react"] : []), - }; setActivePluginRegistry( createTestRegistry([ @@ -536,22 +538,14 @@ describe("message tool schema scoping", () => { label: "Discord", docsPath: "/channels/discord", blurb: "Discord context plugin.", - listActions: (ctx) => { - seenContexts.push({ phase: "listActions", ...ctx }); - return ["send", "react"]; - }, - toolSchema: (ctx) => { - seenContexts.push({ phase: "getToolSchema", ...ctx }); - return null; + describeMessageTool: (ctx) => { + seenContexts.push({ phase: "describeMessageTool", ...ctx }); + return { + actions: ["send", "react"], + capabilities: ["interactive"], + }; }, }); - contextPlugin.actions = { - ...contextPlugin.actions, - getCapabilities: (ctx) => { - seenContexts.push({ phase: "getCapabilities", ...ctx }); - return ["interactive"]; - }, - }; setActivePluginRegistry( createTestRegistry([{ pluginId: "discord", source: "test", plugin: contextPlugin }]), @@ -595,7 +589,7 @@ describe("message tool description", () => { label: "BlueBubbles", docsPath: "/channels/bluebubbles", blurb: "BlueBubbles test plugin.", - listActions: ({ currentChannelId }) => { + describeMessageTool: ({ currentChannelId }) => { const all: ChannelMessageActionName[] = [ "react", "renameGroup", @@ -606,15 +600,17 @@ describe("message tool description", () => { const lowered = currentChannelId?.toLowerCase() ?? ""; const isDmTarget = lowered.includes("chat_guid:imessage;-;") || lowered.includes("chat_guid:sms;-;"); - return isDmTarget - ? all.filter( - (action) => - action !== "renameGroup" && - action !== "addParticipant" && - action !== "removeParticipant" && - action !== "leaveGroup", - ) - : all; + return { + actions: isDmTarget + ? all.filter( + (action) => + action !== "renameGroup" && + action !== "addParticipant" && + action !== "removeParticipant" && + action !== "leaveGroup", + ) + : all, + }; }, messaging: { normalizeTarget: (raw) => { diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index b4631d03f2c..5442b2cf135 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1089,7 +1089,7 @@ describe("signalMessageActions", () => { for (const testCase of cases) { expect( - signalMessageActions.listActions?.({ cfg: testCase.cfg }) ?? [], + signalMessageActions.describeMessageTool?.({ cfg: testCase.cfg })?.actions ?? [], testCase.name, ).toEqual(testCase.expected); } diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts index 2eacd78857c..073496ab2e2 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/src/channels/plugins/actions/signal.ts @@ -74,14 +74,14 @@ async function mutateSignalReaction(params: { } export const signalMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + describeMessageTool: ({ cfg }) => { const accounts = listEnabledSignalAccounts(cfg); if (accounts.length === 0) { - return []; + return null; } const configuredAccounts = accounts.filter((account) => account.configured); if (configuredAccounts.length === 0) { - return []; + return null; } const actions = new Set(["send"]); @@ -93,7 +93,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = { actions.add("react"); } - return Array.from(actions); + return { actions: Array.from(actions) }; }, supportsAction: ({ action }) => action !== "send", diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 58a62d62ed3..892d4b293f9 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -43,16 +43,10 @@ function resolveContractMessageDiscovery(params: { capabilities: [] as readonly ChannelMessageCapability[], }; } - if (actions.describeMessageTool) { - const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null; - return { - actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [], - capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [], - }; - } + const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null; return { - actions: actions.listActions?.({ cfg: params.cfg }) ?? [], - capabilities: actions.getCapabilities?.({ cfg: params.cfg }) ?? [], + actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [], + capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [], }; } @@ -156,10 +150,7 @@ export function installChannelActionsContractSuite(params: { }) { it("exposes the base message actions contract", () => { expect(params.plugin.actions).toBeDefined(); - expect( - typeof params.plugin.actions?.describeMessageTool === "function" || - typeof params.plugin.actions?.listActions === "function", - ).toBe(true); + expect(typeof params.plugin.actions?.describeMessageTool).toBe("function"); }); for (const testCase of params.cases) { @@ -223,10 +214,7 @@ export function installChannelSurfaceContractSuite(params: { it(`exposes the ${surface} surface contract`, () => { if (surface === "actions") { expect(plugin.actions).toBeDefined(); - expect( - typeof plugin.actions?.describeMessageTool === "function" || - typeof plugin.actions?.listActions === "function", - ).toBe(true); + expect(typeof plugin.actions?.describeMessageTool).toBe("function"); return; } diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index d54aec45679..256cceb1ecc 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -60,7 +60,7 @@ export function createMessageActionDiscoveryContext( function logMessageActionError(params: { pluginId: string; - operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; + operation: "describeMessageTool"; error: unknown; }) { const message = params.error instanceof Error ? params.error.message : String(params.error); @@ -75,24 +75,6 @@ function logMessageActionError(params: { ); } -function runListActionsSafely(params: { - pluginId: string; - context: ChannelMessageActionDiscoveryContext; - listActions: NonNullable; -}): ChannelMessageActionName[] { - try { - const listed = params.listActions(params.context); - return Array.isArray(listed) ? listed : []; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "listActions", - error, - }); - return []; - } -} - function describeMessageToolSafely(params: { pluginId: string; context: ChannelMessageActionDiscoveryContext; @@ -110,44 +92,6 @@ function describeMessageToolSafely(params: { } } -function listCapabilitiesSafely(params: { - pluginId: string; - actions: ChannelActions; - context: ChannelMessageActionDiscoveryContext; -}): readonly ChannelMessageCapability[] { - try { - return params.actions.getCapabilities?.(params.context) ?? []; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "getCapabilities", - error, - }); - return []; - } -} - -function runGetToolSchemaSafely(params: { - pluginId: string; - context: ChannelMessageActionDiscoveryContext; - getToolSchema: NonNullable; -}): - | ChannelMessageToolSchemaContribution - | ChannelMessageToolSchemaContribution[] - | null - | undefined { - try { - return params.getToolSchema(params.context); - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "getToolSchema", - error, - }); - return null; - } -} - function normalizeToolSchemaContributions( value: | ChannelMessageToolSchemaContribution @@ -184,52 +128,21 @@ export function resolveMessageActionDiscoveryForPlugin(params: { }; } - if (adapter.describeMessageTool) { - const described = describeMessageToolSafely({ - pluginId: params.pluginId, - context: params.context, - describeMessageTool: adapter.describeMessageTool, - }); - return { - actions: - params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [], - capabilities: - params.includeCapabilities && Array.isArray(described?.capabilities) - ? described.capabilities - : [], - schemaContributions: params.includeSchema - ? normalizeToolSchemaContributions(described?.schema) - : [], - }; - } - + const described = describeMessageToolSafely({ + pluginId: params.pluginId, + context: params.context, + describeMessageTool: adapter.describeMessageTool, + }); return { actions: - params.includeActions && adapter.listActions - ? runListActionsSafely({ - pluginId: params.pluginId, - context: params.context, - listActions: adapter.listActions, - }) - : [], + params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [], capabilities: - params.includeCapabilities && adapter.getCapabilities - ? listCapabilitiesSafely({ - pluginId: params.pluginId, - actions: adapter, - context: params.context, - }) - : [], - schemaContributions: - params.includeSchema && adapter.getToolSchema - ? normalizeToolSchemaContributions( - runGetToolSchemaSafely({ - pluginId: params.pluginId, - context: params.context, - getToolSchema: adapter.getToolSchema, - }), - ) + params.includeCapabilities && Array.isArray(described?.capabilities) + ? described.capabilities : [], + schemaContributions: params.includeSchema + ? normalizeToolSchemaContributions(described?.schema) + : [], }; } diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index ed178a9e2fa..e025f601404 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -23,7 +23,7 @@ const discordPlugin: ChannelPlugin = { }, }), actions: { - listActions: () => ["kick"], + describeMessageTool: () => ({ actions: ["kick"] }), supportsAction: ({ action }) => action === "kick", requiresTrustedRequesterSender: ({ action, toolContext }) => Boolean(action === "kick" && toolContext), diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 396b82a498c..1130adc8031 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -41,8 +41,10 @@ function createMessageActionsPlugin(params: { ...(params.aliases ? { aliases: params.aliases } : {}), }, actions: { - listActions: () => ["send"], - getCapabilities: () => params.capabilities, + describeMessageTool: () => ({ + actions: ["send"], + capabilities: params.capabilities, + }), }, }; } @@ -161,16 +163,7 @@ describe("message action capability checks", () => { ).toEqual(["cards"]); }); - it("prefers unified message tool discovery over legacy discovery methods", () => { - const legacyListActions = vi.fn(() => { - throw new Error("legacy listActions should not run"); - }); - const legacyCapabilities = vi.fn(() => { - throw new Error("legacy getCapabilities should not run"); - }); - const legacySchema = vi.fn(() => { - throw new Error("legacy getToolSchema should not run"); - }); + it("uses unified message tool discovery for actions, capabilities, and schema", () => { const unifiedPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ id: "discord", @@ -190,9 +183,6 @@ describe("message action capability checks", () => { }, }, }), - listActions: legacyListActions, - getCapabilities: legacyCapabilities, - getToolSchema: legacySchema, }, }; setActivePluginRegistry( @@ -207,9 +197,6 @@ describe("message action capability checks", () => { channel: "discord", }), ).toHaveProperty("components"); - expect(legacyListActions).not.toHaveBeenCalled(); - expect(legacyCapabilities).not.toHaveBeenCalled(); - expect(legacySchema).not.toHaveBeenCalled(); }); it("skips crashing action/capability discovery paths and logs once", () => { @@ -223,10 +210,7 @@ describe("message action capability checks", () => { }, }), actions: { - listActions: () => { - throw new Error("boom"); - }, - getCapabilities: () => { + describeMessageTool: () => { throw new Error("boom"); }, }, @@ -237,10 +221,10 @@ describe("message action capability checks", () => { expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]); expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]); - expect(errorSpy).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledTimes(1); expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]); expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]); - expect(errorSpy).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 15b66bd6456..668a47c750b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -489,38 +489,14 @@ export type ChannelToolSend = { export type ChannelMessageActionAdapter = { /** - * Preferred unified discovery surface for the shared `message` tool. - * When provided, this is authoritative and should return the scoped actions, + * Unified discovery surface for the shared `message` tool. + * This returns the scoped actions, * capabilities, and schema fragments together so they cannot drift. */ - describeMessageTool?: ( + describeMessageTool: ( params: ChannelMessageActionDiscoveryContext, ) => ChannelMessageToolDiscovery | null | undefined; - /** - * Advertise agent-discoverable actions for this channel. - * Legacy fallback used when `describeMessageTool` is not implemented. - * Keep this aligned with any gated capability checks. Poll discovery is - * not inferred from `outbound.sendPoll`, so channels that want agents to - * create polls should include `"poll"` here when enabled. - */ - listActions?: (params: ChannelMessageActionDiscoveryContext) => ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; - getCapabilities?: ( - params: ChannelMessageActionDiscoveryContext, - ) => readonly ChannelMessageCapability[]; - /** - * Extend the shared `message` tool schema with channel-owned fields. - * Legacy fallback used when `describeMessageTool` is not implemented. - * Keep this aligned with `listActions` and `getCapabilities` so the exposed - * schema matches what the channel can actually execute in the current scope. - */ - getToolSchema?: ( - params: ChannelMessageActionDiscoveryContext, - ) => - | ChannelMessageToolSchemaContribution - | ChannelMessageToolSchemaContribution[] - | null - | undefined; requiresTrustedRequesterSender?: (params: { action: ChannelMessageActionName; toolContext?: ChannelThreadingToolContext; diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 7019c84bb3a..188f24eaf35 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -118,7 +118,7 @@ function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { isEnabled: () => true, }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index e613c64323a..85347c56bf9 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -92,7 +92,7 @@ function createTokenOnlyPlugin() { isEnabled: () => true, }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index 3a70bdb85f9..f907ac4ca0e 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -68,7 +68,7 @@ function buildPlugin(params: { } : undefined, actions: { - listActions: () => ["poll"], + describeMessageTool: () => ({ actions: ["poll"] }), }, }; } diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 806dc2655d1..29df194cf2d 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -150,7 +150,7 @@ const createDiscordPollPluginRegistration = () => ({ id: "discord", label: "Discord", actions: { - listActions: () => ["poll"], + describeMessageTool: () => ({ actions: ["poll"] }), handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => { return await handleDiscordAction( { action, to: params.to, accountId: accountId ?? undefined }, @@ -168,7 +168,7 @@ const createTelegramSendPluginRegistration = () => ({ id: "telegram", label: "Telegram", actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => { return await handleTelegramAction( { action, to: params.to, accountId: accountId ?? undefined }, @@ -186,7 +186,7 @@ const createTelegramPollPluginRegistration = () => ({ id: "telegram", label: "Telegram", actions: { - listActions: () => ["poll"], + describeMessageTool: () => ({ actions: ["poll"] }), handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => { return await handleTelegramAction( { action, to: params.to, accountId: accountId ?? undefined }, diff --git a/src/commands/status-all/channels.mattermost-token-summary.test.ts b/src/commands/status-all/channels.mattermost-token-summary.test.ts index a012a3a3647..3bf59d1104d 100644 --- a/src/commands/status-all/channels.mattermost-token-summary.test.ts +++ b/src/commands/status-all/channels.mattermost-token-summary.test.ts @@ -32,7 +32,7 @@ function makeMattermostPlugin(): ChannelPlugin { isEnabled: () => true, }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts index 12cfa8bbbae..24eb8ca966d 100644 --- a/src/infra/channel-summary.test.ts +++ b/src/infra/channel-summary.test.ts @@ -67,7 +67,7 @@ function makeSlackHttpSummaryPlugin(): ChannelPlugin { isEnabled: () => true, }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } @@ -125,7 +125,7 @@ function makeTelegramSummaryPlugin(params: { }), }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } @@ -170,7 +170,7 @@ function makeSignalSummaryPlugin(params: { enabled: boolean; configured: boolean isEnabled: (account) => Boolean((account as { enabled?: boolean }).enabled), }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } @@ -208,7 +208,7 @@ function makeFallbackSummaryPlugin(params: { isEnabled: (account) => Boolean((account as { enabled?: boolean }).enabled), }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index fbbb9e6e2c8..292b301a8b7 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -129,7 +129,7 @@ describe("runMessageAction media behavior", () => { isConfigured: () => true, }, actions: { - listActions: () => ["sendAttachment", "setGroupIcon"], + describeMessageTool: () => ({ actions: ["sendAttachment", "setGroupIcon"] }), supportsAction: ({ action }) => action === "sendAttachment" || action === "setGroupIcon", handleAction: async ({ params }) => jsonResult({ 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 55290b8d9d1..6f3d3fd0f03 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -35,7 +35,7 @@ describe("runMessageAction plugin dispatch", () => { capabilities: { chatTypes: ["direct", "channel"] }, config: createAlwaysConfiguredPluginConfig(), actions: { - listActions: () => ["pin", "list-pins", "member-info"], + describeMessageTool: () => ({ actions: ["pin", "list-pins", "member-info"] }), supportsAction: ({ action }) => action === "pin" || action === "list-pins" || action === "member-info", handleAction, @@ -240,7 +240,7 @@ describe("runMessageAction plugin dispatch", () => { capabilities: { chatTypes: ["direct"] }, config: createAlwaysConfiguredPluginConfig(), actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), supportsAction: ({ action }) => action === "send", handleAction, }, @@ -332,7 +332,7 @@ describe("runMessageAction plugin dispatch", () => { }, }, actions: { - listActions: () => ["poll"], + describeMessageTool: () => ({ actions: ["poll"] }), supportsAction: ({ action }) => action === "poll", handleAction, }, @@ -439,6 +439,7 @@ describe("runMessageAction plugin dispatch", () => { }, }, actions: { + describeMessageTool: () => ({ actions: ["poll"] }), supportsAction: ({ action }) => action === "poll", handleAction, }, @@ -521,7 +522,7 @@ describe("runMessageAction plugin dispatch", () => { capabilities: { chatTypes: ["direct"] }, config: createAlwaysConfiguredPluginConfig({}), actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), supportsAction: ({ action }) => action === "send", handleAction, }, @@ -603,7 +604,7 @@ describe("runMessageAction plugin dispatch", () => { resolveAccount: () => ({}), }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), handleAction, }, }; diff --git a/src/test-utils/channel-plugin-test-fixtures.ts b/src/test-utils/channel-plugin-test-fixtures.ts index 39f5a617787..a32c2837748 100644 --- a/src/test-utils/channel-plugin-test-fixtures.ts +++ b/src/test-utils/channel-plugin-test-fixtures.ts @@ -18,7 +18,7 @@ export function makeDirectPlugin(params: { capabilities: { chatTypes: ["direct"] }, config: params.config, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } From 6b9b32a160b44646625c02382a8f1c155b9806e1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:02:10 +0000 Subject: [PATCH 211/393] Docs: require unified message discovery --- docs/tools/plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index e9f33b00ab5..2e347670e42 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -202,7 +202,7 @@ The current boundary is: channel-specific schema fragments - channel plugins execute the final action through their action adapter -For channel plugins, the preferred SDK surface is +For channel plugins, the SDK surface is `ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery call lets a plugin return its visible actions, capabilities, and schema contributions together so those pieces do not drift apart. From 27d4fdf3bb0e840162d1561b5a551bb331ab505f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 19:58:40 -0700 Subject: [PATCH 212/393] Plugins: surface compatibility notices --- src/auto-reply/reply/commands-plugins.test.ts | 2 + src/auto-reply/reply/commands-plugins.ts | 10 + src/cli/plugins-cli.ts | 27 +- src/commands/doctor-workspace-status.test.ts | 162 ++++++++++++ src/commands/doctor-workspace-status.ts | 12 + src/commands/status-all.ts | 3 + src/commands/status-all/diagnosis.ts | 14 ++ src/commands/status-all/report-lines.test.ts | 1 + src/commands/status.command.ts | 24 ++ src/commands/status.scan.test.ts | 5 + src/commands/status.scan.ts | 14 +- src/commands/status.test.ts | 38 +++ src/plugins/status.test.ts | 235 +++++++++++++++++- src/plugins/status.ts | 87 ++++++- src/wizard/setup.test.ts | 61 +++++ src/wizard/setup.ts | 22 ++ 16 files changed, 701 insertions(+), 16 deletions(-) create mode 100644 src/commands/doctor-workspace-status.test.ts diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 1bf3feb772b..02e7fc948c6 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -62,6 +62,7 @@ describe("handleCommands /plugins", () => { expect(showResult.reply?.text).toContain('"id": "superpowers"'); expect(showResult.reply?.text).toContain('"bundleFormat": "claude"'); expect(showResult.reply?.text).toContain('"shape":'); + expect(showResult.reply?.text).toContain('"compatibilityWarnings": []'); const inspectAllParams = buildCommandTestParams( "/plugins inspect all", @@ -75,6 +76,7 @@ describe("handleCommands /plugins", () => { const inspectAllResult = await handleCommands(inspectAllParams); expect(inspectAllResult.reply?.text).toContain("```json"); expect(inspectAllResult.reply?.text).toContain('"plugin"'); + expect(inspectAllResult.reply?.text).toContain('"compatibilityWarnings"'); expect(inspectAllResult.reply?.text).toContain('"superpowers"'); }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 1adbf57e717..3b5dcdb9b60 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -45,6 +45,11 @@ function buildPluginInspectJson(params: { } return { inspect, + compatibilityWarnings: inspect.compatibility.map((warning) => ({ + code: warning.code, + severity: warning.severity, + message: `${warning.pluginId} ${warning.message}`, + })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, }; } @@ -61,6 +66,11 @@ function buildAllPluginInspectJson(params: { report: params.report, }).map((inspect) => ({ inspect, + compatibilityWarnings: inspect.compatibility.map((warning) => ({ + code: warning.code, + severity: warning.severity, + message: `${warning.pluginId} ${warning.message}`, + })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, })); } diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 412e45a6639..ad52aa4559d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -22,6 +22,7 @@ import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { buildAllPluginInspectReports, + buildPluginCompatibilityNotices, buildPluginInspectReport, buildPluginStatusReport, } from "../plugins/status.js"; @@ -652,6 +653,12 @@ export function registerPluginsCli(program: Command) { : theme.error("error"), Shape: inspect.shape, Capabilities: formatCapabilityKinds(inspect.capabilities), + Compatibility: + inspect.compatibility.length > 0 + ? inspect.compatibility + .map((entry) => (entry.severity === "warn" ? `warn:${entry.code}` : entry.code)) + .join(", ") + : "none", Hooks: formatHookSummary({ usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart, typedHookCount: inspect.typedHooks.length, @@ -667,6 +674,7 @@ export function registerPluginsCli(program: Command) { { key: "Status", header: "Status", minWidth: 10 }, { key: "Shape", header: "Shape", minWidth: 18 }, { key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true }, + { key: "Compatibility", header: "Compatibility", minWidth: 24, flex: true }, { key: "Hooks", header: "Hooks", minWidth: 20, flex: true }, ], rows, @@ -751,6 +759,12 @@ export function registerPluginsCli(program: Command) { ), ), ); + lines.push( + ...formatInspectSection( + "Compatibility warnings", + inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), + ), + ); lines.push( ...formatInspectSection( "Custom hooks", @@ -1058,8 +1072,9 @@ export function registerPluginsCli(program: Command) { const report = buildPluginStatusReport(); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); + const compatibility = buildPluginCompatibilityNotices({ report }); - if (errors.length === 0 && diags.length === 0) { + if (errors.length === 0 && diags.length === 0 && compatibility.length === 0) { defaultRuntime.log("No plugin issues detected."); return; } @@ -1081,6 +1096,16 @@ export function registerPluginsCli(program: Command) { lines.push(`- ${target}${diag.message}`); } } + if (compatibility.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(theme.warn("Compatibility:")); + for (const notice of compatibility) { + const marker = notice.severity === "warn" ? theme.warn("warn") : theme.muted("info"); + lines.push(`- ${notice.pluginId} [${marker}]: ${notice.message}`); + } + } const docs = formatDocsLink("/plugin", "docs.openclaw.ai/plugin"); lines.push(""); lines.push(`${theme.muted("Docs:")} ${docs}`); diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts new file mode 100644 index 00000000000..ad64d600dff --- /dev/null +++ b/src/commands/doctor-workspace-status.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; +import * as noteModule from "../terminal/note.js"; + +const resolveAgentWorkspaceDirMock = vi.fn(); +const resolveDefaultAgentIdMock = vi.fn(); +const buildWorkspaceSkillStatusMock = vi.fn(); +const loadOpenClawPluginsMock = vi.fn(); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args), + resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args), +})); + +vi.mock("../agents/skills-status.js", () => ({ + buildWorkspaceSkillStatus: (...args: unknown[]) => buildWorkspaceSkillStatusMock(...args), +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), +})); + +describe("noteWorkspaceStatus", () => { + it("warns when plugins use legacy compatibility paths", async () => { + resolveDefaultAgentIdMock.mockReturnValue("default"); + resolveAgentWorkspaceDirMock.mockReturnValue("/workspace"); + buildWorkspaceSkillStatusMock.mockReturnValue({ + skills: [], + }); + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "legacy-plugin", + name: "Legacy Plugin", + source: "/tmp/legacy-plugin/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [ + { + pluginId: "legacy-plugin", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/legacy-plugin/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + }); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + const { noteWorkspaceStatus } = await import("./doctor-workspace-status.js"); + noteWorkspaceStatus({}); + + const compatibilityCalls = noteSpy.mock.calls.filter( + ([, title]) => title === "Plugin compatibility", + ); + expect(compatibilityCalls).toHaveLength(1); + expect(String(compatibilityCalls[0]?.[0])).toContain( + "legacy-plugin still relies on legacy before_agent_start", + ); + expect(String(compatibilityCalls[0]?.[0])).toContain( + "legacy-plugin is hook-only; this remains supported for compatibility", + ); + } finally { + noteSpy.mockRestore(); + } + }); + + it("omits plugin compatibility note when no legacy compatibility paths are present", async () => { + resolveDefaultAgentIdMock.mockReturnValue("default"); + resolveAgentWorkspaceDirMock.mockReturnValue("/workspace"); + buildWorkspaceSkillStatusMock.mockReturnValue({ + skills: [], + }); + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "modern-plugin", + name: "Modern Plugin", + source: "/tmp/modern-plugin/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["modern"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + }); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + const { noteWorkspaceStatus } = await import("./doctor-workspace-status.js"); + noteWorkspaceStatus({}); + + expect(noteSpy.mock.calls.some(([, title]) => title === "Plugin compatibility")).toBe(false); + } finally { + noteSpy.mockRestore(); + } + }); +}); diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 34cffe18092..5e8132c0216 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { buildPluginCompatibilityWarnings } from "../plugins/status.js"; import { note } from "../terminal/note.js"; import { detectLegacyWorkspaceDirs, formatLegacyWorkspaceWarning } from "./doctor-workspace.js"; @@ -54,6 +55,17 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) { note(lines.join("\n"), "Plugins"); } + const compatibilityWarnings = buildPluginCompatibilityWarnings({ + config: cfg, + workspaceDir, + report: { + workspaceDir, + ...pluginRegistry, + }, + }); + if (compatibilityWarnings.length > 0) { + note(compatibilityWarnings.map((line) => `- ${line}`).join("\n"), "Plugin compatibility"); + } if (pluginRegistry.diagnostics.length > 0) { const lines = pluginRegistry.diagnostics.map((diag) => { const prefix = diag.level.toUpperCase(); diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 3ef91457a50..99a4e8bdc9e 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -25,6 +25,7 @@ import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { readTailscaleStatusJson } from "../infra/tailscale.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import { checkUpdateStatus, formatGitInstallLabel } from "../infra/update-check.js"; +import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { VERSION } from "../version.js"; @@ -238,6 +239,7 @@ export async function statusAllCommand( } })() : null; + const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true; const dashboard = controlUiEnabled @@ -360,6 +362,7 @@ export async function statusAllCommand( tailscale, tailscaleHttpsUrl, skillStatus, + pluginCompatibility, channelsStatus, channelIssues, gatewayReachable, diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 5b866413021..66ae5d02ecd 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -6,6 +6,7 @@ import { type RestartSentinelPayload, summarizeRestartSentinel, } from "../../infra/restart-sentinel.js"; +import type { PluginCompatibilityNotice } from "../../plugins/status.js"; import { formatTimeAgo, redactSecrets } from "./format.js"; import { readFileTailLines, summarizeLogTail } from "./gateway.js"; @@ -59,6 +60,7 @@ export async function appendStatusAllDiagnosis(params: { tailscale: TailscaleStatusLike; tailscaleHttpsUrl: string | null; skillStatus: SkillStatusLike | null; + pluginCompatibility: PluginCompatibilityNotice[]; channelsStatus: unknown; channelIssues: ChannelIssueLike[]; gatewayReachable: boolean; @@ -176,6 +178,18 @@ export async function appendStatusAllDiagnosis(params: { ); } + emitCheck( + `Plugin compatibility (${params.pluginCompatibility.length || "none"})`, + params.pluginCompatibility.length === 0 ? "ok" : "warn", + ); + for (const notice of params.pluginCompatibility.slice(0, 12)) { + const severity = notice.severity === "warn" ? "warn" : "info"; + lines.push(` - ${notice.pluginId} [${severity}] ${notice.message}`); + } + if (params.pluginCompatibility.length > 12) { + lines.push(` ${muted(`… +${params.pluginCompatibility.length - 12} more`)}`); + } + params.progress.setLabel("Reading logs…"); const logPaths = (() => { try { diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts index 0a71665224c..70b9503d63f 100644 --- a/src/commands/status-all/report-lines.test.ts +++ b/src/commands/status-all/report-lines.test.ts @@ -60,6 +60,7 @@ describe("buildStatusAllReportLines", () => { }, tailscaleHttpsUrl: null, skillStatus: null, + pluginCompatibility: [], channelsStatus: null, channelIssues: [], gatewayReachable: false, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 9f17b1a9fee..18e4c53ebf7 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -137,6 +137,7 @@ export async function statusCommand( secretDiagnostics, memory, memoryPlugin, + pluginCompatibility, } = scan; const usage = opts.usage @@ -217,6 +218,10 @@ export async function statusCommand( agents: agentStatus, securityAudit, secretDiagnostics, + pluginCompatibility: { + count: pluginCompatibility.length, + warnings: pluginCompatibility, + }, ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), }, null, @@ -416,6 +421,12 @@ export async function statusCommand( const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); const channelLabel = channelInfo.label; const gitLabel = formatGitInstallLabel(update); + const pluginCompatibilityValue = + pluginCompatibility.length === 0 + ? ok("none") + : warn( + `${pluginCompatibility.length} notice${pluginCompatibility.length === 1 ? "" : "s"} · ${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size} plugin${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size === 1 ? "" : "s"}`, + ); const overviewRows = [ { Item: "Dashboard", Value: dashboard }, @@ -443,6 +454,7 @@ export async function statusCommand( { Item: "Node service", Value: nodeDaemonValue }, { Item: "Agents", Value: agentsValue }, { Item: "Memory", Value: memoryValue }, + { Item: "Plugin compatibility", Value: pluginCompatibilityValue }, { Item: "Probes", Value: probesValue }, { Item: "Events", Value: eventsValue }, { Item: "Heartbeat", Value: heartbeatValue }, @@ -467,6 +479,18 @@ export async function statusCommand( }).trimEnd(), ); + if (pluginCompatibility.length > 0) { + runtime.log(""); + runtime.log(theme.heading("Plugin compatibility")); + for (const notice of pluginCompatibility.slice(0, 8)) { + const label = notice.severity === "warn" ? theme.warn("WARN") : theme.muted("INFO"); + runtime.log(` ${label} ${notice.pluginId} ${notice.message}`); + } + if (pluginCompatibility.length > 8) { + runtime.log(theme.muted(` … +${pluginCompatibility.length - 8} more`)); + } + } + if (pairingRecovery) { runtime.log(""); runtime.log(theme.warn("Gateway pairing approval required.")); diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 899aea2b267..269b6dc8097 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), ensurePluginRegistryLoaded: vi.fn(), + buildPluginCompatibilityNotices: vi.fn(() => []), })); beforeEach(() => { @@ -91,6 +92,10 @@ vi.mock("../cli/plugin-registry.js", () => ({ ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, })); +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, +})); + import { scanStatus } from "./status.scan.js"; describe("scanStatus", () => { diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index e7d05542743..736c1a8b215 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -8,6 +8,10 @@ import { readBestEffortConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; +import { + buildPluginCompatibilityNotices, + type PluginCompatibilityNotice, +} from "../plugins/status.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; @@ -107,6 +111,7 @@ export type StatusScanResult = { summary: Awaited>; memory: MemoryStatusSnapshot | null; memoryPlugin: MemoryPluginStatus; + pluginCompatibility: PluginCompatibilityNotice[]; }; async function resolveMemoryStatusSnapshot(params: { @@ -192,6 +197,7 @@ async function scanStatusJsonFast(opts: { const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); const memory = await memoryPromise; + const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); return { cfg, @@ -216,6 +222,7 @@ async function scanStatusJsonFast(opts: { summary, memory, memoryPlugin, + pluginCompatibility, }; } @@ -233,7 +240,7 @@ export async function scanStatus( return await withProgress( { label: "Scanning status…", - total: 10, + total: 11, enabled: true, }, async (progress) => { @@ -325,6 +332,10 @@ export async function scanStatus( const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); progress.tick(); + progress.setLabel("Checking plugins…"); + const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); + progress.tick(); + progress.setLabel("Reading sessions…"); const summary = unwrapDeferredResult(await summaryPromise); progress.tick(); @@ -355,6 +366,7 @@ export async function scanStatus( summary, memory, memoryPlugin, + pluginCompatibility, }; }, ); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 3e68d55ced2..e4a6e66d976 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -205,6 +205,7 @@ const mocks = vi.hoisted(() => ({ }, ], }), + buildPluginCompatibilityNotices: vi.fn(() => []), })); vi.mock("../memory/manager.js", () => ({ @@ -385,6 +386,9 @@ vi.mock("../daemon/node-service.js", () => ({ vi.mock("../security/audit.js", () => ({ runSecurityAudit: mocks.runSecurityAudit, })); +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, +})); import { statusCommand } from "./status.js"; @@ -403,6 +407,15 @@ describe("statusCommand", () => { }); it("prints JSON when requested", async () => { + mocks.buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); expect(payload.linkChannel).toBeUndefined(); @@ -424,6 +437,18 @@ describe("statusCommand", () => { expect(payload.securityAudit.summary.warn).toBe(1); expect(payload.gatewayService.label).toBe("LaunchAgent"); expect(payload.nodeService.label).toBe("LaunchAgent"); + expect(payload.pluginCompatibility).toEqual({ + count: 1, + warnings: [ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ], + }); expect(mocks.runSecurityAudit).toHaveBeenCalledWith( expect.objectContaining({ includeFilesystem: true, @@ -452,6 +477,15 @@ describe("statusCommand", () => { }); it("prints formatted lines otherwise", async () => { + mocks.buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); const logs = await runStatusAndGetLogs(); for (const token of [ "OpenClaw status", @@ -462,6 +496,7 @@ describe("statusCommand", () => { "Dashboard", "macos 14.0 (arm64)", "Memory", + "Plugin compatibility", "Channels", "WhatsApp", "bootstrap files", @@ -476,6 +511,9 @@ describe("statusCommand", () => { ]) { expect(logs.some((line) => line.includes(token))).toBe(true); } + expect( + logs.some((line) => line.includes("legacy-plugin still relies on legacy before_agent_start")), + ).toBe(true); expect( logs.some( (line) => diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index d16db23da4b..7cbdffb4e04 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -5,6 +5,8 @@ const loadOpenClawPluginsMock = vi.fn(); let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport; let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport; let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; +let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices; +let buildPluginCompatibilityWarnings: typeof import("./status.js").buildPluginCompatibilityWarnings; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), @@ -48,8 +50,13 @@ describe("buildPluginStatusReport", () => { services: [], commands: [], }); - ({ buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport } = - await import("./status.js")); + ({ + buildAllPluginInspectReports, + buildPluginCompatibilityNotices, + buildPluginCompatibilityWarnings, + buildPluginInspectReport, + buildPluginStatusReport, + } = await import("./status.js")); }); it("forwards an explicit env to plugin loading", () => { @@ -148,6 +155,15 @@ describe("buildPluginStatusReport", () => { "web-search", ]); expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect?.compatibility).toEqual([ + { + pluginId: "google", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); expect(inspect?.policy).toEqual({ allowPromptInjection: false, allowModelOverride: true, @@ -257,4 +273,219 @@ describe("buildPluginStatusReport", () => { "web-search", ]); }); + + it("builds compatibility warnings for legacy compatibility paths", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "lca", + name: "LCA", + description: "Legacy hook plugin", + source: "/tmp/lca/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [ + { + pluginId: "lca", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/lca/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + expect(buildPluginCompatibilityWarnings()).toEqual([ + "lca still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "lca is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + ]); + }); + + it("builds structured compatibility notices with deterministic ordering", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "hook-only", + name: "Hook Only", + description: "", + source: "/tmp/hook-only/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + { + id: "legacy-only", + name: "Legacy Only", + description: "", + source: "/tmp/legacy-only/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["legacy-only"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [ + { + pluginId: "hook-only", + events: ["message"], + entry: { + hook: { + name: "legacy", + handler: () => undefined, + }, + }, + }, + ], + typedHooks: [ + { + pluginId: "legacy-only", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/legacy-only/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + expect(buildPluginCompatibilityNotices()).toEqual([ + { + pluginId: "hook-only", + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }, + { + pluginId: "legacy-only", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); + }); + + it("returns no compatibility warnings for modern capability plugins", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "modern", + name: "Modern", + description: "", + source: "/tmp/modern/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["modern"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + expect(buildPluginCompatibilityNotices()).toEqual([]); + expect(buildPluginCompatibilityWarnings()).toEqual([]); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 5588d6f5874..47a7b7f845e 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -26,6 +26,13 @@ export type PluginInspectShape = | "hybrid-capability" | "non-capability"; +export type PluginCompatibilityNotice = { + pluginId: string; + code: "legacy-before-agent-start" | "hook-only"; + severity: "warn" | "info"; + message: string; +}; + export type PluginInspectReport = { workspaceDir?: string; plugin: PluginRegistry["plugins"][number]; @@ -61,8 +68,34 @@ export type PluginInspectReport = { hasAllowedModelsConfig: boolean; }; usesLegacyBeforeAgentStart: boolean; + compatibility: PluginCompatibilityNotice[]; }; +function buildCompatibilityNoticesForInspect( + inspect: Pick, +): PluginCompatibilityNotice[] { + const warnings: PluginCompatibilityNotice[] = []; + if (inspect.usesLegacyBeforeAgentStart) { + warnings.push({ + pluginId: inspect.plugin.id, + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }); + } + if (inspect.shape === "hook-only") { + warnings.push({ + pluginId: inspect.plugin.id, + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }); + } + return warnings; +} + const log = createSubsystemLogger("plugins"); export function buildPluginStatusReport(params?: { @@ -176,21 +209,30 @@ export function buildPluginInspectReport(params: { const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; const capabilityCount = capabilities.length; + const shape = deriveInspectShape({ + capabilityCount, + typedHookCount: typedHooks.length, + customHookCount: customHooks.length, + toolCount: tools.length, + commandCount: plugin.commands.length, + cliCount: plugin.cliCommands.length, + serviceCount: plugin.services.length, + gatewayMethodCount: plugin.gatewayMethods.length, + httpRouteCount: plugin.httpRoutes, + }); + const usesLegacyBeforeAgentStart = typedHooks.some( + (entry) => entry.name === "before_agent_start", + ); + const compatibility = buildCompatibilityNoticesForInspect({ + plugin, + shape, + usesLegacyBeforeAgentStart, + }); return { workspaceDir: report.workspaceDir, plugin, - shape: deriveInspectShape({ - capabilityCount, - typedHookCount: typedHooks.length, - customHookCount: customHooks.length, - toolCount: tools.length, - commandCount: plugin.commands.length, - cliCount: plugin.cliCommands.length, - serviceCount: plugin.services.length, - gatewayMethodCount: plugin.gatewayMethods.length, - httpRouteCount: plugin.httpRoutes, - }), + shape, capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", capabilityCount, capabilities, @@ -209,7 +251,8 @@ export function buildPluginInspectReport(params: { allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])], hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true, }, - usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"), + usesLegacyBeforeAgentStart, + compatibility, }; } @@ -238,3 +281,23 @@ export function buildAllPluginInspectReports(params?: { ) .filter((entry): entry is PluginInspectReport => entry !== null); } + +export function buildPluginCompatibilityWarnings(params?: { + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): string[] { + return buildAllPluginInspectReports(params).flatMap((inspect) => + inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), + ); +} + +export function buildPluginCompatibilityNotices(params?: { + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): PluginCompatibilityNotice[] { + return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility); +} diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 0f280244231..ff157287902 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -88,6 +88,7 @@ const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: tru const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); +const buildPluginCompatibilityNotices = vi.hoisted(() => vi.fn(() => [])); vi.mock("../commands/onboard-channels.js", () => ({ setupChannels, @@ -172,6 +173,10 @@ vi.mock("../infra/control-ui-assets.js", () => ({ ensureControlUiAssetsBuilt, })); +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilityNotices, +})); + vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins, })); @@ -398,6 +403,62 @@ describe("runSetupWizard", () => { } }); + it("shows plugin compatibility notices for an existing valid config", async () => { + buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); + readConfigFileSnapshot.mockResolvedValueOnce({ + path: "/tmp/.openclaw/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {}, + valid: true, + config: { + gateway: {}, + }, + issues: [], + warnings: [], + legacyIssues: [], + }); + + const note: WizardPrompter["note"] = vi.fn(async () => {}); + const select = vi.fn(async (opts: WizardSelectParams) => { + if (opts.message === "Config handling") { + return "keep"; + } + return "quickstart"; + }) as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ note, select }); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true); + expect(calls.some((call) => String(call?.[0] ?? "").includes("legacy-plugin"))).toBe(true); + }); + it("resolves gateway.auth.password SecretRef for local setup probe", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; // pragma: allowlist secret diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 6ffa4d9a2d4..92abd51a20e 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -13,6 +13,7 @@ import { writeConfigFile, } from "../config/config.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; +import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; @@ -102,6 +103,27 @@ export async function runSetupWizard( return; } + const compatibilityNotices = snapshot.valid + ? buildPluginCompatibilityNotices({ config: baseConfig }) + : []; + if (compatibilityNotices.length > 0) { + await prompter.note( + [ + `Detected ${compatibilityNotices.length} plugin compatibility notice${compatibilityNotices.length === 1 ? "" : "s"} in the current config.`, + ...compatibilityNotices + .slice(0, 4) + .map((notice) => `- ${notice.pluginId}: ${notice.message}`), + ...(compatibilityNotices.length > 4 + ? [`- ... +${compatibilityNotices.length - 4} more`] + : []), + "", + `Review: ${formatCliCommand("openclaw doctor")}`, + `Inspect: ${formatCliCommand("openclaw plugins inspect --all")}`, + ].join("\n"), + "Plugin compatibility", + ); + } + const quickstartHint = `Configure details later via ${formatCliCommand("openclaw configure")}.`; const manualHint = "Configure port, network, Tailscale, and auth options."; const explicitFlowRaw = opts.flow?.trim(); From 206d1be0827e6352c598cab6778d32e34ee53d9a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:04:34 +0000 Subject: [PATCH 213/393] Changelog: note plugin message discovery break --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e031c51f6e..60362275d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,7 @@ Docs: https://docs.openclaw.ai - Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. - Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. - Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. +- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. ## 2026.3.13 From 53dcafbec3f3f99f78b44bd99123eb0d003d2ee9 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:10:31 -0500 Subject: [PATCH 214/393] Config UI: click-to-reveal redacted env vars and use lightweight re-render (#49399) * Refactor CSS styles: replace hardcoded colors with CSS variables for accent colors and optimize spacing rules in layout files. * Update CSS styles: streamline selectors, enhance hover effects, and adjust focus states for chat components and layout elements. * Enhance focus styles for chat components: update border colors and box-shadow effects for improved accessibility and visual consistency. * Config UI: click-to-reveal redacted env vars and use lightweight re-render --- ui/src/styles/config.css | 7 +++++++ ui/src/ui/app-render.ts | 6 ++++++ ui/src/ui/views/config-form.node.ts | 31 ++++++++++++++++++++--------- ui/src/ui/views/config.ts | 10 ++++++---- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index c05bdcbe98e..455fbeb019a 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -962,6 +962,13 @@ font-size: 12px; } +/* Redacted (click-to-reveal) */ +.cfg-input--redacted, +.cfg-textarea--redacted { + cursor: pointer; + opacity: 0.7; +} + /* Number Input */ .cfg-number { display: inline-flex; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 11bcacae1ee..76a2fcb04b7 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1512,6 +1512,7 @@ export function renderApp(state: AppViewState) { onRawChange: (next) => { state.configRaw = next; }, + onRequestUpdate: requestHostUpdate, onFormModeChange: (mode) => (state.configFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onSearchChange: (query) => (state.configSearchQuery = query), @@ -1582,6 +1583,7 @@ export function renderApp(state: AppViewState) { onRawChange: (next) => { state.configRaw = next; }, + onRequestUpdate: requestHostUpdate, onFormModeChange: (mode) => (state.communicationsFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onSearchChange: (query) => (state.communicationsSearchQuery = query), @@ -1646,6 +1648,7 @@ export function renderApp(state: AppViewState) { onRawChange: (next) => { state.configRaw = next; }, + onRequestUpdate: requestHostUpdate, onFormModeChange: (mode) => (state.appearanceFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onSearchChange: (query) => (state.appearanceSearchQuery = query), @@ -1710,6 +1713,7 @@ export function renderApp(state: AppViewState) { onRawChange: (next) => { state.configRaw = next; }, + onRequestUpdate: requestHostUpdate, onFormModeChange: (mode) => (state.automationFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onSearchChange: (query) => (state.automationSearchQuery = query), @@ -1774,6 +1778,7 @@ export function renderApp(state: AppViewState) { onRawChange: (next) => { state.configRaw = next; }, + onRequestUpdate: requestHostUpdate, onFormModeChange: (mode) => (state.infrastructureFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onSearchChange: (query) => (state.infrastructureSearchQuery = query), @@ -1838,6 +1843,7 @@ export function renderApp(state: AppViewState) { onRawChange: (next) => { state.configRaw = next; }, + onRequestUpdate: requestHostUpdate, onFormModeChange: (mode) => (state.aiAgentsFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onSearchChange: (query) => (state.aiAgentsSearchQuery = query), diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index e7758e1c29a..9e5be1c20f7 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -646,7 +646,6 @@ function renderTextInput(params: { // oxlint-disable typescript/no-base-to-string (schema.default !== undefined ? `Default: ${String(schema.default)}` : "")); const displayValue = sensitiveState.isRedacted ? "" : (value ?? ""); - const effectiveDisabled = disabled || sensitiveState.isRedacted; const effectiveInputType = sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType; @@ -658,11 +657,16 @@ function renderTextInput(params: {
{ + if (sensitiveState.isRedacted && params.onToggleSensitivePath) { + params.onToggleSensitivePath(path); + } + }} @input=${(e: Event) => { if (sensitiveState.isRedacted) { return; @@ -700,7 +704,7 @@ function renderTextInput(params: { type="button" class="cfg-input__reset" title="Reset to default" - ?disabled=${effectiveDisabled} + ?disabled=${disabled || sensitiveState.isRedacted} @click=${() => onPatch(path, schema.default)} >↺ ` @@ -830,7 +834,6 @@ function renderJsonTextarea(params: { isSensitivePathRevealed: params.isSensitivePathRevealed, }); const displayValue = sensitiveState.isRedacted ? "" : fallback; - const effectiveDisabled = disabled || sensitiveState.isRedacted; return html`
@@ -839,12 +842,17 @@ function renderJsonTextarea(params: { ${renderTags(tags)}
- +
`; })() } From cd2752346c14b647849639471fff03c7f0b4c080 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:24:30 -0500 Subject: [PATCH 312/393] refactor move web search sdk helpers into plugin-sdk --- .../brave/src/brave-web-search-provider.ts | 25 +- .../src/firecrawl-search-provider.ts | 6 +- .../google/src/gemini-web-search-provider.ts | 25 +- .../moonshot/src/kimi-web-search-provider.ts | 23 +- .../src/perplexity-web-search-provider.ts | 26 +- .../xai/src/grok-web-search-provider.ts | 23 +- src/plugin-sdk/provider-web-search.ts | 40 +- ...sion-src-outside-plugin-sdk-inventory.json | 702 ++++++++++-------- 8 files changed, 472 insertions(+), 398 deletions(-) diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 266f7dd666c..370fe77e854 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -1,33 +1,30 @@ import { Type } from "@sinclair/typebox"; -import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; -import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js"; import { buildSearchCacheKey, DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, + formatCliCommand, normalizeFreshness, normalizeToIsoDate, readCachedSearchPayload, readConfiguredSecretString, + readNumberParam, readProviderEnvValue, + readStringParam, resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, resolveSiteName, - withTrustedWebSearchEndpoint, - writeCachedSearchPayload, -} from "../../../src/agents/tools/web-search-provider-common.js"; -import { resolveProviderWebSearchPluginConfig, setProviderWebSearchPluginConfigValue, -} from "../../../src/agents/tools/web-search-provider-config.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { - WebSearchProviderPlugin, - WebSearchProviderToolDefinition, -} from "../../../src/plugins/types.js"; -import { wrapWebContent } from "../../../src/security/external-content.js"; + type OpenClawConfig, + type SearchConfigRecord, + type WebSearchProviderPlugin, + type WebSearchProviderToolDefinition, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index bb2a8aa2864..11a0fa0788d 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -1,10 +1,10 @@ import { Type } from "@sinclair/typebox"; import { + enablePluginInConfig, resolveProviderWebSearchPluginConfig, setProviderWebSearchPluginConfigValue, -} from "../../../src/agents/tools/web-search-provider-config.js"; -import { enablePluginInConfig } from "../../../src/plugins/enable.js"; -import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js"; + type WebSearchProviderPlugin, +} from "openclaw/plugin-sdk/provider-web-search"; import { runFirecrawlSearch } from "./firecrawl-client.js"; const GenericFirecrawlSearchSchema = Type.Object( diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 1d56f36e13f..b0b5d56da66 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -1,30 +1,27 @@ import { Type } from "@sinclair/typebox"; -import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; -import { resolveCitationRedirectUrl } from "../../../src/agents/tools/web-search-citation-redirect.js"; -import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js"; import { buildSearchCacheKey, DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, readCachedSearchPayload, readConfiguredSecretString, + readNumberParam, readProviderEnvValue, + readStringParam, + resolveCitationRedirectUrl, resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, - withTrustedWebSearchEndpoint, - writeCachedSearchPayload, -} from "../../../src/agents/tools/web-search-provider-common.js"; -import { resolveProviderWebSearchPluginConfig, setProviderWebSearchPluginConfigValue, -} from "../../../src/agents/tools/web-search-provider-config.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { - WebSearchProviderPlugin, - WebSearchProviderToolDefinition, -} from "../../../src/plugins/types.js"; -import { wrapWebContent } from "../../../src/security/external-content.js"; + type OpenClawConfig, + type SearchConfigRecord, + type WebSearchProviderPlugin, + type WebSearchProviderToolDefinition, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"; diff --git a/extensions/moonshot/src/kimi-web-search-provider.ts b/extensions/moonshot/src/kimi-web-search-provider.ts index ab76814201a..9224f86e3a6 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.ts @@ -1,29 +1,26 @@ import { Type } from "@sinclair/typebox"; -import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; -import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js"; import { buildSearchCacheKey, DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, readCachedSearchPayload, readConfiguredSecretString, + readNumberParam, readProviderEnvValue, + readStringParam, resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, - withTrustedWebSearchEndpoint, - writeCachedSearchPayload, -} from "../../../src/agents/tools/web-search-provider-common.js"; -import { resolveProviderWebSearchPluginConfig, setProviderWebSearchPluginConfigValue, -} from "../../../src/agents/tools/web-search-provider-config.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { - WebSearchProviderPlugin, - WebSearchProviderToolDefinition, -} from "../../../src/plugins/types.js"; -import { wrapWebContent } from "../../../src/security/external-content.js"; + type OpenClawConfig, + type SearchConfigRecord, + type WebSearchProviderPlugin, + type WebSearchProviderToolDefinition, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1"; const DEFAULT_KIMI_MODEL = "moonshot-v1-128k"; diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index 6a150d64b53..53bdaaa5a98 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -3,9 +3,6 @@ import { readNumberParam, readStringArrayParam, readStringParam, -} from "../../../src/agents/tools/common.js"; -import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js"; -import { buildSearchCacheKey, DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, @@ -19,21 +16,18 @@ import { resolveSearchCount, resolveSearchTimeoutSeconds, resolveSiteName, - throwWebSearchApiError, - withTrustedWebSearchEndpoint, - writeCachedSearchPayload, -} from "../../../src/agents/tools/web-search-provider-common.js"; -import { resolveProviderWebSearchPluginConfig, setProviderWebSearchPluginConfigValue, -} from "../../../src/agents/tools/web-search-provider-config.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { - WebSearchCredentialResolutionSource, - WebSearchProviderPlugin, - WebSearchProviderToolDefinition, -} from "../../../src/plugins/types.js"; -import { wrapWebContent } from "../../../src/security/external-content.js"; + throwWebSearchApiError, + type OpenClawConfig, + type SearchConfigRecord, + type WebSearchCredentialResolutionSource, + type WebSearchProviderPlugin, + type WebSearchProviderToolDefinition, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; diff --git a/extensions/xai/src/grok-web-search-provider.ts b/extensions/xai/src/grok-web-search-provider.ts index e18b9a156ef..864f7ede9ac 100644 --- a/extensions/xai/src/grok-web-search-provider.ts +++ b/extensions/xai/src/grok-web-search-provider.ts @@ -1,29 +1,26 @@ import { Type } from "@sinclair/typebox"; -import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; -import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js"; import { buildSearchCacheKey, DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, readCachedSearchPayload, readConfiguredSecretString, + readNumberParam, readProviderEnvValue, + readStringParam, resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, - withTrustedWebSearchEndpoint, - writeCachedSearchPayload, -} from "../../../src/agents/tools/web-search-provider-common.js"; -import { resolveProviderWebSearchPluginConfig, setProviderWebSearchPluginConfigValue, -} from "../../../src/agents/tools/web-search-provider-config.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { - WebSearchProviderPlugin, - WebSearchProviderToolDefinition, -} from "../../../src/plugins/types.js"; -import { wrapWebContent } from "../../../src/security/external-content.js"; + type OpenClawConfig, + type SearchConfigRecord, + type WebSearchProviderPlugin, + type WebSearchProviderToolDefinition, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; const DEFAULT_GROK_MODEL = "grok-4-1-fast"; diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index e158b160a6f..c130aebb9b2 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -1,17 +1,45 @@ // Public web-search registration helpers for provider plugins. -import type { WebSearchProviderPlugin } from "../plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { + WebSearchCredentialResolutionSource, + WebSearchProviderPlugin, + WebSearchProviderToolDefinition, +} from "../plugins/types.js"; +export { readNumberParam, readStringArrayParam, readStringParam } from "../agents/tools/common.js"; +export { resolveCitationRedirectUrl } from "../agents/tools/web-search-citation-redirect.js"; export { getScopedCredentialValue, getTopLevelCredentialValue, + resolveProviderWebSearchPluginConfig, setScopedCredentialValue, + setProviderWebSearchPluginConfigValue, setTopLevelCredentialValue, } from "../agents/tools/web-search-provider-config.js"; +export type { SearchConfigRecord } from "../agents/tools/web-search-provider-common.js"; export { resolveWebSearchProviderCredential } from "../agents/tools/web-search-provider-credentials.js"; export { withTrustedWebToolsEndpoint } from "../agents/tools/web-guarded-fetch.js"; export { - DEFAULT_TIMEOUT_SECONDS, + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + MAX_SEARCH_COUNT, + isoToPerplexityDate, + normalizeFreshness, + normalizeToIsoDate, + readCachedSearchPayload, + readConfiguredSecretString, + readProviderEnvValue, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + resolveSiteName, + throwWebSearchApiError, + withTrustedWebSearchEndpoint, + writeCachedSearchPayload, +} from "../agents/tools/web-search-provider-common.js"; +export { DEFAULT_CACHE_TTL_MINUTES, + DEFAULT_TIMEOUT_SECONDS, normalizeCacheKey, readCache, readResponseText, @@ -19,7 +47,15 @@ export { resolveTimeoutSeconds, writeCache, } from "../agents/tools/web-shared.js"; +export { enablePluginInConfig } from "../plugins/enable.js"; +export { formatCliCommand } from "../cli/command-format.js"; export { wrapWebContent } from "../security/external-content.js"; +export type { + OpenClawConfig, + WebSearchCredentialResolutionSource, + WebSearchProviderPlugin, + WebSearchProviderToolDefinition, +}; /** * @deprecated Implement provider-owned `createTool(...)` directly on the diff --git a/test/fixtures/extension-src-outside-plugin-sdk-inventory.json b/test/fixtures/extension-src-outside-plugin-sdk-inventory.json index 4cd9a910b0d..3c5aff2a370 100644 --- a/test/fixtures/extension-src-outside-plugin-sdk-inventory.json +++ b/test/fixtures/extension-src-outside-plugin-sdk-inventory.json @@ -1,362 +1,418 @@ [ { - "file": "extensions/brave/src/brave-web-search-provider.ts", - "line": 2, + "file": "extensions/discord/src/directory-config.ts", + "line": 7, "kind": "import", - "specifier": "../../../src/agents/tools/common.js", + "specifier": "../../../src/channels/read-only-account-inspect.discord.runtime.js", + "resolvedPath": "src/channels/read-only-account-inspect.discord.runtime.js", + "reason": "imports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/discord/src/directory-config.ts", + "line": 8, + "kind": "import", + "specifier": "../../../src/channels/read-only-account-inspect.js", + "resolvedPath": "src/channels/read-only-account-inspect.js", + "reason": "imports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 10, + "kind": "export", + "specifier": "../../src/agents/tools/common.js", "resolvedPath": "src/agents/tools/common.js", - "reason": "imports core src path outside plugin-sdk from an extension" + "reason": "re-exports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/brave/src/brave-web-search-provider.ts", - "line": 3, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/brave/src/brave-web-search-provider.ts", - "line": 19, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/brave/src/brave-web-search-provider.ts", + "file": "extensions/googlechat/runtime-api.ts", "line": 23, + "kind": "export", + "specifier": "../../src/channels/mention-gating.js", + "resolvedPath": "src/channels/mention-gating.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 30, + "kind": "export", + "specifier": "../../src/channels/plugins/config-schema.js", + "resolvedPath": "src/channels/plugins/config-schema.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 34, + "kind": "export", + "specifier": "../../src/channels/plugins/config-helpers.js", + "resolvedPath": "src/channels/plugins/config-helpers.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 38, + "kind": "export", + "specifier": "../../src/channels/plugins/directory-config-helpers.js", + "resolvedPath": "src/channels/plugins/directory-config-helpers.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 39, + "kind": "export", + "specifier": "../../src/channels/plugins/helpers.js", + "resolvedPath": "src/channels/plugins/helpers.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 40, + "kind": "export", + "specifier": "../../src/channels/plugins/media-limits.js", + "resolvedPath": "src/channels/plugins/media-limits.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 46, + "kind": "export", + "specifier": "../../src/channels/plugins/setup-wizard-helpers.js", + "resolvedPath": "src/channels/plugins/setup-wizard-helpers.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 47, + "kind": "export", + "specifier": "../../src/channels/plugins/pairing-message.js", + "resolvedPath": "src/channels/plugins/pairing-message.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 52, + "kind": "export", + "specifier": "../../src/channels/plugins/setup-helpers.js", + "resolvedPath": "src/channels/plugins/setup-helpers.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 53, + "kind": "export", + "specifier": "../../src/channels/plugins/account-helpers.js", + "resolvedPath": "src/channels/plugins/account-helpers.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 59, + "kind": "export", + "specifier": "../../src/channels/plugins/types.js", + "resolvedPath": "src/channels/plugins/types.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 60, + "kind": "export", + "specifier": "../../src/channels/plugins/types.plugin.js", + "resolvedPath": "src/channels/plugins/types.plugin.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 61, + "kind": "export", + "specifier": "../../src/channels/registry.js", + "resolvedPath": "src/channels/registry.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 62, + "kind": "export", + "specifier": "../../src/channels/reply-prefix.js", + "resolvedPath": "src/channels/reply-prefix.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 63, + "kind": "export", + "specifier": "../../src/config/config.js", + "resolvedPath": "src/config/config.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 64, + "kind": "export", + "specifier": "../../src/config/dangerous-name-matching.js", + "resolvedPath": "src/config/dangerous-name-matching.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 70, + "kind": "export", + "specifier": "../../src/config/runtime-group-policy.js", + "resolvedPath": "src/config/runtime-group-policy.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 75, + "kind": "export", + "specifier": "../../src/config/types.js", + "resolvedPath": "src/config/types.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 76, + "kind": "export", + "specifier": "../../src/config/types.secrets.js", + "resolvedPath": "src/config/types.secrets.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 77, + "kind": "export", + "specifier": "../../src/config/zod-schema.providers-core.js", + "resolvedPath": "src/config/zod-schema.providers-core.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 78, + "kind": "export", + "specifier": "../../src/infra/net/fetch-guard.js", + "resolvedPath": "src/infra/net/fetch-guard.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 79, + "kind": "export", + "specifier": "../../src/infra/outbound/target-errors.js", + "resolvedPath": "src/infra/outbound/target-errors.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 80, + "kind": "export", + "specifier": "../../src/plugins/config-schema.js", + "resolvedPath": "src/plugins/config-schema.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 81, + "kind": "export", + "specifier": "../../src/plugins/runtime/types.js", + "resolvedPath": "src/plugins/runtime/types.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 82, + "kind": "export", + "specifier": "../../src/plugins/types.js", + "resolvedPath": "src/plugins/types.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 83, + "kind": "export", + "specifier": "../../src/routing/session-key.js", + "resolvedPath": "src/routing/session-key.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 84, + "kind": "export", + "specifier": "../../src/security/dm-policy-shared.js", + "resolvedPath": "src/security/dm-policy-shared.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 85, + "kind": "export", + "specifier": "../../src/terminal/links.js", + "resolvedPath": "src/terminal/links.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 86, + "kind": "export", + "specifier": "../../src/wizard/prompts.js", + "resolvedPath": "src/wizard/prompts.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 89, + "kind": "export", + "specifier": "../../src/pairing/pairing-challenge.js", + "resolvedPath": "src/pairing/pairing-challenge.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/imessage/runtime-api.ts", + "line": 1, + "kind": "export", + "specifier": "../../src/config/types.imessage.js", + "resolvedPath": "src/config/types.imessage.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/imessage/runtime-api.ts", + "line": 2, + "kind": "export", + "specifier": "../../src/channels/plugins/types.plugin.js", + "resolvedPath": "src/channels/plugins/types.plugin.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/imessage/runtime-api.ts", + "line": 15, + "kind": "export", + "specifier": "../../src/channels/plugins/media-limits.js", + "resolvedPath": "src/channels/plugins/media-limits.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/imessage/runtime-api.ts", + "line": 19, + "kind": "export", + "specifier": "../../src/channels/plugins/normalize/imessage.js", + "resolvedPath": "src/channels/plugins/normalize/imessage.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/imessage/runtime-api.ts", + "line": 20, + "kind": "export", + "specifier": "../../src/config/zod-schema.providers-core.js", + "resolvedPath": "src/config/zod-schema.providers-core.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/slack/src/directory-config.ts", + "line": 8, "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-config.js", - "resolvedPath": "src/agents/tools/web-search-provider-config.js", + "specifier": "../../../src/channels/plugins/normalize/slack.js", + "resolvedPath": "src/channels/plugins/normalize/slack.js", "reason": "imports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/brave/src/brave-web-search-provider.ts", - "line": 24, + "file": "extensions/slack/src/directory-config.ts", + "line": 9, "kind": "import", - "specifier": "../../../src/cli/command-format.js", - "resolvedPath": "src/cli/command-format.js", + "specifier": "../../../src/channels/read-only-account-inspect.js", + "resolvedPath": "src/channels/read-only-account-inspect.js", "reason": "imports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/brave/src/brave-web-search-provider.ts", - "line": 25, + "file": "extensions/slack/src/directory-config.ts", + "line": 10, "kind": "import", + "specifier": "../../../src/channels/read-only-account-inspect.slack.runtime.js", + "resolvedPath": "src/channels/read-only-account-inspect.slack.runtime.js", + "reason": "imports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 1, + "kind": "export", "specifier": "../../../src/config/config.js", "resolvedPath": "src/config/config.js", - "reason": "imports core src path outside plugin-sdk from an extension" + "reason": "re-exports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/brave/src/brave-web-search-provider.ts", - "line": 29, - "kind": "import", - "specifier": "../../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "imports core src path outside plugin-sdk from an extension" + "file": "extensions/slack/src/runtime-api.ts", + "line": 2, + "kind": "export", + "specifier": "../../../src/config/types.slack.js", + "resolvedPath": "src/config/types.slack.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/brave/src/brave-web-search-provider.ts", - "line": 30, - "kind": "import", - "specifier": "../../../src/security/external-content.js", - "resolvedPath": "src/security/external-content.js", - "reason": "imports core src path outside plugin-sdk from an extension" + "file": "extensions/slack/src/runtime-api.ts", + "line": 3, + "kind": "export", + "specifier": "../../../src/channels/plugins/types.js", + "resolvedPath": "src/channels/plugins/types.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/discord/src/runtime-api.ts", - "line": 38, + "file": "extensions/slack/src/runtime-api.ts", + "line": 19, + "kind": "export", + "specifier": "../../../src/channels/plugins/normalize/slack.js", + "resolvedPath": "src/channels/plugins/normalize/slack.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 23, + "kind": "export", + "specifier": "../../../src/channels/account-snapshot-fields.js", + "resolvedPath": "src/channels/account-snapshot-fields.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 24, + "kind": "export", + "specifier": "../../../src/config/zod-schema.providers-core.js", + "resolvedPath": "src/config/zod-schema.providers-core.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 32, + "kind": "export", + "specifier": "../../../src/agents/tools/common.js", + "resolvedPath": "src/agents/tools/common.js", + "reason": "re-exports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 33, "kind": "export", "specifier": "../../../src/agents/date-time.js", "resolvedPath": "src/agents/date-time.js", "reason": "re-exports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/discord/src/runtime-api.ts", - "line": 39, - "kind": "export", - "specifier": "../../../src/agents/sandbox-paths.js", - "resolvedPath": "src/agents/sandbox-paths.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/discord/src/runtime-api.ts", - "line": 41, - "kind": "export", - "specifier": "../../../src/polls.js", - "resolvedPath": "src/polls.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/discord/src/runtime-api.ts", - "line": 42, - "kind": "export", - "specifier": "../../../src/config/types.js", - "resolvedPath": "src/config/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/discord/src/runtime-api.ts", - "line": 47, - "kind": "export", - "specifier": "../../../src/config/types.secrets.js", - "resolvedPath": "src/config/types.secrets.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/firecrawl/src/firecrawl-search-provider.ts", - "line": 5, + "file": "extensions/telegram/src/directory-config.ts", + "line": 9, "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-config.js", - "resolvedPath": "src/agents/tools/web-search-provider-config.js", + "specifier": "../../../src/channels/read-only-account-inspect.js", + "resolvedPath": "src/channels/read-only-account-inspect.js", "reason": "imports core src path outside plugin-sdk from an extension" }, { - "file": "extensions/firecrawl/src/firecrawl-search-provider.ts", + "file": "extensions/telegram/src/directory-config.ts", + "line": 10, + "kind": "import", + "specifier": "../../../src/channels/read-only-account-inspect.telegram.runtime.js", + "resolvedPath": "src/channels/read-only-account-inspect.telegram.runtime.js", + "reason": "imports core src path outside plugin-sdk from an extension" + }, + { + "file": "extensions/whatsapp/src/directory-config.ts", "line": 6, "kind": "import", - "specifier": "../../../src/plugins/enable.js", - "resolvedPath": "src/plugins/enable.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/firecrawl/src/firecrawl-search-provider.ts", - "line": 7, - "kind": "import", - "specifier": "../../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 2, - "kind": "import", - "specifier": "../../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 3, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-citation-redirect.js", - "resolvedPath": "src/agents/tools/web-search-citation-redirect.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 4, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 17, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 21, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-config.js", - "resolvedPath": "src/agents/tools/web-search-provider-config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 22, - "kind": "import", - "specifier": "../../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 26, - "kind": "import", - "specifier": "../../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/google/src/gemini-web-search-provider.ts", - "line": 27, - "kind": "import", - "specifier": "../../../src/security/external-content.js", - "resolvedPath": "src/security/external-content.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/moonshot/src/kimi-web-search-provider.ts", - "line": 2, - "kind": "import", - "specifier": "../../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/moonshot/src/kimi-web-search-provider.ts", - "line": 3, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/moonshot/src/kimi-web-search-provider.ts", - "line": 16, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/moonshot/src/kimi-web-search-provider.ts", - "line": 20, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-config.js", - "resolvedPath": "src/agents/tools/web-search-provider-config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/moonshot/src/kimi-web-search-provider.ts", - "line": 21, - "kind": "import", - "specifier": "../../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/moonshot/src/kimi-web-search-provider.ts", - "line": 25, - "kind": "import", - "specifier": "../../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/moonshot/src/kimi-web-search-provider.ts", - "line": 26, - "kind": "import", - "specifier": "../../../src/security/external-content.js", - "resolvedPath": "src/security/external-content.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/perplexity/src/perplexity-web-search-provider.ts", - "line": 6, - "kind": "import", - "specifier": "../../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/perplexity/src/perplexity-web-search-provider.ts", - "line": 7, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/perplexity/src/perplexity-web-search-provider.ts", - "line": 25, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/perplexity/src/perplexity-web-search-provider.ts", - "line": 29, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-config.js", - "resolvedPath": "src/agents/tools/web-search-provider-config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/perplexity/src/perplexity-web-search-provider.ts", - "line": 30, - "kind": "import", - "specifier": "../../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/perplexity/src/perplexity-web-search-provider.ts", - "line": 35, - "kind": "import", - "specifier": "../../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/perplexity/src/perplexity-web-search-provider.ts", - "line": 36, - "kind": "import", - "specifier": "../../../src/security/external-content.js", - "resolvedPath": "src/security/external-content.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/xai/src/grok-web-search-provider.ts", - "line": 2, - "kind": "import", - "specifier": "../../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/xai/src/grok-web-search-provider.ts", - "line": 3, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/xai/src/grok-web-search-provider.ts", - "line": 16, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-common.js", - "resolvedPath": "src/agents/tools/web-search-provider-common.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/xai/src/grok-web-search-provider.ts", - "line": 20, - "kind": "import", - "specifier": "../../../src/agents/tools/web-search-provider-config.js", - "resolvedPath": "src/agents/tools/web-search-provider-config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/xai/src/grok-web-search-provider.ts", - "line": 21, - "kind": "import", - "specifier": "../../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/xai/src/grok-web-search-provider.ts", - "line": 25, - "kind": "import", - "specifier": "../../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/xai/src/grok-web-search-provider.ts", - "line": 26, - "kind": "import", - "specifier": "../../../src/security/external-content.js", - "resolvedPath": "src/security/external-content.js", + "specifier": "../../../src/whatsapp/normalize.js", + "resolvedPath": "src/whatsapp/normalize.js", "reason": "imports core src path outside plugin-sdk from an extension" } ] From dc20a7cd896882905a7ec2ed9fad7f2b428fdf79 Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM Date: Wed, 18 Mar 2026 05:42:51 +0000 Subject: [PATCH 313/393] Build: fix bundled plugin runtime symlinks --- scripts/stage-bundled-plugin-runtime.mjs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 07fd9e958f0..cbd28bc3b24 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -102,10 +102,6 @@ function linkPluginNodeModules(params) { if (params.distPluginDir) { removePathIfExists(path.join(params.distPluginDir, "node_modules")); } - if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { - return; - } - fs.symlinkSync(params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, symlinkType()); if (params.distPluginDir) { const distNodeModulesDir = path.join(params.distPluginDir, "node_modules"); From ff849613a495dbcf8a87efa043ef7c5acaef4104 Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM Date: Wed, 18 Mar 2026 05:42:54 +0000 Subject: [PATCH 314/393] Extensions: route Signal and xai through plugin-sdk --- extensions/signal/runtime-api.ts | 2 +- extensions/signal/src/accounts.ts | 2 +- extensions/xai/web-search.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/signal/runtime-api.ts b/extensions/signal/runtime-api.ts index 52ebf7ff363..3a84b043f2b 100644 --- a/extensions/signal/runtime-api.ts +++ b/extensions/signal/runtime-api.ts @@ -1,2 +1,2 @@ export * from "./src/index.js"; -export type { SignalAccountConfig } from "../../src/config/types.signal.js"; +export type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 2cc323dd33d..456db907685 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -4,7 +4,7 @@ import { resolveAccountEntry, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; -import type { SignalAccountConfig } from "../../../src/config/types.signal.js"; +import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index d1e3a03eb82..c1d97652d54 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -10,11 +10,11 @@ import { resolveTimeoutSeconds, resolveWebSearchProviderCredential, setScopedCredentialValue, + type WebSearchProviderPlugin, withTrustedWebToolsEndpoint, wrapWebContent, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; -import type { WebSearchProviderPlugin } from "../../src/plugins/types.js"; const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; From 937f118d8e777b1b9b1a934dc2edb63127f307c9 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 17 Mar 2026 22:53:34 -0700 Subject: [PATCH 315/393] Gateway: add docs hint for plugin override trust error (#49513) --- src/gateway/server-plugins.test.ts | 21 +++++++++++++++++++++ src/gateway/server-plugins.ts | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 1ad6bf858ef..c1df98dfde2 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -298,6 +298,27 @@ describe("loadGatewayPlugins", () => { }); }); + test("includes docs guidance when a plugin fallback override is not trusted", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-untrusted-plugin")); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + + await expect( + gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () => + runtime.run({ + sessionKey: "s-untrusted-override", + message: "use untrusted override", + provider: "anthropic", + model: "claude-haiku-4-5", + deliver: false, + }), + ), + ).rejects.toThrow( + 'plugin "voice-call" is not trusted for fallback provider/model override requests. See https://docs.openclaw.ai/tools/plugin#runtime-helpers and search for: plugins.entries..subagent.allowModelOverride', + ); + }); + test("allows trusted fallback model-only overrides when the model ref is canonical", async () => { const serverPlugins = await importServerPluginsModule(); const runtime = await createSubagentRuntime(serverPlugins, { diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index a997c93cbbc..071819be73e 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -155,7 +155,10 @@ function authorizeFallbackModelOverride(params: { if (!policy?.allowModelOverride) { return { allowed: false, - reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests.`, + reason: + `plugin "${pluginId}" is not trusted for fallback provider/model override requests. ` + + "See https://docs.openclaw.ai/tools/plugin#runtime-helpers and search for: " + + "plugins.entries..subagent.allowModelOverride", }; } if (policy.allowAnyModel) { From 7f0f8dd26802c7f3a333104844e815f7e84d3e9c Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 17 Mar 2026 22:54:18 -0700 Subject: [PATCH 316/393] feat: expose context-engine compaction delegate helper (#49061) * ContextEngine: add runtime compaction delegate helper * plugin-sdk: expose compaction delegate through compat * docs: clarify delegated plugin compaction * docs: use scoped compaction delegate import --- CHANGELOG.md | 1 + docs/concepts/compaction.md | 8 +++ docs/concepts/context-engine.md | 32 +++++++++--- docs/concepts/context.md | 4 +- docs/tools/plugin.md | 30 +++++++++++ docs/zh-CN/tools/plugin.md | 29 +++++++++++ src/context-engine/context-engine.test.ts | 35 +++++++++++++ src/context-engine/delegate.ts | 61 +++++++++++++++++++++++ src/context-engine/index.ts | 1 + src/context-engine/legacy.ts | 44 +--------------- src/plugin-sdk/compat.ts | 1 + src/plugin-sdk/core.ts | 1 + src/plugin-sdk/index.test.ts | 1 + src/plugin-sdk/index.ts | 1 + src/plugin-sdk/root-alias.test.ts | 14 ++++++ 15 files changed, 213 insertions(+), 50 deletions(-) create mode 100644 src/context-engine/delegate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 115481dd284..fa96121ab73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. - Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev. - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. +- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. ### Fixes diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index 5640fa51a35..550d3b385d4 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -108,6 +108,14 @@ summaries, vector retrieval, incremental condensation, etc. When a plugin engine sets `ownsCompaction: true`, OpenClaw delegates all compaction decisions to the engine and does not run built-in auto-compaction. +When `ownsCompaction` is `false` or unset, OpenClaw may still use Pi's +built-in in-attempt auto-compaction, but the active engine's `compact()` method +still handles `/compact` and overflow recovery. There is no automatic fallback +to the legacy engine's compaction path. + +If you are building a non-owning context engine, implement `compact()` by +calling `delegateCompactionToRuntime(...)` from `openclaw/plugin-sdk/core`. + ## Tips - Use `/compact` when sessions feel stale or context is bloated. diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index 87d5e87d85b..0b2ec1cd78b 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -14,7 +14,7 @@ It decides which messages to include, how to summarize older history, and how to manage context across subagent boundaries. OpenClaw ships with a built-in `legacy` engine. Plugins can register -alternative engines that replace the entire context pipeline. +alternative engines that replace the active context-engine lifecycle. ## Quick start @@ -194,13 +194,31 @@ Optional members: ### ownsCompaction -When `info.ownsCompaction` is `true`, the engine manages its own compaction -lifecycle. OpenClaw will not trigger the built-in auto-compaction; instead it -delegates entirely to the engine's `compact()` method. The engine may also -run compaction proactively in `afterTurn()`. +`ownsCompaction` controls whether Pi's built-in in-attempt auto-compaction stays +enabled for the run: -When `false` or unset, OpenClaw's built-in auto-compaction logic runs -alongside the engine. +- `true` — the engine owns compaction behavior. OpenClaw disables Pi's built-in + auto-compaction for that run, and the engine's `compact()` implementation is + responsible for `/compact`, overflow recovery compaction, and any proactive + compaction it wants to do in `afterTurn()`. +- `false` or unset — Pi's built-in auto-compaction may still run during prompt + execution, but the active engine's `compact()` method is still called for + `/compact` and overflow recovery. + +`ownsCompaction: false` does **not** mean OpenClaw automatically falls back to +the legacy engine's compaction path. + +That means there are two valid plugin patterns: + +- **Owning mode** — implement your own compaction algorithm and set + `ownsCompaction: true`. +- **Delegating mode** — set `ownsCompaction: false` and have `compact()` call + `delegateCompactionToRuntime(...)` from `openclaw/plugin-sdk/core` to use + OpenClaw's built-in compaction behavior. + +A no-op `compact()` is unsafe for an active non-owning engine because it +disables the normal `/compact` and overflow-recovery compaction path for that +engine slot. ## Configuration reference diff --git a/docs/concepts/context.md b/docs/concepts/context.md index d5316ea8bf8..356f8b810c3 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -157,7 +157,9 @@ By default, OpenClaw uses the built-in `legacy` context engine for assembly and compaction. If you install a plugin that provides `kind: "context-engine"` and select it with `plugins.slots.contextEngine`, OpenClaw delegates context assembly, `/compact`, and related subagent context lifecycle hooks to that -engine instead. See [Context Engine](/concepts/context-engine) for the full +engine instead. `ownsCompaction: false` does not auto-fallback to the legacy +engine; the active engine must still implement `compact()` correctly. See +[Context Engine](/concepts/context-engine) for the full pluggable interface, lifecycle hooks, and configuration. ## What `/context` actually reports diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 0cc98187550..e04c30f6003 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1810,6 +1810,36 @@ export default function (api) { } ``` +If your engine does **not** own the compaction algorithm, keep `compact()` +implemented and delegate it explicitly: + +```ts +import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core"; + +export default function (api) { + api.registerContextEngine("my-memory-engine", () => ({ + info: { + id: "my-memory-engine", + name: "My Memory Engine", + ownsCompaction: false, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact(params) { + return await delegateCompactionToRuntime(params); + }, + })); +} +``` + +`ownsCompaction: false` does not automatically fall back to legacy compaction. +If your engine is active, its `compact()` method still handles `/compact` and +overflow recovery. + Then enable it in config: ```json5 diff --git a/docs/zh-CN/tools/plugin.md b/docs/zh-CN/tools/plugin.md index a2ade46ffbc..775d94eb751 100644 --- a/docs/zh-CN/tools/plugin.md +++ b/docs/zh-CN/tools/plugin.md @@ -950,6 +950,35 @@ export default function (api) { } ``` +如果你的引擎**并不拥有**压缩算法,仍然要实现 `compact()`,并显式委托给运行时: + +```ts +import { delegateCompactionToRuntime } from "openclaw/plugin-sdk"; + +export default function (api) { + api.registerContextEngine("my-memory-engine", () => ({ + info: { + id: "my-memory-engine", + name: "My Memory Engine", + ownsCompaction: false, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact(params) { + return await delegateCompactionToRuntime(params); + }, + })); +} +``` + +`ownsCompaction: false` 不会自动回退到 legacy 压缩路径。 +只要该引擎处于激活状态,它自己的 `compact()` 仍然会处理 `/compact` +和溢出恢复。 + 然后在配置中启用它: ```json5 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 82c3501343b..cf24bfd7a07 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -5,6 +5,7 @@ import { compactEmbeddedPiSessionDirect } from "../agents/pi-embedded-runner/com // We dynamically import the registry so we can get a fresh module per test // group when needed. For most groups we use the shared singleton directly. // --------------------------------------------------------------------------- +import { delegateCompactionToRuntime } from "./delegate.js"; import { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js"; import { registerContextEngine, @@ -255,6 +256,40 @@ describe("Engine contract tests", () => { }), ); }); + + it("delegateCompactionToRuntime reuses the legacy runtime bridge", async () => { + const result = await delegateCompactionToRuntime({ + sessionId: "s2", + sessionFile: "/tmp/session.json", + tokenBudget: 4096, + runtimeContext: { + workspaceDir: "/tmp/workspace", + currentTokenCount: 12345, + }, + }); + + expect(mockedCompactEmbeddedPiSessionDirect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "s2", + sessionFile: "/tmp/session.json", + tokenBudget: 4096, + currentTokenCount: 12345, + workspaceDir: "/tmp/workspace", + }), + ); + expect(result).toEqual({ + ok: true, + compacted: false, + reason: "mock compaction", + result: { + summary: "", + firstKeptEntryId: "", + tokensBefore: 0, + tokensAfter: 0, + details: undefined, + }, + }); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts new file mode 100644 index 00000000000..6d03045d795 --- /dev/null +++ b/src/context-engine/delegate.ts @@ -0,0 +1,61 @@ +import type { ContextEngine, CompactResult, ContextEngineRuntimeContext } from "./types.js"; + +/** + * Delegate a context-engine compaction request to OpenClaw's built-in runtime compaction path. + * + * This is the same bridge used by the legacy context engine. Third-party + * engines can call it from their own `compact()` implementations when they do + * not own the compaction algorithm but still need `/compact` and overflow + * recovery to use the stock runtime behavior. + * + * Note: `compactionTarget` is part of the public `compact()` contract, but the + * built-in runtime compaction path does not expose that knob. This helper + * ignores it to preserve legacy behavior; engines that need target-specific + * compaction should implement their own `compact()` algorithm. + */ +export async function delegateCompactionToRuntime( + params: Parameters[0], +): Promise { + // Import through a dedicated runtime boundary so the lazy edge remains effective. + const { compactEmbeddedPiSessionDirect } = + await import("../agents/pi-embedded-runner/compact.runtime.js"); + + // runtimeContext carries the full CompactEmbeddedPiSessionParams fields set + // by runtime callers. We spread them and override the fields that come from + // the public ContextEngine compact() signature directly. + const runtimeContext: ContextEngineRuntimeContext = params.runtimeContext ?? {}; + const currentTokenCount = + params.currentTokenCount ?? + (typeof runtimeContext.currentTokenCount === "number" && + Number.isFinite(runtimeContext.currentTokenCount) && + runtimeContext.currentTokenCount > 0 + ? Math.floor(runtimeContext.currentTokenCount) + : undefined); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge runtimeContext matches CompactEmbeddedPiSessionParams + const result = await compactEmbeddedPiSessionDirect({ + ...runtimeContext, + sessionId: params.sessionId, + sessionFile: params.sessionFile, + tokenBudget: params.tokenBudget, + ...(currentTokenCount !== undefined ? { currentTokenCount } : {}), + force: params.force, + customInstructions: params.customInstructions, + workspaceDir: (runtimeContext.workspaceDir as string) ?? process.cwd(), + } as Parameters[0]); + + return { + ok: result.ok, + compacted: result.compacted, + reason: result.reason, + result: result.result + ? { + summary: result.result.summary, + firstKeptEntryId: result.result.firstKeptEntryId, + tokensBefore: result.result.tokensBefore, + tokensAfter: result.result.tokensAfter, + details: result.result.details, + } + : undefined, + }; +} diff --git a/src/context-engine/index.ts b/src/context-engine/index.ts index fa3193d4030..09cc4c8e94e 100644 --- a/src/context-engine/index.ts +++ b/src/context-engine/index.ts @@ -15,5 +15,6 @@ export { export type { ContextEngineFactory } from "./registry.js"; export { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js"; +export { delegateCompactionToRuntime } from "./delegate.js"; export { ensureContextEnginesInitialized } from "./init.js"; diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index 3080e9aba0b..09659c968fb 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { delegateCompactionToRuntime } from "./delegate.js"; import { registerContextEngineForOwner } from "./registry.js"; import type { ContextEngine, @@ -74,48 +75,7 @@ export class LegacyContextEngine implements ContextEngine { customInstructions?: string; runtimeContext?: ContextEngineRuntimeContext; }): Promise { - // Import through a dedicated runtime boundary so the lazy edge remains effective. - const { compactEmbeddedPiSessionDirect } = - await import("../agents/pi-embedded-runner/compact.runtime.js"); - - // runtimeContext carries the full CompactEmbeddedPiSessionParams fields - // set by the caller in run.ts. We spread them and override the fields - // that come from the ContextEngine compact() signature directly. - const runtimeContext = params.runtimeContext ?? {}; - const currentTokenCount = - params.currentTokenCount ?? - (typeof runtimeContext.currentTokenCount === "number" && - Number.isFinite(runtimeContext.currentTokenCount) && - runtimeContext.currentTokenCount > 0 - ? Math.floor(runtimeContext.currentTokenCount) - : undefined); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge runtimeContext matches CompactEmbeddedPiSessionParams - const result = await compactEmbeddedPiSessionDirect({ - ...runtimeContext, - sessionId: params.sessionId, - sessionFile: params.sessionFile, - tokenBudget: params.tokenBudget, - ...(currentTokenCount !== undefined ? { currentTokenCount } : {}), - force: params.force, - customInstructions: params.customInstructions, - workspaceDir: (runtimeContext.workspaceDir as string) ?? process.cwd(), - } as Parameters[0]); - - return { - ok: result.ok, - compacted: result.compacted, - reason: result.reason, - result: result.result - ? { - summary: result.result.summary, - firstKeptEntryId: result.result.firstKeptEntryId, - tokensBefore: result.result.tokensBefore, - tokensAfter: result.result.tokensAfter, - details: result.result.details, - } - : undefined, - }; + return await delegateCompactionToRuntime(params); } async dispose(): Promise { diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 9892bbc8fc7..83a2a21e75e 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -19,6 +19,7 @@ if (shouldWarnCompatImport) { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; +export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { createAccountStatusSink } from "./channel-lifecycle.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 232989ebbfc..ba49614389d 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -70,6 +70,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 07d4dde6d98..a744113a8cf 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -64,6 +64,7 @@ describe("plugin-sdk exports", () => { it("keeps the root runtime surface intentionally small", () => { expect(typeof sdk.emptyPluginConfigSchema).toBe("function"); + expect(typeof sdk.delegateCompactionToRuntime).toBe("function"); expect(Object.prototype.hasOwnProperty.call(sdk, "resolveControlCommandGate")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "buildAgentSessionKey")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "isDangerousNameMatchingEnabled")).toBe(false); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a683f5437ca..5bb67920734 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -67,3 +67,4 @@ export type { ContextEngineFactory } from "../context-engine/registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; +export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 3c30dbee6be..6767ca773e3 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -127,6 +127,20 @@ describe("plugin-sdk root alias", () => { expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined(); }); + it("forwards delegateCompactionToRuntime through the compat-backed root alias", () => { + const delegateCompactionToRuntime = () => "delegated"; + const lazyModule = loadRootAliasWithStubs({ + monolithicExports: { + delegateCompactionToRuntime, + }, + }); + const lazyRootSdk = lazyModule.moduleExports; + + expect(typeof lazyRootSdk.delegateCompactionToRuntime).toBe("function"); + expect(lazyRootSdk.delegateCompactionToRuntime).toBe(delegateCompactionToRuntime); + expect("delegateCompactionToRuntime" in lazyRootSdk).toBe(true); + }); + it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); expect(typeof rootSdk.default).toBe("object"); From 7b27f8a9ae3a5fc1231731b8b7f98c903578ff73 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 22:55:24 -0700 Subject: [PATCH 317/393] docs(refactor): replace seam terminology with capability/surface Align refactor docs with the public capability model vocabulary. Co-Authored-By: Claude Opus 4.6 --- docs/refactor/cluster.md | 4 ++-- docs/refactor/firecrawl-extension.md | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/refactor/cluster.md b/docs/refactor/cluster.md index 1d9c8e6f119..db2d9b1276f 100644 --- a/docs/refactor/cluster.md +++ b/docs/refactor/cluster.md @@ -111,7 +111,7 @@ Strong examples: - `extensions/matrix/src/setup-surface.ts` - `extensions/irc/src/setup-surface.ts` -Existing helper seam: +Existing helper surface: - `src/channels/plugins/setup-wizard-helpers.ts` @@ -187,7 +187,7 @@ Strong examples: - `extensions/telegram/src/channel.ts` - `extensions/nextcloud-talk/src/channel.ts` -Existing helper seam: +Existing helper surface: - `src/plugin-sdk/channel-lifecycle.ts` diff --git a/docs/refactor/firecrawl-extension.md b/docs/refactor/firecrawl-extension.md index e25e010e7b1..273f9667916 100644 --- a/docs/refactor/firecrawl-extension.md +++ b/docs/refactor/firecrawl-extension.md @@ -2,7 +2,7 @@ summary: "Design for an opt-in Firecrawl extension that adds search/scrape value without hardwiring Firecrawl into core defaults" read_when: - Designing Firecrawl integration work - - Evaluating web_search/web_fetch plugin seams + - Evaluating web_search/web_fetch plugin extension surfaces - Deciding whether Firecrawl belongs in core or as an extension title: "Firecrawl Extension Design" --- @@ -38,7 +38,7 @@ That combination argues for an extension, not more Firecrawl-specific logic in t - **Opt-in, vendor-scoped**: no auto-enable, no setup hijack, no default tool-profile widening. - **Extension owns Firecrawl-specific config**: prefer plugin config over growing `tools.web.*` again. -- **Useful on day one**: works even if core `web_search` / `web_fetch` seams stay unchanged. +- **Useful on day one**: works even if core `web_search` / `web_fetch` extension surfaces stay unchanged. - **Security-first**: endpoint fetches use the same guarded networking posture as other web tools. - **Self-hosted-friendly**: config + env fallback, explicit base URL, no hosted-only assumptions. @@ -208,15 +208,15 @@ Recommended shape: - allow any registered plugin provider id at runtime, - validate provider-specific config via the provider plugin or a generic provider bag. -### Phase 3: optional `web_fetch` provider seam +### Phase 3: optional `web_fetch` provider capability Do this only if maintainers want vendor-specific fetch backends to participate in `web_fetch`. Needed core addition: -- `registerWebFetchProvider` or equivalent fetch-backend seam +- `registerWebFetchProvider` or equivalent fetch-backend extension surface -Without that seam, the extension should keep `firecrawl_scrape` as an explicit tool rather than trying to patch built-in `web_fetch`. +Without that capability, the extension should keep `firecrawl_scrape` as an explicit tool rather than trying to patch built-in `web_fetch`. ## Security requirements @@ -249,7 +249,7 @@ This belongs as an extension, not a prompt-only skill. - Self-hosted Firecrawl works with config/env fallback. - Extension endpoint fetches use guarded networking. - No new Firecrawl-specific core onboarding/default behavior. -- Core can later adopt plugin-native `web_search` / `web_fetch` seams without redesigning the extension. +- Core can later adopt plugin-native `web_search` / `web_fetch` extension surfaces without redesigning the extension. ## Recommended implementation order @@ -257,4 +257,4 @@ This belongs as an extension, not a prompt-only skill. 2. Build `firecrawl_search` 3. Add docs and examples 4. If desired, generalize `web_search` provider loading so the extension can back `web_search` -5. Only then consider a true `web_fetch` provider seam +5. Only then consider a true `web_fetch` provider capability From 2ef28a7a3e71b37ed48c551275e0529e0b4afecb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 21:39:32 -0700 Subject: [PATCH 318/393] Plugins: internalize zalouser SDK imports --- extensions/zalouser/index.ts | 2 +- extensions/zalouser/runtime-api.ts | 1 + extensions/zalouser/src/accounts.ts | 2 +- extensions/zalouser/src/channel.setup.ts | 2 +- extensions/zalouser/src/channel.ts | 6 +++--- extensions/zalouser/src/config-schema.ts | 2 +- extensions/zalouser/src/monitor.ts | 6 +++--- extensions/zalouser/src/probe.ts | 2 +- extensions/zalouser/src/qr-temp-file.ts | 2 +- extensions/zalouser/src/runtime.ts | 2 +- extensions/zalouser/src/shared.ts | 4 ++-- extensions/zalouser/src/status-issues.ts | 2 +- extensions/zalouser/src/test-helpers.ts | 2 +- extensions/zalouser/src/zalo-js.ts | 2 +- 14 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 extensions/zalouser/runtime-api.ts diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index c5d4cc2ba24..b6a9a1699e0 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -1,5 +1,5 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; -import type { AnyAgentTool } from "openclaw/plugin-sdk/zalouser"; +import type { AnyAgentTool } from "./runtime-api.js"; import { zalouserPlugin } from "./src/channel.js"; import { setZalouserRuntime } from "./src/runtime.js"; import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js"; diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts new file mode 100644 index 00000000000..ef062d07887 --- /dev/null +++ b/extensions/zalouser/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/zalouser"; diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 71385db0e17..05436e86ba5 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/zalouser"; +import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js"; import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js"; import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js"; diff --git a/extensions/zalouser/src/channel.setup.ts b/extensions/zalouser/src/channel.setup.ts index 1280bbb0e51..7373b10977a 100644 --- a/extensions/zalouser/src/channel.setup.ts +++ b/extensions/zalouser/src/channel.setup.ts @@ -1,4 +1,4 @@ -import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser"; +import type { ChannelPlugin } from "../runtime-api.js"; import type { ResolvedZalouserAccount } from "./accounts.js"; import { zalouserSetupAdapter } from "./setup-core.js"; import { zalouserSetupWizard } from "./setup-surface.js"; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index b86b3ef8156..c1c90affe9c 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,6 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import type { ChannelAccountSnapshot, ChannelDirectoryEntry, @@ -8,7 +9,7 @@ import type { ChannelPlugin, OpenClawConfig, GroupToolPolicyConfig, -} from "openclaw/plugin-sdk/zalouser"; +} from "../runtime-api.js"; import { buildChannelSendResult, buildBaseAccountStatusSnapshot, @@ -17,8 +18,7 @@ import { isNumericTargetId, normalizeAccountId, sendPayloadWithChunkedTextAndMedia, -} from "openclaw/plugin-sdk/zalouser"; -import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; +} from "../runtime-api.js"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index e3c4c4ae7ea..478ac85e985 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -4,8 +4,8 @@ import { DmPolicySchema, GroupPolicySchema, } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser"; import { z } from "zod"; +import { MarkdownConfigSchema, ToolPolicySchema } from "../runtime-api.js"; const groupConfigSchema = z.object({ allow: z.boolean().optional(), diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index e4acdd61cb9..5ae729c703e 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -10,12 +10,13 @@ import { clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled, } from "openclaw/plugin-sdk/reply-history"; +import { createDeferred } from "../../shared/deferred.js"; import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload, RuntimeEnv, -} from "openclaw/plugin-sdk/zalouser"; +} from "../runtime-api.js"; import { createTypingCallbacks, createScopedPairingAccess, @@ -33,8 +34,7 @@ import { sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, -} from "openclaw/plugin-sdk/zalouser"; -import { createDeferred } from "../../shared/deferred.js"; +} from "../runtime-api.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry, diff --git a/extensions/zalouser/src/probe.ts b/extensions/zalouser/src/probe.ts index b3213010f26..bb3daaabbb3 100644 --- a/extensions/zalouser/src/probe.ts +++ b/extensions/zalouser/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/zalouser"; +import type { BaseProbeResult } from "../runtime-api.js"; import type { ZcaUserInfo } from "./types.js"; import { getZaloUserInfo } from "./zalo-js.js"; diff --git a/extensions/zalouser/src/qr-temp-file.ts b/extensions/zalouser/src/qr-temp-file.ts index 07babfcc731..0c201d48a33 100644 --- a/extensions/zalouser/src/qr-temp-file.ts +++ b/extensions/zalouser/src/qr-temp-file.ts @@ -1,6 +1,6 @@ import fsp from "node:fs/promises"; import path from "node:path"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/zalouser"; +import { resolvePreferredOpenClawTmpDir } from "../runtime-api.js"; export async function writeQrDataUrlToTempFile( qrDataUrl: string, diff --git a/extensions/zalouser/src/runtime.ts b/extensions/zalouser/src/runtime.ts index eaa93ec1b20..fb418e3af94 100644 --- a/extensions/zalouser/src/runtime.ts +++ b/extensions/zalouser/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser"; +import type { PluginRuntime } from "../runtime-api.js"; const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } = createPluginRuntimeStore("Zalouser runtime not initialized"); diff --git a/extensions/zalouser/src/shared.ts b/extensions/zalouser/src/shared.ts index c48c80b4903..4d4e7f1dff2 100644 --- a/extensions/zalouser/src/shared.ts +++ b/extensions/zalouser/src/shared.ts @@ -1,6 +1,6 @@ import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser"; -import { buildChannelConfigSchema, formatAllowFromLowercase } from "openclaw/plugin-sdk/zalouser"; +import type { ChannelPlugin } from "../runtime-api.js"; +import { buildChannelConfigSchema, formatAllowFromLowercase } from "../runtime-api.js"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, diff --git a/extensions/zalouser/src/status-issues.ts b/extensions/zalouser/src/status-issues.ts index b42c915e00a..ca324f6d169 100644 --- a/extensions/zalouser/src/status-issues.ts +++ b/extensions/zalouser/src/status-issues.ts @@ -1,5 +1,5 @@ -import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk/zalouser"; import { coerceStatusIssueAccountId, readStatusIssueFields } from "../../shared/status-issues.js"; +import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../runtime-api.js"; const ZALOUSER_STATUS_FIELDS = [ "accountId", diff --git a/extensions/zalouser/src/test-helpers.ts b/extensions/zalouser/src/test-helpers.ts index 8b43e182c54..7826938450d 100644 --- a/extensions/zalouser/src/test-helpers.ts +++ b/extensions/zalouser/src/test-helpers.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser"; +import type { RuntimeEnv } from "../runtime-api.js"; import type { ResolvedZalouserAccount } from "./types.js"; export function createZalouserRuntimeEnv(): RuntimeEnv { diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 8cc20e59158..3d1a146ea9f 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -4,7 +4,7 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveStateDir as resolvePluginStateDir } from "openclaw/plugin-sdk/state-paths"; -import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/zalouser"; +import { loadOutboundMediaFromUrl } from "../runtime-api.js"; import { normalizeZaloReactionIcon } from "./reaction.js"; import type { ZaloAuthStatus, From 645c5bda2cbe7fbaa344b7a0638e76c58ba6b7bd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 21:40:10 -0700 Subject: [PATCH 319/393] Plugins: internalize zalo SDK imports --- extensions/zalo/runtime-api.ts | 1 + extensions/zalo/src/accounts.ts | 2 +- extensions/zalo/src/actions.ts | 4 ++-- extensions/zalo/src/channel.runtime.ts | 10 +++++++--- extensions/zalo/src/channel.ts | 10 ++++------ extensions/zalo/src/config-schema.ts | 2 +- extensions/zalo/src/group-access.ts | 5 +++-- extensions/zalo/src/monitor.ts | 4 ++-- extensions/zalo/src/monitor.webhook.ts | 4 ++-- extensions/zalo/src/probe.ts | 2 +- extensions/zalo/src/runtime-api.ts | 1 + extensions/zalo/src/runtime.ts | 2 +- extensions/zalo/src/secret-input.ts | 2 +- extensions/zalo/src/send.ts | 2 +- extensions/zalo/src/status-issues.ts | 2 +- extensions/zalo/src/token.ts | 2 +- extensions/zalo/src/types.ts | 2 +- 17 files changed, 31 insertions(+), 26 deletions(-) create mode 100644 extensions/zalo/runtime-api.ts create mode 100644 extensions/zalo/src/runtime-api.ts diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts new file mode 100644 index 00000000000..666b1c2a59d --- /dev/null +++ b/extensions/zalo/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/zalo"; diff --git a/extensions/zalo/src/accounts.ts b/extensions/zalo/src/accounts.ts index 205a6b94474..e12503561f9 100644 --- a/extensions/zalo/src/accounts.ts +++ b/extensions/zalo/src/accounts.ts @@ -1,6 +1,6 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { resolveZaloToken } from "./token.js"; +import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js"; import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js"; export type { ResolvedZaloAccount }; diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index b741d358c5a..b6b5c5b95f3 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -3,8 +3,8 @@ import type { ChannelMessageActionAdapter, ChannelMessageActionName, OpenClawConfig, -} from "openclaw/plugin-sdk/zalo"; -import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; +} from "./runtime-api.js"; +import { extractToolSend, jsonResult, readStringParam } from "./runtime-api.js"; import { listEnabledZaloAccounts } from "./accounts.js"; const loadZaloActionsRuntime = createLazyRuntimeNamedExport( diff --git a/extensions/zalo/src/channel.runtime.ts b/extensions/zalo/src/channel.runtime.ts index 86ddc97dcf3..39702a439fc 100644 --- a/extensions/zalo/src/channel.runtime.ts +++ b/extensions/zalo/src/channel.runtime.ts @@ -1,12 +1,16 @@ import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/zalo"; import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; +import { + PAIRING_APPROVED_MESSAGE, + type ChannelPlugin, + type OpenClawConfig, +} from "./runtime-api.js"; export async function notifyZaloPairingApproval(params: { - cfg: import("openclaw/plugin-sdk/zalo").OpenClawConfig; + cfg: OpenClawConfig; id: string; }) { const { resolveZaloAccount } = await import("./accounts.js"); @@ -42,7 +46,7 @@ export async function probeZaloAccount(params: { export async function startZaloGatewayAccount( ctx: Parameters< NonNullable< - NonNullable["startAccount"] + NonNullable["startAccount"] > >[0], ) { diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 57f74ca01d2..a9cfea6f9ad 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -9,11 +9,6 @@ import { collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import type { - ChannelAccountSnapshot, - ChannelPlugin, - OpenClawConfig, -} from "openclaw/plugin-sdk/zalo"; import { buildBaseAccountStatusSnapshot, buildChannelConfigSchema, @@ -25,7 +20,10 @@ import { listDirectoryUserEntriesFromAllowFrom, isNumericTargetId, sendPayloadWithChunkedTextAndMedia, -} from "openclaw/plugin-sdk/zalo"; + type ChannelAccountSnapshot, + type ChannelPlugin, + type OpenClawConfig, +} from "./runtime-api.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index d70e1441d9b..70b863779c1 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -4,9 +4,9 @@ import { DmPolicySchema, GroupPolicySchema, } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo"; import { z } from "zod"; import { buildSecretInputSchema } from "./secret-input.js"; +import { MarkdownConfigSchema } from "./runtime-api.js"; const zaloAccountSchema = z.object({ name: z.string().optional(), diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts index 56a929cc23a..bde9e205f48 100644 --- a/extensions/zalo/src/group-access.ts +++ b/extensions/zalo/src/group-access.ts @@ -1,9 +1,10 @@ -import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk/zalo"; import { evaluateSenderGroupAccess, isNormalizedSenderAllowed, resolveOpenProviderRuntimeGroupPolicy, -} from "openclaw/plugin-sdk/zalo"; + type GroupPolicy, + type SenderGroupAccessDecision, +} from "./runtime-api.js"; const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i; diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index d82c0d96ba4..ee97207cf3b 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -3,7 +3,7 @@ import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload, -} from "openclaw/plugin-sdk/zalo"; +} from "./runtime-api.js"; import { createTypingCallbacks, createScopedPairingAccess, @@ -19,7 +19,7 @@ import { resolveWebhookPath, waitForAbortSignal, warnMissingProviderGroupPolicyFallbackOnce, -} from "openclaw/plugin-sdk/zalo"; +} from "./runtime-api.js"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index ab218dbd7a6..e058dcc453c 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -1,6 +1,5 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { createDedupeCache, createFixedWindowRateLimiter, @@ -16,7 +15,8 @@ import { WEBHOOK_ANOMALY_COUNTER_DEFAULTS, WEBHOOK_RATE_LIMIT_DEFAULTS, resolveClientIp, -} from "openclaw/plugin-sdk/zalo"; + type OpenClawConfig, +} from "./runtime-api.js"; import type { ResolvedZaloAccount } from "./accounts.js"; import type { ZaloFetch, ZaloUpdate } from "./api.js"; import type { ZaloRuntimeEnv } from "./monitor.js"; diff --git a/extensions/zalo/src/probe.ts b/extensions/zalo/src/probe.ts index 67015ac5f08..544097b9514 100644 --- a/extensions/zalo/src/probe.ts +++ b/extensions/zalo/src/probe.ts @@ -1,5 +1,5 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/zalo"; import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js"; +import type { BaseProbeResult } from "./runtime-api.js"; export type ZaloProbeResult = BaseProbeResult & { bot?: ZaloBotInfo; diff --git a/extensions/zalo/src/runtime-api.ts b/extensions/zalo/src/runtime-api.ts new file mode 100644 index 00000000000..ece735819df --- /dev/null +++ b/extensions/zalo/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "../runtime-api.js"; diff --git a/extensions/zalo/src/runtime.ts b/extensions/zalo/src/runtime.ts index f36309db5c5..f454924991b 100644 --- a/extensions/zalo/src/runtime.ts +++ b/extensions/zalo/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/zalo"; +import type { PluginRuntime } from "./runtime-api.js"; const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } = createPluginRuntimeStore("Zalo runtime not initialized"); diff --git a/extensions/zalo/src/secret-input.ts b/extensions/zalo/src/secret-input.ts index bf218d1e48b..b32083456e7 100644 --- a/extensions/zalo/src/secret-input.ts +++ b/extensions/zalo/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/zalo"; +} from "./runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index e38427fcb14..d83bd16114d 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { resolveZaloAccount } from "./accounts.js"; import type { ZaloFetch } from "./api.js"; import { sendMessage, sendPhoto } from "./api.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { resolveZaloToken } from "./token.js"; +import type { OpenClawConfig } from "./runtime-api.js"; export type ZaloSendOptions = { token?: string; diff --git a/extensions/zalo/src/status-issues.ts b/extensions/zalo/src/status-issues.ts index c19992a64ee..28e2f333c80 100644 --- a/extensions/zalo/src/status-issues.ts +++ b/extensions/zalo/src/status-issues.ts @@ -1,5 +1,5 @@ -import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk/zalo"; import { coerceStatusIssueAccountId, readStatusIssueFields } from "../../shared/status-issues.js"; +import type { ChannelAccountSnapshot, ChannelStatusIssue } from "./runtime-api.js"; const ZALO_STATUS_FIELDS = ["accountId", "enabled", "configured", "dmPolicy"] as const; diff --git a/extensions/zalo/src/token.ts b/extensions/zalo/src/token.ts index 9e8eec34caa..c593cb5b824 100644 --- a/extensions/zalo/src/token.ts +++ b/extensions/zalo/src/token.ts @@ -1,8 +1,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; -import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js"; import type { ZaloConfig } from "./types.js"; +import type { BaseTokenResolution } from "./runtime-api.js"; export type ZaloTokenResolution = BaseTokenResolution & { source: "env" | "config" | "configFile" | "none"; diff --git a/extensions/zalo/src/types.ts b/extensions/zalo/src/types.ts index f112f5f69b9..9246d9812e6 100644 --- a/extensions/zalo/src/types.ts +++ b/extensions/zalo/src/types.ts @@ -1,4 +1,4 @@ -import type { SecretInput } from "openclaw/plugin-sdk/zalo"; +import type { SecretInput } from "./runtime-api.js"; export type ZaloAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ From 5642fb2682fc3bf7f3b51ff2db34dbca0f0571df Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 21:41:01 -0700 Subject: [PATCH 320/393] Plugins: internalize twitch SDK imports --- extensions/twitch/api.ts | 1 + extensions/twitch/src/config-schema.ts | 2 +- extensions/twitch/src/config.ts | 2 +- extensions/twitch/src/monitor.ts | 4 ++-- extensions/twitch/src/plugin.ts | 4 ++-- extensions/twitch/src/probe.ts | 2 +- extensions/twitch/src/runtime.ts | 2 +- extensions/twitch/src/send.ts | 2 +- extensions/twitch/src/status.ts | 2 +- extensions/twitch/src/test-fixtures.ts | 2 +- extensions/twitch/src/token.ts | 6 +----- extensions/twitch/src/twitch-client.ts | 2 +- extensions/twitch/src/types.ts | 2 +- 13 files changed, 15 insertions(+), 18 deletions(-) diff --git a/extensions/twitch/api.ts b/extensions/twitch/api.ts index 7c705aec6e5..4743a12fb3b 100644 --- a/extensions/twitch/api.ts +++ b/extensions/twitch/api.ts @@ -1 +1,2 @@ +export * from "openclaw/plugin-sdk/twitch"; export * from "./src/setup-surface.js"; diff --git a/extensions/twitch/src/config-schema.ts b/extensions/twitch/src/config-schema.ts index 1b45004ba6b..32bea8075e0 100644 --- a/extensions/twitch/src/config-schema.ts +++ b/extensions/twitch/src/config-schema.ts @@ -1,5 +1,5 @@ -import { MarkdownConfigSchema } from "openclaw/plugin-sdk/twitch"; import { z } from "zod"; +import { MarkdownConfigSchema } from "../api.js"; /** * Twitch user roles that can be allowed to interact with the bot diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index de960f4dc8a..5e7a8fa8441 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; +import type { OpenClawConfig } from "../api.js"; import type { TwitchAccountConfig } from "./types.js"; /** diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index f5c3d690b52..3678d1d175d 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -5,8 +5,8 @@ * resolves agent routes, and handles replies. */ -import type { ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk/twitch"; -import { createReplyPrefixOptions } from "openclaw/plugin-sdk/twitch"; +import type { ReplyPayload, OpenClawConfig } from "../api.js"; +import { createReplyPrefixOptions } from "../api.js"; import { checkTwitchAccessControl } from "./access-control.js"; import { getOrCreateClientManager } from "./client-manager-registry.js"; import { getTwitchRuntime } from "./runtime.js"; diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 490b741d989..59e016d4473 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -5,9 +5,9 @@ * This is the primary entry point for the Twitch channel integration. */ -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/twitch"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; +import type { OpenClawConfig } from "../api.js"; +import { buildChannelConfigSchema } from "../api.js"; import { twitchMessageActions } from "./actions.js"; import { removeClientManager } from "./client-manager-registry.js"; import { TwitchConfigSchema } from "./config-schema.js"; diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts index 7ce02501007..f22243e76ee 100644 --- a/extensions/twitch/src/probe.ts +++ b/extensions/twitch/src/probe.ts @@ -1,6 +1,6 @@ import { StaticAuthProvider } from "@twurple/auth"; import { ChatClient } from "@twurple/chat"; -import type { BaseProbeResult } from "openclaw/plugin-sdk/twitch"; +import type { BaseProbeResult } from "../api.js"; import type { TwitchAccountConfig } from "./types.js"; import { normalizeToken } from "./utils/twitch.js"; diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts index 2b2806cfdb3..b5edc038816 100644 --- a/extensions/twitch/src/runtime.ts +++ b/extensions/twitch/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/twitch"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = createPluginRuntimeStore("Twitch runtime not initialized"); diff --git a/extensions/twitch/src/send.ts b/extensions/twitch/src/send.ts index f62aadc0e10..3b9f16d19c2 100644 --- a/extensions/twitch/src/send.ts +++ b/extensions/twitch/src/send.ts @@ -5,7 +5,7 @@ * They support dependency injection via the `deps` parameter for testability. */ -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; +import type { OpenClawConfig } from "../api.js"; import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { resolveTwitchToken } from "./token.js"; diff --git a/extensions/twitch/src/status.ts b/extensions/twitch/src/status.ts index c30e129f9f1..593cdcd25e8 100644 --- a/extensions/twitch/src/status.ts +++ b/extensions/twitch/src/status.ts @@ -4,7 +4,7 @@ * Detects and reports configuration issues for Twitch accounts. */ -import type { ChannelStatusIssue } from "openclaw/plugin-sdk/twitch"; +import type { ChannelStatusIssue } from "../api.js"; import { getAccountConfig } from "./config.js"; import { resolveTwitchToken } from "./token.js"; import type { ChannelAccountSnapshot } from "./types.js"; diff --git a/extensions/twitch/src/test-fixtures.ts b/extensions/twitch/src/test-fixtures.ts index efc5877765a..664e01cde3f 100644 --- a/extensions/twitch/src/test-fixtures.ts +++ b/extensions/twitch/src/test-fixtures.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; import { afterEach, beforeEach, vi } from "vitest"; +import type { OpenClawConfig } from "../api.js"; export const BASE_TWITCH_TEST_ACCOUNT = { username: "testbot", diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts index 76f0c2007aa..840aa9b568f 100644 --- a/extensions/twitch/src/token.ts +++ b/extensions/twitch/src/token.ts @@ -9,11 +9,7 @@ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only) */ -import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - type OpenClawConfig, -} from "openclaw/plugin-sdk/twitch"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig } from "../api.js"; export type TwitchTokenSource = "env" | "config" | "none"; diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index deafd4e01b9..09fc3db264e 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -1,6 +1,6 @@ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth"; import { ChatClient, LogLevel } from "@twurple/chat"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; +import type { OpenClawConfig } from "../api.js"; import { resolveTwitchToken } from "./token.js"; import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js"; import { normalizeToken } from "./utils/twitch.js"; diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index 8bb677bdc3e..f767b8aecd3 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -22,7 +22,7 @@ import type { OpenClawConfig, OutboundDeliveryResult, RuntimeEnv, -} from "openclaw/plugin-sdk/twitch"; +} from "../api.js"; // ============================================================================ // Twitch-Specific Types From 0a065bc6c222076d9d4debe17e2e130d950fc117 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 21:42:40 -0700 Subject: [PATCH 321/393] Plugins: guard channel api barrels --- src/plugin-sdk/channel-import-guardrails.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 52788b3f41c..d6448856334 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -135,6 +135,9 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "thread-ownership", "tlon", "voice-call", + "twitch", + "zalo", + "zalouser", ] as const; const LOCAL_EXTENSION_API_BARREL_EXCEPTIONS = [ From ed479f96a1ebac10784bb39858aa8c79ed8e148d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 22:57:58 -0700 Subject: [PATCH 322/393] Plugins: internalize qwen portal auth SDK imports --- extensions/qwen-portal-auth/index.ts | 4 ++-- extensions/qwen-portal-auth/oauth.ts | 5 +---- extensions/qwen-portal-auth/runtime-api.ts | 1 + 3 files changed, 4 insertions(+), 6 deletions(-) create mode 100644 extensions/qwen-portal-auth/runtime-api.ts diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 377a4a598af..384f58f4845 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -3,10 +3,10 @@ import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; import { buildOauthProviderAuthResult, definePluginEntry, + refreshQwenPortalCredentials, type ProviderAuthContext, type ProviderCatalogContext, -} from "openclaw/plugin-sdk/qwen-portal-auth"; -import { refreshQwenPortalCredentials } from "openclaw/plugin-sdk/qwen-portal-auth"; +} from "./runtime-api.js"; import { loginQwenPortalOAuth } from "./oauth.js"; import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; diff --git a/extensions/qwen-portal-auth/oauth.ts b/extensions/qwen-portal-auth/oauth.ts index cdb8ab1bc36..d95273420e5 100644 --- a/extensions/qwen-portal-auth/oauth.ts +++ b/extensions/qwen-portal-auth/oauth.ts @@ -1,8 +1,5 @@ import { randomUUID } from "node:crypto"; -import { - generatePkceVerifierChallenge, - toFormUrlEncoded, -} from "openclaw/plugin-sdk/qwen-portal-auth"; +import { generatePkceVerifierChallenge, toFormUrlEncoded } from "./runtime-api.js"; const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"; const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`; diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts new file mode 100644 index 00000000000..232a2886110 --- /dev/null +++ b/extensions/qwen-portal-auth/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/qwen-portal-auth"; From 02826eaa0c5d651f556a4ff30738eba944e18880 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 22:58:03 -0700 Subject: [PATCH 323/393] Plugins: internalize lobster SDK imports --- extensions/lobster/index.ts | 8 ++------ extensions/lobster/runtime-api.ts | 1 + extensions/lobster/src/lobster-tool.ts | 2 +- extensions/lobster/src/windows-spawn.ts | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) create mode 100644 extensions/lobster/runtime-api.ts diff --git a/extensions/lobster/index.ts b/extensions/lobster/index.ts index c70ccc49da0..e6e586af9c5 100644 --- a/extensions/lobster/index.ts +++ b/extensions/lobster/index.ts @@ -1,9 +1,5 @@ -import { - definePluginEntry, - type AnyAgentTool, - type OpenClawPluginApi, - type OpenClawPluginToolFactory, -} from "openclaw/plugin-sdk/lobster"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolFactory } from "./runtime-api.js"; import { createLobsterTool } from "./src/lobster-tool.js"; export default definePluginEntry({ diff --git a/extensions/lobster/runtime-api.ts b/extensions/lobster/runtime-api.ts new file mode 100644 index 00000000000..7ab2351b77d --- /dev/null +++ b/extensions/lobster/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/lobster"; diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 96276bb9d69..fa3994bb45d 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import path from "node:path"; import { Type } from "@sinclair/typebox"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/lobster"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; type LobsterEnvelope = diff --git a/extensions/lobster/src/windows-spawn.ts b/extensions/lobster/src/windows-spawn.ts index 7c35deab2a7..22541f866a8 100644 --- a/extensions/lobster/src/windows-spawn.ts +++ b/extensions/lobster/src/windows-spawn.ts @@ -2,7 +2,7 @@ import { applyWindowsSpawnProgramPolicy, materializeWindowsSpawnProgram, resolveWindowsSpawnProgramCandidate, -} from "openclaw/plugin-sdk/lobster"; +} from "../runtime-api.js"; type SpawnTarget = { command: string; From 4d551e6f33174fffbcb9a4ec41a05cb810ea58aa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 22:58:43 -0700 Subject: [PATCH 324/393] Plugins: internalize acpx SDK imports --- extensions/acpx/index.ts | 2 +- extensions/acpx/runtime-api.ts | 1 + extensions/acpx/src/config.ts | 2 +- extensions/acpx/src/ensure.ts | 2 +- extensions/acpx/src/runtime-internals/events.ts | 2 +- extensions/acpx/src/runtime-internals/process.ts | 4 ++-- extensions/acpx/src/runtime.ts | 4 ++-- extensions/acpx/src/service.ts | 4 ++-- 8 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 extensions/acpx/runtime-api.ts diff --git a/extensions/acpx/index.ts b/extensions/acpx/index.ts index 20a1cbbefe2..2ae578b9c3f 100644 --- a/extensions/acpx/index.ts +++ b/extensions/acpx/index.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/acpx"; +import type { OpenClawPluginApi } from "./runtime-api.js"; import { createAcpxPluginConfigSchema } from "./src/config.js"; import { createAcpxRuntimeService } from "./src/service.js"; diff --git a/extensions/acpx/runtime-api.ts b/extensions/acpx/runtime-api.ts new file mode 100644 index 00000000000..8d1d125f226 --- /dev/null +++ b/extensions/acpx/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/acpx"; diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index e604b69db7c..612147320d5 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx"; +import type { OpenClawPluginConfigSchema } from "../runtime-api.js"; export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const; export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number]; diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 05825b75bc9..197dab820b8 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import type { PluginLogger } from "openclaw/plugin-sdk/acpx"; +import type { PluginLogger } from "../runtime-api.js"; import { ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT, buildAcpxLocalInstallCommand } from "./config.js"; import { resolveSpawnFailure, diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts index f0326bbe938..3bbfed68495 100644 --- a/extensions/acpx/src/runtime-internals/events.ts +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -1,4 +1,4 @@ -import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "openclaw/plugin-sdk/acpx"; +import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../runtime-api.js"; import { asOptionalBoolean, asOptionalString, diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index 60b85114bcb..4e2aa38a6d4 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -5,14 +5,14 @@ import type { WindowsSpawnProgram, WindowsSpawnProgramCandidate, WindowsSpawnResolution, -} from "openclaw/plugin-sdk/acpx"; +} from "../runtime-api.js"; import { applyWindowsSpawnProgramPolicy, listKnownProviderAuthEnvVarNames, materializeWindowsSpawnProgram, omitEnvKeysCaseInsensitive, resolveWindowsSpawnProgramCandidate, -} from "openclaw/plugin-sdk/acpx"; +} from "../runtime-api.js"; export type SpawnExit = { code: number | null; diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index a528de476af..e1f0024c699 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -10,8 +10,8 @@ import type { AcpRuntimeStatus, AcpRuntimeTurnInput, PluginLogger, -} from "openclaw/plugin-sdk/acpx"; -import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx"; +} from "../runtime-api.js"; +import { AcpRuntimeError } from "../runtime-api.js"; import { toAcpMcpServers, type ResolvedAcpxPluginConfig } from "./config.js"; import { checkAcpxVersion, type AcpxVersionCheckResult } from "./ensure.js"; import { diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index a863546fb30..524c25d6e63 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -3,8 +3,8 @@ import type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger, -} from "openclaw/plugin-sdk/acpx"; -import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk/acpx"; +} from "../runtime-api.js"; +import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js"; import { resolveAcpxPluginConfig, type ResolvedAcpxPluginConfig } from "./config.js"; import { ensureAcpx } from "./ensure.js"; import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js"; From 1aab71cf5bc21c364ad1a01cdbeb2dce7cd0140b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 22:59:24 -0700 Subject: [PATCH 325/393] Plugins: guard local extension barrels --- src/plugin-sdk/channel-import-guardrails.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index d6448856334..3a6f65b2f27 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -118,6 +118,7 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [ ]; const LOCAL_EXTENSION_API_BARREL_GUARDS = [ + "acpx", "bluebubbles", "device-pair", "diagnostics-otel", @@ -125,11 +126,13 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "feishu", "llm-task", "line", + "lobster", "matrix", "mattermost", "memory-lancedb", "msteams", "nextcloud-talk", + "qwen-portal-auth", "synology-chat", "talk-voice", "thread-ownership", From 8c436a470e6a3ed91306227b80fda41688e6349d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 11:27:04 +0530 Subject: [PATCH 326/393] perf(test): decouple plugin runtime bootstrap --- src/plugins/registry-empty.ts | 24 ++++++++++++++++++++++++ src/plugins/registry.ts | 24 ++---------------------- src/plugins/runtime.ts | 3 ++- 3 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 src/plugins/registry-empty.ts diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts new file mode 100644 index 00000000000..fa78dac7536 --- /dev/null +++ b/src/plugins/registry-empty.ts @@ -0,0 +1,24 @@ +import type { PluginRegistry } from "./registry.js"; + +export function createEmptyPluginRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + diagnostics: [], + }; +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 3e89c8462b5..2fdadfeb94d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -14,6 +14,7 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -240,28 +241,7 @@ const constrainLegacyPromptInjectionHook = ( }; }; -export function createEmptyPluginRegistry(): PluginRegistry { - return { - plugins: [], - tools: [], - hooks: [], - typedHooks: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - gatewayHandlers: {}, - httpRoutes: [], - cliRegistrars: [], - services: [], - commands: [], - conversationBindingResolvedHandlers: [], - diagnostics: [], - }; -} +export { createEmptyPluginRegistry } from "./registry-empty.js"; export function createPluginRegistry(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index d159ad42758..f5f8133e5ba 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,4 +1,5 @@ -import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import type { PluginRegistry } from "./registry.js"; const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); From c245c8b39d39fa6b978d50c785a9abf12f05da6a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 11:27:07 +0530 Subject: [PATCH 327/393] refactor(plugin-sdk): split interactive runtime helpers --- extensions/discord/src/actions/handle-action.ts | 2 +- extensions/discord/src/shared-interactive.ts | 7 +++++-- extensions/slack/src/blocks-render.ts | 4 ++-- extensions/slack/src/message-action-dispatch.ts | 8 +++----- extensions/slack/src/outbound-adapter.ts | 4 ++-- extensions/telegram/src/button-types.ts | 4 ++-- extensions/telegram/src/outbound-adapter.ts | 2 +- package.json | 4 ++++ scripts/lib/plugin-sdk-entrypoints.json | 1 + src/plugin-sdk/interactive-runtime.ts | 17 +++++++++++++++++ 10 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 src/plugin-sdk/interactive-runtime.ts diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 0fca934e86f..9726b07cdda 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -7,7 +7,7 @@ import { import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; diff --git a/extensions/discord/src/shared-interactive.ts b/extensions/discord/src/shared-interactive.ts index bb8bf1dac70..393b94cdf92 100644 --- a/extensions/discord/src/shared-interactive.ts +++ b/extensions/discord/src/shared-interactive.ts @@ -1,5 +1,8 @@ -import { reduceInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; -import type { InteractiveButtonStyle, InteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; +import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; +import type { + InteractiveButtonStyle, + InteractiveReply, +} from "openclaw/plugin-sdk/interactive-runtime"; import type { DiscordComponentButtonStyle, DiscordComponentMessageSpec } from "./components.js"; function resolveDiscordInteractiveButtonStyle( diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index 775b988c521..f19d32c2c53 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -1,6 +1,6 @@ import type { Block, KnownBlock } from "@slack/web-api"; -import { reduceInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; -import type { InteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; +import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; +import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { truncateSlackText } from "./truncate.js"; export const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button"; diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index b6a48035627..4a2e17f5455 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,11 +1,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { - normalizeInteractiveReply, - type ChannelMessageActionContext, -} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; +import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; -import { readNumberParam, readStringParam } from "./runtime-api.js"; type SlackActionInvoke = ( action: Record, diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 56a5c995e40..42888ea12b4 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -5,11 +5,11 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolveInteractiveTextFallback, type InteractiveReply, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +} from "openclaw/plugin-sdk/interactive-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index 15c307ca8c0..9aaaf55e655 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -1,9 +1,9 @@ -import { reduceInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; +import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { normalizeInteractiveReply, type InteractiveReply, type InteractiveReplyButton, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/interactive-runtime"; export type TelegramButtonStyle = "danger" | "success" | "primary"; diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 1b12c5203a1..16ef036d93d 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -4,7 +4,7 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; diff --git a/package.json b/package.json index 6c536f0a518..5b9c9866ba9 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,10 @@ "types": "./dist/plugin-sdk/channel-runtime.d.ts", "default": "./dist/plugin-sdk/channel-runtime.js" }, + "./plugin-sdk/interactive-runtime": { + "types": "./dist/plugin-sdk/interactive-runtime.d.ts", + "default": "./dist/plugin-sdk/interactive-runtime.js" + }, "./plugin-sdk/infra-runtime": { "types": "./dist/plugin-sdk/infra-runtime.d.ts", "default": "./dist/plugin-sdk/infra-runtime.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 237f69282f2..55c22bf8470 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -14,6 +14,7 @@ "config-runtime", "reply-runtime", "channel-runtime", + "interactive-runtime", "infra-runtime", "media-runtime", "media-understanding-runtime", diff --git a/src/plugin-sdk/interactive-runtime.ts b/src/plugin-sdk/interactive-runtime.ts new file mode 100644 index 00000000000..2eef796733a --- /dev/null +++ b/src/plugin-sdk/interactive-runtime.ts @@ -0,0 +1,17 @@ +export { reduceInteractiveReply } from "../channels/plugins/outbound/interactive.js"; +export type { + InteractiveButtonStyle, + InteractiveReply, + InteractiveReplyBlock, + InteractiveReplyButton, + InteractiveReplyOption, + InteractiveReplySelectBlock, + InteractiveReplyTextBlock, +} from "../interactive/payload.js"; +export { + hasInteractiveReplyBlocks, + hasReplyChannelData, + hasReplyContent, + normalizeInteractiveReply, + resolveInteractiveTextFallback, +} from "../interactive/payload.js"; From d949a513c555e3df7825cae28355770c54d4c294 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:00:59 -0700 Subject: [PATCH 328/393] Plugins: internalize small extension SDK imports --- extensions/copilot-proxy/index.ts | 2 +- extensions/copilot-proxy/runtime-api.ts | 1 + extensions/open-prose/index.ts | 2 +- extensions/open-prose/runtime-api.ts | 1 + extensions/phone-control/index.ts | 2 +- extensions/phone-control/runtime-api.ts | 1 + extensions/zai/detect.ts | 2 +- extensions/zai/runtime-api.ts | 1 + 8 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 extensions/copilot-proxy/runtime-api.ts create mode 100644 extensions/open-prose/runtime-api.ts create mode 100644 extensions/phone-control/runtime-api.ts create mode 100644 extensions/zai/runtime-api.ts diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts index cf71710db5c..ef0aa61030c 100644 --- a/extensions/copilot-proxy/index.ts +++ b/extensions/copilot-proxy/index.ts @@ -2,7 +2,7 @@ import { definePluginEntry, type ProviderAuthContext, type ProviderAuthResult, -} from "openclaw/plugin-sdk/copilot-proxy"; +} from "./runtime-api.js"; const DEFAULT_BASE_URL = "http://localhost:3000/v1"; const DEFAULT_API_KEY = "n/a"; diff --git a/extensions/copilot-proxy/runtime-api.ts b/extensions/copilot-proxy/runtime-api.ts new file mode 100644 index 00000000000..849136c6efb --- /dev/null +++ b/extensions/copilot-proxy/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/copilot-proxy"; diff --git a/extensions/open-prose/index.ts b/extensions/open-prose/index.ts index 540148f498c..c86f309fcc4 100644 --- a/extensions/open-prose/index.ts +++ b/extensions/open-prose/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/open-prose"; +import { definePluginEntry, type OpenClawPluginApi } from "./runtime-api.js"; export default definePluginEntry({ id: "open-prose", diff --git a/extensions/open-prose/runtime-api.ts b/extensions/open-prose/runtime-api.ts new file mode 100644 index 00000000000..1601f81be1f --- /dev/null +++ b/extensions/open-prose/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/open-prose"; diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 88446e4fde7..1743e3faae5 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -4,7 +4,7 @@ import { definePluginEntry, type OpenClawPluginApi, type OpenClawPluginService, -} from "openclaw/plugin-sdk/phone-control"; +} from "./runtime-api.js"; type ArmGroup = "camera" | "screen" | "writes" | "all"; diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts new file mode 100644 index 00000000000..2e9e0adeba2 --- /dev/null +++ b/extensions/phone-control/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/phone-control"; diff --git a/extensions/zai/detect.ts b/extensions/zai/detect.ts index 9bd1f25f50a..6d01c0ddce7 100644 --- a/extensions/zai/detect.ts +++ b/extensions/zai/detect.ts @@ -2,7 +2,7 @@ import { detectZaiEndpoint as detectZaiEndpointCore, type ZaiDetectedEndpoint, type ZaiEndpointId, -} from "openclaw/plugin-sdk/zai"; +} from "./runtime-api.js"; type DetectZaiEndpointFn = typeof detectZaiEndpointCore; diff --git a/extensions/zai/runtime-api.ts b/extensions/zai/runtime-api.ts new file mode 100644 index 00000000000..27c34abce5a --- /dev/null +++ b/extensions/zai/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/zai"; From 75f98fe19a4eee54160ad35a252bc58adc0e07ea Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:01:09 -0700 Subject: [PATCH 329/393] Plugins: guard small extension barrels --- src/plugin-sdk/channel-import-guardrails.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 3a6f65b2f27..e49ab4da935 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -132,6 +132,10 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "memory-lancedb", "msteams", "nextcloud-talk", + "open-prose", + "phone-control", + "copilot-proxy", + "zai", "qwen-portal-auth", "synology-chat", "talk-voice", From 08a0219b1a9ba61599b1940ea915612a80f3904c Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Tue, 17 Mar 2026 23:02:30 -0700 Subject: [PATCH 330/393] Google Chat: thin runtime api seam (#49504) Merged via squash. Prepared head SHA: 3369cf2c35cbf03bc4008d123e69f43f1cc083e9 Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Reviewed-by: @scoootscooob --- CHANGELOG.md | 1 + extensions/googlechat/runtime-api.ts | 107 +----------------- extensions/googlechat/src/accounts.ts | 3 +- extensions/googlechat/src/group-policy.ts | 2 +- extensions/googlechat/src/monitor-types.ts | 2 +- src/plugin-sdk/core.ts | 1 + src/plugin-sdk/runtime-api-guardrails.test.ts | 1 + src/plugin-sdk/subpaths.test.ts | 15 +++ 8 files changed, 24 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa96121ab73..1b16e3f6efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,7 @@ Docs: https://docs.openclaw.ai - Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc. - xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob. - Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob. +- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob. ### Breaking diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 28f7c81c4e9..6f0861114ec 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -1,107 +1,4 @@ // Private runtime barrel for the bundled Google Chat extension. -// Keep this curated to the symbols used by production code under extensions/googlechat/src. +// Keep this seam thin and aligned with the curated plugin-sdk/googlechat surface. -export { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "../../src/agents/tools/common.js"; -export { - createScopedChannelConfigAdapter, - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, - createTopLevelChannelConfigAdapter, - createHybridChannelConfigAdapter, - createScopedDmSecurityResolver, -} from "../../src/plugin-sdk/channel-config-helpers.js"; -export { - buildOpenGroupPolicyConfigureRouteAllowlistWarning, - collectAllowlistProviderGroupPolicyWarnings, -} from "../../src/plugin-sdk/channel-policy.js"; -export { resolveMentionGatingWithBypass } from "../../src/channels/mention-gating.js"; -export { formatNormalizedAllowFromEntries } from "../../src/plugin-sdk/allow-from.js"; -export { buildComputedAccountStatusSnapshot } from "../../src/plugin-sdk/status-helpers.js"; -export { - createAccountStatusSink, - runPassiveAccountLifecycle, -} from "../../src/plugin-sdk/channel-lifecycle.js"; -export { buildChannelConfigSchema } from "../../src/channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../../src/channels/plugins/config-helpers.js"; -export { - listDirectoryGroupEntriesFromMapKeys, - listDirectoryUserEntriesFromAllowFrom, -} from "../../src/channels/plugins/directory-config-helpers.js"; -export { formatPairingApproveHint } from "../../src/channels/plugins/helpers.js"; -export { resolveChannelMediaMaxBytes } from "../../src/channels/plugins/media-limits.js"; -export { - addWildcardAllowFrom, - mergeAllowFromEntries, - splitSetupEntries, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../src/channels/plugins/setup-wizard-helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js"; -export { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../src/channels/plugins/setup-helpers.js"; -export { createAccountListHelpers } from "../../src/channels/plugins/account-helpers.js"; -export type { - ChannelAccountSnapshot, - ChannelMessageActionAdapter, - ChannelMessageActionName, - ChannelStatusIssue, -} from "../../src/channels/plugins/types.js"; -export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js"; -export { getChatChannelMeta } from "../../src/channels/registry.js"; -export { createReplyPrefixOptions } from "../../src/channels/reply-prefix.js"; -export type { OpenClawConfig } from "../../src/config/config.js"; -export { isDangerousNameMatchingEnabled } from "../../src/config/dangerous-name-matching.js"; -export { - GROUP_POLICY_BLOCKED_LABEL, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, -} from "../../src/config/runtime-group-policy.js"; -export type { - DmPolicy, - GoogleChatAccountConfig, - GoogleChatConfig, -} from "../../src/config/types.js"; -export { isSecretRef } from "../../src/config/types.secrets.js"; -export { GoogleChatConfigSchema } from "../../src/config/zod-schema.providers-core.js"; -export { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; -export { missingTargetError } from "../../src/infra/outbound/target-errors.js"; -export { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; -export type { PluginRuntime } from "../../src/plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../../src/plugins/types.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../src/routing/session-key.js"; -export { resolveDmGroupAccessWithLists } from "../../src/security/dm-policy-shared.js"; -export { formatDocsLink } from "../../src/terminal/links.js"; -export type { WizardPrompter } from "../../src/wizard/prompts.js"; -export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "../../src/plugin-sdk/inbound-envelope.js"; -export { createScopedPairingAccess } from "../../src/plugin-sdk/pairing-access.js"; -export { issuePairingChallenge } from "../../src/pairing/pairing-challenge.js"; -export { - evaluateGroupRouteAccessForPolicy, - resolveSenderScopedGroupPolicy, -} from "../../src/plugin-sdk/group-access.js"; -export { extractToolSend } from "../../src/plugin-sdk/tool-send.js"; -export { resolveWebhookPath } from "../../src/plugin-sdk/webhook-path.js"; -export type { WebhookInFlightLimiter } from "../../src/plugin-sdk/webhook-request-guards.js"; -export { - beginWebhookRequestPipelineOrReject, - createWebhookInFlightLimiter, - readJsonWebhookBodyOrReject, -} from "../../src/plugin-sdk/webhook-request-guards.js"; -export { - registerWebhookTargetWithPluginRoute, - resolveWebhookTargets, - resolveWebhookTargetWithAuthOrReject, - withResolvedWebhookRequestPipeline, -} from "../../src/plugin-sdk/webhook-targets.js"; +export * from "openclaw/plugin-sdk/googlechat"; diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 0e973cbe02f..314ae8272bb 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -1,5 +1,6 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { isSecretRef, createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js"; +import { isSecretRef, type OpenClawConfig } from "openclaw/plugin-sdk/core"; import type { GoogleChatAccountConfig } from "./types.config.js"; export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none"; diff --git a/extensions/googlechat/src/group-policy.ts b/extensions/googlechat/src/group-policy.ts index ab10399e529..cf4de7018cf 100644 --- a/extensions/googlechat/src/group-policy.ts +++ b/extensions/googlechat/src/group-policy.ts @@ -1,5 +1,5 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import type { OpenClawConfig } from "../runtime-api.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; type GoogleChatGroupContext = { cfg: OpenClawConfig; diff --git a/extensions/googlechat/src/monitor-types.ts b/extensions/googlechat/src/monitor-types.ts index 4cddc70ea3b..26027be5d17 100644 --- a/extensions/googlechat/src/monitor-types.ts +++ b/extensions/googlechat/src/monitor-types.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "../runtime-api.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import type { GoogleChatAudienceType } from "./auth.js"; import { getGoogleChatRuntime } from "./runtime.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index ba49614389d..124c37d6712 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -54,6 +54,7 @@ export type { PluginInteractiveTelegramHandlerContext, } from "../plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; +export { isSecretRef } from "../config/types.secrets.js"; export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; export type { ChannelOutboundSessionRoute, diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 785ed9de224..1b29d1570c6 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -31,6 +31,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/probe.js";', 'export * from "./src/send.js";', ], + "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], "extensions/nextcloud-talk/runtime-api.ts": [ 'export * from "openclaw/plugin-sdk/nextcloud-talk";', ], diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 606e7b623f8..313d2d4d263 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -73,6 +73,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof coreSdk.defineChannelPluginEntry).toBe("function"); expect(typeof coreSdk.defineSetupPluginEntry).toBe("function"); expect(typeof coreSdk.createChannelPluginBase).toBe("function"); + expect(typeof coreSdk.isSecretRef).toBe("function"); expect(typeof coreSdk.optionalStringEnum).toBe("function"); expect("runPassiveAccountLifecycle" in asExports(coreSdk)).toBe(false); expect("createLoggerBackedRuntime" in asExports(coreSdk)).toBe(false); @@ -259,8 +260,22 @@ describe("plugin-sdk subpath exports", () => { }); it("exports Google Chat helpers", async () => { + expect(typeof googlechatSdk.buildChannelConfigSchema).toBe("function"); + expect(typeof googlechatSdk.createWebhookInFlightLimiter).toBe("function"); + expect(typeof googlechatSdk.fetchWithSsrFGuard).toBe("function"); expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object"); expect(typeof googlechatSdk.googlechatSetupAdapter).toBe("object"); + expect(typeof googlechatSdk.resolveGoogleChatGroupRequireMention).toBe("function"); + }); + + it("keeps the Google Chat runtime seam aligned with the public SDK subpath", async () => { + const googlechatRuntimeApi = await import("../../extensions/googlechat/runtime-api.js"); + + expect(typeof googlechatRuntimeApi.buildChannelConfigSchema).toBe("function"); + expect(typeof googlechatRuntimeApi.createWebhookInFlightLimiter).toBe("function"); + expect(typeof googlechatRuntimeApi.fetchWithSsrFGuard).toBe("function"); + expect(typeof googlechatRuntimeApi.createActionGate).toBe("function"); + expect(typeof googlechatRuntimeApi.resolveWebhookTargetWithAuthOrReject).toBe("function"); }); it("exports Zalo helpers", async () => { From 9282d5d09ebd95f8701b538c911b1321ebb7d2b9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:08:37 -0700 Subject: [PATCH 331/393] Plugins: soften hook-only compatibility copy --- src/commands/config-validation.test.ts | 4 ++-- src/commands/doctor-workspace-status.test.ts | 4 ++-- src/commands/status.test.ts | 8 ++++---- src/plugins/status.test.ts | 16 ++++++++-------- src/plugins/status.ts | 4 ++-- src/wizard/setup.test.ts | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 8ff9f595af0..6d809a3b50b 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -31,7 +31,7 @@ describe("requireValidConfigSnapshot", () => { code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }, ]); const runtime = { @@ -48,7 +48,7 @@ describe("requireValidConfigSnapshot", () => { expect(runtime.exit).not.toHaveBeenCalled(); expect(String(runtime.log.mock.calls[0]?.[0])).toContain("Plugin compatibility: 1 notice."); expect(String(runtime.log.mock.calls[0]?.[0])).toContain( - "legacy-plugin still relies on legacy before_agent_start", + "legacy-plugin still uses legacy before_agent_start", ); }); diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index ad64d600dff..8d206ac56d7 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -88,10 +88,10 @@ describe("noteWorkspaceStatus", () => { ); expect(compatibilityCalls).toHaveLength(1); expect(String(compatibilityCalls[0]?.[0])).toContain( - "legacy-plugin still relies on legacy before_agent_start", + "legacy-plugin still uses legacy before_agent_start", ); expect(String(compatibilityCalls[0]?.[0])).toContain( - "legacy-plugin is hook-only; this remains supported for compatibility", + "legacy-plugin is hook-only. This remains a supported compatibility path", ); } finally { noteSpy.mockRestore(); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index b6977460ee8..f84875c02b1 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -414,7 +414,7 @@ describe("statusCommand", () => { code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }, ]); await statusCommand({ json: true }, runtime as never); @@ -446,7 +446,7 @@ describe("statusCommand", () => { code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }, ], }); @@ -484,7 +484,7 @@ describe("statusCommand", () => { code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }, ]); const logs = await runStatusAndGetLogs(); @@ -513,7 +513,7 @@ describe("statusCommand", () => { expect(logs.some((line) => line.includes(token))).toBe(true); } expect( - logs.some((line) => line.includes("legacy-plugin still relies on legacy before_agent_start")), + logs.some((line) => line.includes("legacy-plugin still uses legacy before_agent_start")), ).toBe(true); expect( logs.some( diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 04ba3c9679f..7d93c52bc21 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -165,7 +165,7 @@ describe("buildPluginStatusReport", () => { code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }, ]); expect(inspect?.policy).toEqual({ @@ -332,8 +332,8 @@ describe("buildPluginStatusReport", () => { }); expect(buildPluginCompatibilityWarnings()).toEqual([ - "lca still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", - "lca is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + "lca still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", + "lca is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", ]); }); @@ -431,14 +431,14 @@ describe("buildPluginStatusReport", () => { code: "hook-only", severity: "info", message: - "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", }, { pluginId: "legacy-only", code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }, ]); }); @@ -499,11 +499,11 @@ describe("buildPluginStatusReport", () => { code: "legacy-before-agent-start" as const, severity: "warn" as const, message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }; expect(formatPluginCompatibilityNotice(notice)).toBe( - "legacy-plugin still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "legacy-plugin still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", ); expect( summarizePluginCompatibility([ @@ -513,7 +513,7 @@ describe("buildPluginStatusReport", () => { code: "hook-only", severity: "info", message: - "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", }, ]), ).toEqual({ diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 154ea25262e..ad747d375bd 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -86,7 +86,7 @@ function buildCompatibilityNoticesForInspect( code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }); } if (inspect.shape === "hook-only") { @@ -95,7 +95,7 @@ function buildCompatibilityNoticesForInspect( code: "hook-only", severity: "info", message: - "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", }); } return warnings; diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index c9765282493..c24e695f598 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -413,7 +413,7 @@ describe("runSetupWizard", () => { code: "legacy-before-agent-start", severity: "warn", message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }, ]); readConfigFileSnapshot.mockResolvedValueOnce({ From 0bdd17aef29cfd9454207aec79baf209163ff610 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:05:40 -0700 Subject: [PATCH 332/393] Plugins: finish signal SDK internalization --- extensions/signal/runtime-api.ts | 3 +-- extensions/signal/src/accounts.ts | 2 +- extensions/signal/src/channel.setup.ts | 2 +- extensions/signal/src/channel.ts | 2 +- extensions/signal/src/runtime-api.ts | 1 + extensions/signal/src/shared.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 extensions/signal/src/runtime-api.ts diff --git a/extensions/signal/runtime-api.ts b/extensions/signal/runtime-api.ts index 3a84b043f2b..801051438fb 100644 --- a/extensions/signal/runtime-api.ts +++ b/extensions/signal/runtime-api.ts @@ -1,2 +1 @@ -export * from "./src/index.js"; -export type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; +export * from "./src/runtime-api.js"; diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 456db907685..51bd1f7e96d 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -4,7 +4,7 @@ import { resolveAccountEntry, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; -import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; +import type { SignalAccountConfig } from "./runtime-api.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 14ec21590bd..df5337a4761 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,6 +1,6 @@ -import { type ChannelPlugin } from "../runtime-api.js"; import { type ResolvedSignalAccount } from "./accounts.js"; import { signalSetupAdapter } from "./setup-core.js"; +import { type ChannelPlugin } from "./runtime-api.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 8b8fe842511..85aaadbd2c1 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -16,7 +16,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; +} from "./runtime-api.js"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { markdownToSignalTextChunks } from "./format.js"; import { diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts new file mode 100644 index 00000000000..93bce482026 --- /dev/null +++ b/extensions/signal/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/signal"; diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index c307a51e66c..1a0579e0236 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -10,7 +10,7 @@ import { normalizeE164, SignalConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; +} from "./runtime-api.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, From df7911359376506016dd7a56eb526d798f086a2b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:07:42 -0700 Subject: [PATCH 333/393] Plugins: internalize telegram SDK imports --- extensions/telegram/runtime-api.ts | 57 ++++++++++++++++--- extensions/telegram/src/account-inspect.ts | 2 +- extensions/telegram/src/accounts.ts | 2 +- extensions/telegram/src/action-runtime.ts | 4 +- ...ot-native-commands.fixture-test-support.ts | 2 +- .../bot-native-commands.menu-test-support.ts | 2 +- extensions/telegram/src/channel.setup.ts | 2 +- extensions/telegram/src/channel.ts | 4 +- extensions/telegram/src/probe.ts | 2 +- extensions/telegram/src/shared.ts | 2 +- extensions/telegram/src/token.ts | 2 +- 11 files changed, 61 insertions(+), 20 deletions(-) diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index 76f87396469..b645e653834 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -1,8 +1,49 @@ -export * from "./src/audit.js"; -export * from "./src/action-runtime.js"; -export * from "./src/channel-actions.js"; -export * from "./src/monitor.js"; -export * from "./src/probe.js"; -export * from "./src/send.js"; -export * from "./src/thread-bindings.js"; -export * from "./src/token.js"; +export type { + ChannelPlugin, + OpenClawConfig, + TelegramActionConfig, +} from "../../src/plugin-sdk/telegram-core.js"; +export type { ChannelMessageActionAdapter } from "../../src/channels/plugins/types.js"; +export type { TelegramAccountConfig, TelegramNetworkConfig } from "../../src/config/types.js"; +export type { + OpenClawPluginApi, + OpenClawPluginService, + OpenClawPluginServiceContext, + PluginLogger, +} from "../../src/plugins/types.js"; +export type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, + AcpSessionUpdateTag, +} from "../../src/acp/runtime/types.js"; +export type { AcpRuntimeErrorCode } from "../../src/acp/runtime/errors.js"; +export { AcpRuntimeError } from "../../src/acp/runtime/errors.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../src/routing/session-key.js"; +export { + buildChannelConfigSchema, + getChatChannelMeta, + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, + resolvePollMaxSelections, + TelegramConfigSchema, +} from "../../src/plugin-sdk/telegram-core.js"; +export { parseTelegramTopicConversation } from "../../src/acp/conversation-id.js"; +export { clearAccountEntryFields } from "../../src/channels/plugins/config-helpers.js"; +export { buildTokenChannelStatusSummary } from "../../src/plugin-sdk/status-helpers.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "../../src/channels/account-snapshot-fields.js"; +export { resolveTelegramPollVisibility } from "../../src/poll-params.js"; +export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js"; diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 6295a231451..5d131a70586 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -8,7 +8,7 @@ import { import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; -import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; +import type { TelegramAccountConfig } from "../runtime-api.js"; import { mergeTelegramAccountConfig, resolveDefaultTelegramAccountId, diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index 2e0c053d0d4..e1b86ec15d8 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -16,7 +16,7 @@ import { } from "openclaw/plugin-sdk/routing"; import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import type { TelegramAccountConfig, TelegramActionConfig } from "openclaw/plugin-sdk/telegram"; +import type { TelegramAccountConfig, TelegramActionConfig } from "../runtime-api.js"; import { resolveTelegramToken } from "./token.js"; let log: ReturnType | null = null; diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 6f823d99ae7..c07dae07681 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -1,7 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram"; +import { resolveTelegramPollVisibility } from "../runtime-api.js"; import { jsonResult, readNumberParam, @@ -12,7 +12,7 @@ import { resolvePollMaxSelections, type OpenClawConfig, type TelegramActionConfig, -} from "openclaw/plugin-sdk/telegram-core"; +} from "../runtime-api.js"; import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js"; import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; diff --git a/extensions/telegram/src/bot-native-commands.fixture-test-support.ts b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts index 99e8497ae7f..13f57407ce1 100644 --- a/extensions/telegram/src/bot-native-commands.fixture-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts @@ -1,6 +1,6 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; import { vi } from "vitest"; +import type { OpenClawConfig, TelegramAccountConfig } from "../runtime-api.js"; import type { RegisterTelegramNativeCommandsParams } from "./bot-native-commands.js"; export type NativeCommandTestParams = RegisterTelegramNativeCommandsParams; diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index 86eb7c4f65a..5d0f90257e5 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -1,6 +1,6 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/telegram"; import { expect, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import { createNativeCommandTestParams as createBaseNativeCommandTestParams, diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 4879ef96c09..10067a34378 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,4 +1,4 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; +import { type ChannelPlugin } from "../runtime-api.js"; import { type ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 3313510ad16..073ca5bd03a 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -10,7 +10,7 @@ import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; -import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram"; +import { parseTelegramTopicConversation } from "../runtime-api.js"; import { buildTokenChannelStatusSummary, clearAccountEntryFields, @@ -21,7 +21,7 @@ import { type ChannelPlugin, type ChannelMessageActionAdapter, type OpenClawConfig, -} from "openclaw/plugin-sdk/telegram"; +} from "../runtime-api.js"; import { listTelegramAccountIds, resolveTelegramAccount, diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index 660b9c9fb62..60d9b3a3a40 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,6 +1,6 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; -import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; +import type { TelegramNetworkConfig } from "../runtime-api.js"; import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 6898870e394..7c3e873f0ff 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -8,7 +8,7 @@ import { TelegramConfigSchema, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/telegram-core"; +} from "../runtime-api.js"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index 7a23a34ab12..6727e9a7ee4 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; -import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; +import type { TelegramAccountConfig } from "../runtime-api.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; From 6e723dfd6928772f5da0146ffdd60860981963e2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:07:58 -0700 Subject: [PATCH 334/393] Plugins: internalize medium extension SDK imports --- extensions/bluebubbles/src/group-policy.ts | 2 +- extensions/discord/src/group-policy.ts | 2 +- .../google/media-understanding-provider.ts | 2 +- extensions/google/runtime-api.ts | 1 + extensions/irc/src/channel.ts | 20 +++++++++---------- extensions/irc/src/config-schema.ts | 6 +++--- extensions/line/runtime-api.ts | 1 + extensions/line/src/group-policy.ts | 2 +- extensions/line/src/setup-core.ts | 4 ++-- extensions/line/src/setup-surface.ts | 4 ++-- extensions/nostr/runtime-api.ts | 1 + extensions/nostr/src/channel.ts | 10 +++++----- extensions/nostr/src/config-schema.ts | 2 +- 13 files changed, 30 insertions(+), 27 deletions(-) create mode 100644 extensions/google/runtime-api.ts create mode 100644 extensions/line/runtime-api.ts create mode 100644 extensions/nostr/runtime-api.ts diff --git a/extensions/bluebubbles/src/group-policy.ts b/extensions/bluebubbles/src/group-policy.ts index 656bb867a4c..d3b42cd45b4 100644 --- a/extensions/bluebubbles/src/group-policy.ts +++ b/extensions/bluebubbles/src/group-policy.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; +import type { OpenClawConfig } from "./runtime-api.js"; type BlueBubblesGroupContext = { cfg: OpenClawConfig; diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts index f327a761ea0..a5a8ebac5eb 100644 --- a/extensions/discord/src/group-policy.ts +++ b/extensions/discord/src/group-policy.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/channel-policy"; import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core"; -import type { DiscordConfig } from "openclaw/plugin-sdk/discord"; +import type { DiscordConfig } from "./runtime-api.js"; function normalizeDiscordSlug(value?: string | null) { return normalizeAtHashSlug(value); diff --git a/extensions/google/media-understanding-provider.ts b/extensions/google/media-understanding-provider.ts index 97b008ee578..73561b73ea3 100644 --- a/extensions/google/media-understanding-provider.ts +++ b/extensions/google/media-understanding-provider.ts @@ -1,4 +1,3 @@ -import { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/google"; import { assertOkOrThrowHttpError, describeImageWithModel, @@ -11,6 +10,7 @@ import { type VideoDescriptionRequest, type VideoDescriptionResult, } from "openclaw/plugin-sdk/media-understanding"; +import { normalizeGoogleModelId, parseGeminiAuth } from "../runtime-api.js"; export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; diff --git a/extensions/google/runtime-api.ts b/extensions/google/runtime-api.ts new file mode 100644 index 00000000000..3eaab2b0faf --- /dev/null +++ b/extensions/google/runtime-api.ts @@ -0,0 +1 @@ +export { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/google"; diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 554a01699ad..a0f6c9a5bc8 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -7,16 +7,6 @@ import { buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { - buildBaseAccountStatusSnapshot, - buildBaseChannelStatusSummary, - buildChannelConfigSchema, - createAccountStatusSink, - DEFAULT_ACCOUNT_ID, - getChatChannelMeta, - PAIRING_APPROVED_MESSAGE, - type ChannelPlugin, -} from "openclaw/plugin-sdk/irc"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { listIrcAccountIds, @@ -34,6 +24,16 @@ import { } from "./normalize.js"; import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; import { probeIrc } from "./probe.js"; +import { + buildBaseAccountStatusSnapshot, + buildBaseChannelStatusSummary, + buildChannelConfigSchema, + createAccountStatusSink, + DEFAULT_ACCOUNT_ID, + getChatChannelMeta, + PAIRING_APPROVED_MESSAGE, + type ChannelPlugin, +} from "./runtime-api.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; import { ircSetupAdapter } from "./setup-core.js"; diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts index 8b9625b5bc4..d1af189484b 100644 --- a/extensions/irc/src/config-schema.ts +++ b/extensions/irc/src/config-schema.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { BlockStreamingCoalesceSchema, DmConfigSchema, @@ -7,9 +9,7 @@ import { ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, -} from "openclaw/plugin-sdk/irc"; -import { z } from "zod"; -import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; +} from "./runtime-api.js"; const IrcGroupSchema = z .object({ diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts new file mode 100644 index 00000000000..af6082ba155 --- /dev/null +++ b/extensions/line/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/line-core"; diff --git a/extensions/line/src/group-policy.ts b/extensions/line/src/group-policy.ts index e6b4fa0ba95..eaf30e04cf7 100644 --- a/extensions/line/src/group-policy.ts +++ b/extensions/line/src/group-policy.ts @@ -1,5 +1,5 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "openclaw/plugin-sdk/line-core"; +import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "../runtime-api.js"; type LineGroupContext = { cfg: OpenClawConfig; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 363b4dcb2a1..7e894d2b87a 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,11 +1,11 @@ +import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, listLineAccountIds, normalizeAccountId, resolveLineAccount, type LineConfig, -} from "openclaw/plugin-sdk/line-core"; -import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; +} from "../runtime-api.js"; const channel = "line" as const; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 640ad3812b8..6f46cc92217 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,3 +1,4 @@ +import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, formatDocsLink, @@ -6,8 +7,7 @@ import { splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "openclaw/plugin-sdk/line-core"; -import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup"; +} from "../runtime-api.js"; import { isLineConfigured, listLineAccountIds, diff --git a/extensions/nostr/runtime-api.ts b/extensions/nostr/runtime-api.ts new file mode 100644 index 00000000000..3f3d64cc3bf --- /dev/null +++ b/extensions/nostr/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/nostr"; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 63ea3436dab..3db834e8ad6 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -2,6 +2,10 @@ import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + buildPassiveChannelStatusSummary, + buildTrafficStatusSummary, +} from "../../shared/channel-status-summary.js"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -9,11 +13,7 @@ import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint, type ChannelPlugin, -} from "openclaw/plugin-sdk/nostr"; -import { - buildPassiveChannelStatusSummary, - buildTrafficStatusSummary, -} from "../../shared/channel-status-summary.js"; +} from "../runtime-api.js"; import type { NostrProfile } from "./config-schema.js"; import { NostrConfigSchema } from "./config-schema.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 53346b0789d..0a741d3ac6b 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,6 @@ import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr"; import { z } from "zod"; +import { MarkdownConfigSchema, buildChannelConfigSchema } from "../runtime-api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) From c81b4a53898c431435d973218126f8ed04850507 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:08:37 -0700 Subject: [PATCH 335/393] Plugins: guard remaining local barrels --- src/plugin-sdk/channel-import-guardrails.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index e49ab4da935..c54c309aa45 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -122,8 +122,11 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "bluebubbles", "device-pair", "diagnostics-otel", + "discord", "diffs", "feishu", + "google", + "irc", "llm-task", "line", "lobster", @@ -132,6 +135,7 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "memory-lancedb", "msteams", "nextcloud-talk", + "nostr", "open-prose", "phone-control", "copilot-proxy", From 8af4628a6d288066675f728fe6309787789f9ed8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:09:16 -0700 Subject: [PATCH 336/393] Plugins: guard signal and telegram barrels --- src/plugin-sdk/channel-import-guardrails.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index c54c309aa45..ce8f4167364 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -141,8 +141,10 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "copilot-proxy", "zai", "qwen-portal-auth", + "signal", "synology-chat", "talk-voice", + "telegram", "thread-ownership", "tlon", "voice-call", From 77dfa73736fab29df64cfd3e4063a0a122683eb3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:10:51 -0700 Subject: [PATCH 337/393] Plugins: internalize whatsapp SDK imports --- extensions/whatsapp/src/accounts.ts | 2 +- .../src/action-runtime-target-auth.ts | 4 ++-- extensions/whatsapp/src/action-runtime.ts | 4 ++-- extensions/whatsapp/src/channel.runtime.ts | 4 ++-- extensions/whatsapp/src/channel.setup.ts | 2 +- extensions/whatsapp/src/channel.ts | 19 +++++++-------- extensions/whatsapp/src/group-policy.ts | 2 +- extensions/whatsapp/src/outbound-adapter.ts | 2 +- extensions/whatsapp/src/runtime-api.ts | 23 +++++++++++++++++++ extensions/whatsapp/src/session-route.ts | 2 +- extensions/whatsapp/src/shared.ts | 18 +++++++-------- 11 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 extensions/whatsapp/src/runtime-api.ts diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index 4cb02fb0be5..76fd919eeb2 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -9,8 +9,8 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths"; -import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "openclaw/plugin-sdk/whatsapp"; import { hasWebCredsSync } from "./auth-store.js"; +import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "./runtime-api.js"; export type ResolvedWhatsAppAccount = { accountId: string; diff --git a/extensions/whatsapp/src/action-runtime-target-auth.ts b/extensions/whatsapp/src/action-runtime-target-auth.ts index d641e004df6..f6c28a47c38 100644 --- a/extensions/whatsapp/src/action-runtime-target-auth.ts +++ b/extensions/whatsapp/src/action-runtime-target-auth.ts @@ -1,9 +1,9 @@ +import { resolveWhatsAppAccount } from "./accounts.js"; import { ToolAuthorizationError, resolveWhatsAppOutboundTarget, type OpenClawConfig, -} from "openclaw/plugin-sdk/whatsapp-core"; -import { resolveWhatsAppAccount } from "./accounts.js"; +} from "./runtime-api.js"; export function resolveAuthorizedWhatsAppOutboundTarget(params: { cfg: OpenClawConfig; diff --git a/extensions/whatsapp/src/action-runtime.ts b/extensions/whatsapp/src/action-runtime.ts index c6046e4eaa4..661b3a495dd 100644 --- a/extensions/whatsapp/src/action-runtime.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -1,12 +1,12 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js"; import { createActionGate, jsonResult, readReactionParams, readStringParam, type OpenClawConfig, -} from "openclaw/plugin-sdk/whatsapp-core"; -import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js"; +} from "./runtime-api.js"; import { sendReactionWhatsApp } from "./send.js"; export const whatsAppActionRuntime = { diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index 0d944b3cb17..4aa4951616a 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -1,4 +1,3 @@ -import { monitorWebChannel as monitorWebChannelImpl } from "openclaw/plugin-sdk/whatsapp"; import { getActiveWebListener as getActiveWebListenerImpl } from "./active-listener.js"; import { getWebAuthAgeMs as getWebAuthAgeMsImpl, @@ -8,6 +7,7 @@ import { webAuthExists as webAuthExistsImpl, } from "./auth-store.js"; import { loginWeb as loginWebImpl } from "./login.js"; +import { monitorWebChannel as monitorWebChannelImpl } from "./runtime-api.js"; import { whatsappSetupWizard as whatsappSetupWizardImpl } from "./setup-surface.js"; type GetActiveWebListener = typeof import("./active-listener.js").getActiveWebListener; @@ -20,7 +20,7 @@ type LoginWeb = typeof import("./login.js").loginWeb; type StartWebLoginWithQr = typeof import("./login-qr.js").startWebLoginWithQr; type WaitForWebLogin = typeof import("./login-qr.js").waitForWebLogin; type WhatsAppSetupWizard = typeof import("./setup-surface.js").whatsappSetupWizard; -type MonitorWebChannel = typeof import("openclaw/plugin-sdk/whatsapp").monitorWebChannel; +type MonitorWebChannel = typeof import("./runtime-api.js").monitorWebChannel; let loginQrPromise: Promise | null = null; diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index ebe4deb5789..1debaaca48f 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,6 +1,6 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; +import { type ChannelPlugin } from "./runtime-api.js"; import { whatsappSetupAdapter } from "./setup-core.js"; import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 6361d3de1a3..c859c70c6bc 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,4 +1,11 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) +import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; +import { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./directory-config.js"; +import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; import { createActionGate, createWhatsAppOutboundBase, @@ -10,15 +17,9 @@ import { resolveWhatsAppMentionStripRegexes, type ChannelMessageActionName, type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "openclaw/plugin-sdk/whatsapp"; -// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) -import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; -import { - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "./directory-config.js"; -import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "./runtime-api.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { resolveWhatsAppOutboundSessionRoute } from "./session-route.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/extensions/whatsapp/src/group-policy.ts b/extensions/whatsapp/src/group-policy.ts index dd1d04b7868..9108edd51ae 100644 --- a/extensions/whatsapp/src/group-policy.ts +++ b/extensions/whatsapp/src/group-policy.ts @@ -3,7 +3,7 @@ import { resolveChannelGroupToolsPolicy, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp-core"; +import type { OpenClawConfig } from "./runtime-api.js"; type WhatsAppGroupContext = { cfg: OpenClawConfig; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index 0cd0290e913..ffc0306d80b 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -3,7 +3,7 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveWhatsAppOutboundTarget } from "openclaw/plugin-sdk/whatsapp"; +import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; function trimLeadingWhitespace(text: string | undefined): string { diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts new file mode 100644 index 00000000000..ce89a02eb76 --- /dev/null +++ b/extensions/whatsapp/src/runtime-api.ts @@ -0,0 +1,23 @@ +export { + createActionGate, + createWhatsAppOutboundBase, + DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, + isWhatsAppGroupJid, + jsonResult, + normalizeWhatsAppTarget, + readReactionParams, + readStringParam, + resolveWhatsAppHeartbeatRecipients, + resolveWhatsAppMentionStripRegexes, + resolveWhatsAppOutboundTarget, + ToolAuthorizationError, + type ChannelPlugin, + type ChannelMessageActionName, + type DmPolicy, + type GroupPolicy, + type OpenClawConfig, + type WhatsAppAccountConfig, +} from "openclaw/plugin-sdk/whatsapp"; + +export { monitorWebChannel } from "openclaw/plugin-sdk/whatsapp"; diff --git a/extensions/whatsapp/src/session-route.ts b/extensions/whatsapp/src/session-route.ts index 61750689409..be6da685a25 100644 --- a/extensions/whatsapp/src/session-route.ts +++ b/extensions/whatsapp/src/session-route.ts @@ -2,7 +2,7 @@ import { buildChannelOutboundSessionRoute, type ChannelOutboundSessionRouteParams, } from "openclaw/plugin-sdk/core"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "openclaw/plugin-sdk/whatsapp"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./runtime-api.js"; export function resolveWhatsAppOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { const normalized = normalizeWhatsAppTarget(params.target); diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 9c3e3d50acf..88337f1fc18 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -6,15 +6,6 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup"; -import { - buildChannelConfigSchema, - formatWhatsAppConfigAllowFromEntries, - getChatChannelMeta, - normalizeE164, - resolveWhatsAppGroupIntroHint, - WhatsAppConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp-core"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, @@ -25,6 +16,15 @@ import { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, } from "./group-policy.js"; +import { + buildChannelConfigSchema, + formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, + resolveWhatsAppGroupIntroHint, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "./runtime-api.js"; export const WHATSAPP_CHANNEL = "whatsapp" as const; From d1d10007a9dd938d98d0bdac698d8d554ea15d41 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:11:32 -0700 Subject: [PATCH 338/393] Plugins: guard whatsapp local barrel --- src/plugin-sdk/channel-import-guardrails.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index ce8f4167364..69626948743 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -148,6 +148,7 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "thread-ownership", "tlon", "voice-call", + "whatsapp", "twitch", "zalo", "zalouser", From b9b891b614fd851bdecc62ed77d16379cb799825 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:12:00 -0700 Subject: [PATCH 339/393] Plugins: wire Claude bundle hook resolution (parity with Codex) --- src/plugins/bundle-manifest.test.ts | 66 ++++++++++++++++++++++++++++- src/plugins/bundle-manifest.ts | 2 +- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts index f1ad13035ee..b2a48f02f56 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -113,7 +113,7 @@ describe("bundle manifest parsing", () => { bundleFormat: "claude", skills: ["skill-packs/starter", "commands-pack"], settingsFiles: ["settings.json"], - hooks: [], + hooks: ["hooks/hooks.json", "hooks-pack"], capabilities: expect.arrayContaining([ "hooks", "skills", @@ -191,6 +191,70 @@ describe("bundle manifest parsing", () => { ); }); + it("resolves Claude bundle hooks from default and declared paths", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, ".claude-plugin")); + mkdirSafe(path.join(rootDir, "hooks")); + fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8"); + fs.writeFileSync( + path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), + JSON.stringify({ + name: "Hook Plugin", + description: "Claude hooks fixture", + }), + "utf-8", + ); + + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.manifest.hooks).toEqual(["hooks/hooks.json"]); + expect(result.manifest.capabilities).toContain("hooks"); + }); + + it("resolves Claude bundle hooks from manifest-declared paths only", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, ".claude-plugin")); + mkdirSafe(path.join(rootDir, "custom-hooks")); + fs.writeFileSync( + path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), + JSON.stringify({ + name: "Custom Hook Plugin", + hooks: "custom-hooks", + }), + "utf-8", + ); + + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.manifest.hooks).toEqual(["custom-hooks"]); + expect(result.manifest.capabilities).toContain("hooks"); + }); + + it("returns empty hooks for Claude bundles with no hooks directory", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, ".claude-plugin")); + mkdirSafe(path.join(rootDir, "skills")); + fs.writeFileSync( + path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), + JSON.stringify({ name: "No Hooks" }), + "utf-8", + ); + + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.manifest.hooks).toEqual([]); + expect(result.manifest.capabilities).not.toContain("hooks"); + }); + it("does not misclassify native index plugins as manifestless Claude bundles", () => { const rootDir = makeTempDir(); mkdirSafe(path.join(rootDir, "commands")); diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts index b5645035f5d..7c2a362153b 100644 --- a/src/plugins/bundle-manifest.ts +++ b/src/plugins/bundle-manifest.ts @@ -397,7 +397,7 @@ export function loadBundleManifest(params: { version, skills: resolveClaudeSkillDirs(raw, params.rootDir), settingsFiles: resolveClaudeSettingsFiles(raw, params.rootDir), - hooks: [], + hooks: resolveClaudeHookPaths(raw, params.rootDir), bundleFormat: "claude", capabilities: buildClaudeCapabilities(raw, params.rootDir), }, From b48413e252b4e57b13721afd60aabd75a40cbeaf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:12:48 -0700 Subject: [PATCH 340/393] Plugins: surface MCP servers and bundle capabilities in inspect reports --- src/cli/plugins-cli.ts | 15 ++++- src/plugins/bundle-mcp.ts | 4 ++ src/plugins/status.test.ts | 111 +++++++++++++++++++++++++++++++++++++ src/plugins/status.ts | 28 ++++++++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 8342b6c58b3..8e02bff7a47 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -660,6 +660,8 @@ export function registerPluginsCli(program: Command) { .map((entry) => (entry.severity === "warn" ? `warn:${entry.code}` : entry.code)) .join(", ") : "none", + Bundle: + inspect.bundleCapabilities.length > 0 ? inspect.bundleCapabilities.join(", ") : "-", Hooks: formatHookSummary({ usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart, typedHookCount: inspect.typedHooks.length, @@ -676,6 +678,7 @@ export function registerPluginsCli(program: Command) { { key: "Shape", header: "Shape", minWidth: 18 }, { key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true }, { key: "Compatibility", header: "Compatibility", minWidth: 24, flex: true }, + { key: "Bundle", header: "Bundle", minWidth: 14, flex: true }, { key: "Hooks", header: "Hooks", minWidth: 20, flex: true }, ], rows, @@ -738,9 +741,9 @@ export function registerPluginsCli(program: Command) { lines.push( `${theme.muted("Legacy before_agent_start:")} ${inspect.usesLegacyBeforeAgentStart ? "yes" : "no"}`, ); - if ((inspect.plugin.bundleCapabilities?.length ?? 0) > 0) { + if (inspect.bundleCapabilities.length > 0) { lines.push( - `${theme.muted("Bundle capabilities:")} ${inspect.plugin.bundleCapabilities?.join(", ")}`, + `${theme.muted("Bundle capabilities:")} ${inspect.bundleCapabilities.join(", ")}`, ); } lines.push( @@ -785,6 +788,14 @@ export function registerPluginsCli(program: Command) { lines.push(...formatInspectSection("CLI commands", inspect.cliCommands)); lines.push(...formatInspectSection("Services", inspect.services)); lines.push(...formatInspectSection("Gateway methods", inspect.gatewayMethods)); + lines.push( + ...formatInspectSection( + "MCP servers", + inspect.mcpServers.map((entry) => + entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`, + ), + ), + ); if (inspect.httpRouteCount > 0) { lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)])); } diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index fbd733d9695..b0960c17a93 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -32,6 +32,7 @@ export type EnabledBundleMcpConfigResult = { }; export type BundleMcpRuntimeSupport = { hasSupportedStdioServer: boolean; + supportedServerNames: string[]; unsupportedServerNames: string[]; diagnostics: string[]; }; @@ -279,17 +280,20 @@ export function inspectBundleMcpRuntimeSupport(params: { bundleFormat: PluginBundleFormat; }): BundleMcpRuntimeSupport { const loaded = loadBundleMcpConfig(params); + const supportedServerNames: string[] = []; const unsupportedServerNames: string[] = []; let hasSupportedStdioServer = false; for (const [serverName, server] of Object.entries(loaded.config.mcpServers)) { if (typeof server.command === "string" && server.command.trim().length > 0) { hasSupportedStdioServer = true; + supportedServerNames.push(serverName); continue; } unsupportedServerNames.push(serverName); } return { hasSupportedStdioServer, + supportedServerNames, unsupportedServerNames, diagnostics: loaded.diagnostics, }; diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 7d93c52bc21..ad895899dc5 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -493,6 +493,117 @@ describe("buildPluginStatusReport", () => { expect(buildPluginCompatibilityWarnings()).toEqual([]); }); + it("populates bundleCapabilities from plugin record", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "claude-bundle", + name: "Claude Bundle", + description: "A bundle plugin with skills and commands", + source: "/tmp/claude-bundle/.claude-plugin/plugin.json", + origin: "workspace", + enabled: true, + status: "loaded", + format: "bundle", + bundleFormat: "claude", + bundleCapabilities: ["skills", "commands", "agents", "settings"], + rootDir: "/tmp/claude-bundle", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + const inspect = buildPluginInspectReport({ id: "claude-bundle" }); + + expect(inspect).not.toBeNull(); + expect(inspect?.bundleCapabilities).toEqual(["skills", "commands", "agents", "settings"]); + expect(inspect?.mcpServers).toEqual([]); + expect(inspect?.shape).toBe("non-capability"); + }); + + it("returns empty bundleCapabilities and mcpServers for non-bundle plugins", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "plain-plugin", + name: "Plain Plugin", + description: "A regular plugin", + source: "/tmp/plain-plugin/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["plain"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + const inspect = buildPluginInspectReport({ id: "plain-plugin" }); + + expect(inspect).not.toBeNull(); + expect(inspect?.bundleCapabilities).toEqual([]); + expect(inspect?.mcpServers).toEqual([]); + }); + it("formats and summarizes compatibility notices", () => { const notice = { pluginId: "legacy-plugin", diff --git a/src/plugins/status.ts b/src/plugins/status.ts index ad747d375bd..51284e43d42 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -64,7 +65,12 @@ export type PluginInspectReport = { cliCommands: string[]; services: string[]; gatewayMethods: string[]; + mcpServers: Array<{ + name: string; + hasStdioTransport: boolean; + }>; httpRouteCount: number; + bundleCapabilities: string[]; diagnostics: PluginDiagnostic[]; policy: { allowPromptInjection?: boolean; @@ -226,6 +232,26 @@ export function buildPluginInspectReport(params: { httpRouteCount: plugin.httpRoutes, }); + // Populate MCP server info for bundle-format plugins with a known rootDir. + let mcpServers: PluginInspectReport["mcpServers"] = []; + if (plugin.format === "bundle" && plugin.bundleFormat && plugin.rootDir) { + const mcpSupport = inspectBundleMcpRuntimeSupport({ + pluginId: plugin.id, + rootDir: plugin.rootDir, + bundleFormat: plugin.bundleFormat, + }); + mcpServers = [ + ...mcpSupport.supportedServerNames.map((name) => ({ + name, + hasStdioTransport: true, + })), + ...mcpSupport.unsupportedServerNames.map((name) => ({ + name, + hasStdioTransport: false, + })), + ]; + } + const usesLegacyBeforeAgentStart = typedHooks.some( (entry) => entry.name === "before_agent_start", ); @@ -248,7 +274,9 @@ export function buildPluginInspectReport(params: { cliCommands: [...plugin.cliCommands], services: [...plugin.services], gatewayMethods: [...plugin.gatewayMethods], + mcpServers, httpRouteCount: plugin.httpRoutes, + bundleCapabilities: plugin.bundleCapabilities ?? [], diagnostics, policy: { allowPromptInjection: policyEntry?.hooks?.allowPromptInjection, From 100d7b0227e62889a6dc1703b2ca79989a1c8478 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:13:30 -0700 Subject: [PATCH 341/393] Doctor: add bundle plugin capability summary to workspace status --- src/commands/doctor-workspace-status.test.ts | 69 ++++++++++++++++++++ src/commands/doctor-workspace-status.ts | 8 +++ 2 files changed, 77 insertions(+) diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 8d206ac56d7..427bc56dd99 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -98,6 +98,75 @@ describe("noteWorkspaceStatus", () => { } }); + it("surfaces bundle plugin capabilities in the plugins note", async () => { + resolveDefaultAgentIdMock.mockReturnValue("default"); + resolveAgentWorkspaceDirMock.mockReturnValue("/workspace"); + buildWorkspaceSkillStatusMock.mockReturnValue({ + skills: [], + }); + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "claude-bundle", + name: "Claude Bundle", + source: "/tmp/claude-bundle", + origin: "workspace", + enabled: true, + status: "loaded", + format: "bundle", + bundleFormat: "claude", + bundleCapabilities: ["skills", "commands", "agents"], + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + }); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + const { noteWorkspaceStatus } = await import("./doctor-workspace-status.js"); + noteWorkspaceStatus({}); + + const pluginCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugins"); + expect(pluginCalls).toHaveLength(1); + const body = String(pluginCalls[0]?.[0]); + expect(body).toContain("Bundle plugins: 1"); + expect(body).toContain("agents, commands, skills"); + } finally { + noteSpy.mockRestore(); + } + }); + it("omits plugin compatibility note when no legacy compatibility paths are present", async () => { resolveDefaultAgentIdMock.mockReturnValue("default"); resolveAgentWorkspaceDirMock.mockReturnValue("/workspace"); diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 5e8132c0216..f0069ab0bd5 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -53,6 +53,14 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) { : null, ].filter((line): line is string => Boolean(line)); + const bundlePlugins = loaded.filter( + (p) => p.format === "bundle" && (p.bundleCapabilities?.length ?? 0) > 0, + ); + if (bundlePlugins.length > 0) { + const allCaps = new Set(bundlePlugins.flatMap((p) => p.bundleCapabilities ?? [])); + lines.push(`Bundle plugins: ${bundlePlugins.length} (${[...allCaps].toSorted().join(", ")})`); + } + note(lines.join("\n"), "Plugins"); } const compatibilityWarnings = buildPluginCompatibilityWarnings({ From b333eb137ba58fc9c2825f0f2e86ef0626771477 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:22:58 -0700 Subject: [PATCH 342/393] Tests: align plugin test imports with local barrels --- extensions/mattermost/src/mattermost/reactions.test-helpers.ts | 2 +- extensions/msteams/src/test-runtime.ts | 2 +- extensions/synology-chat/src/channel.test-mocks.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/mattermost/src/mattermost/reactions.test-helpers.ts b/extensions/mattermost/src/mattermost/reactions.test-helpers.ts index 248b9355918..ef31652ea40 100644 --- a/extensions/mattermost/src/mattermost/reactions.test-helpers.ts +++ b/extensions/mattermost/src/mattermost/reactions.test-helpers.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; import { expect, vi } from "vitest"; +import type { OpenClawConfig } from "../../runtime-api.js"; export function createMattermostTestConfig(): OpenClawConfig { return { diff --git a/extensions/msteams/src/test-runtime.ts b/extensions/msteams/src/test-runtime.ts index 6232e28ba07..3d884fcf2ac 100644 --- a/extensions/msteams/src/test-runtime.ts +++ b/extensions/msteams/src/test-runtime.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import type { PluginRuntime } from "openclaw/plugin-sdk/msteams"; +import type { PluginRuntime } from "../runtime-api.js"; export const msteamsRuntimeStub = { state: { diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index 10ccca5f9d0..21859ba90e9 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -27,7 +27,7 @@ async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise ({ +vi.mock("../api.js", () => ({ DEFAULT_ACCOUNT_ID: "default", setAccountEnabledInConfigSection: vi.fn((_opts: unknown) => ({})), registerPluginHttpRoute: registerPluginHttpRouteMock, From 732e075e92cdd4b692b339d99c95ea5169be97f4 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 18 Mar 2026 07:24:38 +0100 Subject: [PATCH 343/393] ACP: reproduce binding restart session reset (#49435) * ACP: reproduce restart binding regression * ACP: resume configured bindings after restart * ACP: scope restart resume to persistent sessions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com> --- src/acp/control-plane/manager.core.ts | 50 +++++-- src/acp/control-plane/manager.test.ts | 180 ++++++++++++++++++++++++++ src/acp/runtime/session-identity.ts | 9 ++ 3 files changed, 227 insertions(+), 12 deletions(-) diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 58f74b72918..d92dd388f05 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -12,6 +12,7 @@ import { identityEquals, isSessionIdentityPending, mergeSessionIdentity, + resolveRuntimeResumeSessionId, resolveRuntimeHandleIdentifiersFromIdentity, resolveSessionIdentityFromMeta, } from "../runtime/session-identity.js"; @@ -972,20 +973,45 @@ export class AcpSessionManager { const backend = this.deps.requireRuntimeBackend(configuredBackend || undefined); const runtime = backend.runtime; - const ensured = await withAcpRuntimeErrorBoundary({ - run: async () => - await runtime.ensureSession({ - sessionKey: params.sessionKey, - agent, - mode, - cwd, - }), - fallbackCode: "ACP_SESSION_INIT_FAILED", - fallbackMessage: "Could not initialize ACP session runtime.", - }); - const previousMeta = params.meta; const previousIdentity = resolveSessionIdentityFromMeta(previousMeta); + const persistedResumeSessionId = + mode === "persistent" ? resolveRuntimeResumeSessionId(previousIdentity) : undefined; + const ensureSession = async (resumeSessionId?: string) => + await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.ensureSession({ + sessionKey: params.sessionKey, + agent, + mode, + ...(resumeSessionId ? { resumeSessionId } : {}), + cwd, + }), + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Could not initialize ACP session runtime.", + }); + let ensured: AcpRuntimeHandle; + if (persistedResumeSessionId) { + try { + ensured = await ensureSession(persistedResumeSessionId); + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Could not initialize ACP session runtime.", + }); + if (acpError.code !== "ACP_SESSION_INIT_FAILED") { + throw acpError; + } + logVerbose( + `acp-manager: resume init failed for ${params.sessionKey}; retrying without persisted ACP session id: ${acpError.message}`, + ); + ensured = await ensureSession(); + } + } else { + ensured = await ensureSession(); + } + const now = Date.now(); const effectiveCwd = normalizeText(ensured.cwd) ?? cwd; const nextRuntimeOptions = normalizeRuntimeOptions({ diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 7229e34914d..4f5d316c393 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -432,6 +432,186 @@ describe("AcpSessionManager", () => { expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); }); + it("passes persisted ACP backend session identity back into ensureSession for configured bindings after restart", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:discord:default:deadbeef"; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: { + ...readySessionMeta(), + runtimeSessionName: key, + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-1", + lastUpdatedAt: Date.now(), + }, + }, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-restart", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + agent: "codex", + resumeSessionId: "acpx-sid-1", + }), + ); + }); + + it("does not resume persisted ACP identity for oneshot sessions after restart", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:discord:default:oneshot"; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: { + ...readySessionMeta(), + runtimeSessionName: key, + mode: "oneshot", + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-oneshot", + lastUpdatedAt: Date.now(), + }, + }, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-oneshot", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(1); + const ensureInput = runtimeState.ensureSession.mock.calls[0]?.[0] as + | { resumeSessionId?: string; mode?: string } + | undefined; + expect(ensureInput).toMatchObject({ + sessionKey, + agent: "codex", + mode: "oneshot", + }); + expect(ensureInput?.resumeSessionId).toBeUndefined(); + }); + + it("falls back to a fresh ensure when reopening a persisted ACP backend session id fails", async () => { + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockImplementation(async (inputUnknown: unknown) => { + const input = inputUnknown as { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + resumeSessionId?: string; + }; + if (input.resumeSessionId) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + "failed to resume persisted ACP session", + ); + } + return { + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `${input.sessionKey}:${input.mode}:runtime`, + backendSessionId: "acpx-sid-fresh", + }; + }); + runtimeState.getStatus.mockResolvedValue({ + summary: "status=alive", + backendSessionId: "acpx-sid-fresh", + details: { status: "alive" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:discord:default:retry-fresh"; + let currentMeta: SessionAcpMeta = { + ...readySessionMeta(), + runtimeSessionName: sessionKey, + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-stale", + lastUpdatedAt: Date.now(), + }, + }; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: currentMeta, + }; + }); + hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + mutate: ( + current: SessionAcpMeta | undefined, + entry: { acp?: SessionAcpMeta } | undefined, + ) => SessionAcpMeta | null | undefined; + }; + const next = params.mutate(currentMeta, { acp: currentMeta }); + if (next) { + currentMeta = next; + } + return { + sessionId: "session-1", + updatedAt: Date.now(), + acp: currentMeta, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-retry-fresh", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + expect(runtimeState.ensureSession.mock.calls[0]?.[0]).toMatchObject({ + sessionKey, + agent: "codex", + resumeSessionId: "acpx-sid-stale", + }); + const retryInput = runtimeState.ensureSession.mock.calls[1]?.[0] as + | { resumeSessionId?: string } + | undefined; + expect(retryInput?.resumeSessionId).toBeUndefined(); + expect(currentMeta.identity?.acpxSessionId).toBe("acpx-sid-fresh"); + }); + it("enforces acp.maxConcurrentSessions when opening new runtime handles", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ diff --git a/src/acp/runtime/session-identity.ts b/src/acp/runtime/session-identity.ts index 066a3cb71e5..1ff808bd28c 100644 --- a/src/acp/runtime/session-identity.ts +++ b/src/acp/runtime/session-identity.ts @@ -71,6 +71,15 @@ export function identityHasStableSessionId(identity: SessionAcpIdentity | undefi return Boolean(identity?.acpxSessionId || identity?.agentSessionId); } +export function resolveRuntimeResumeSessionId( + identity: SessionAcpIdentity | undefined, +): string | undefined { + if (!identity) { + return undefined; + } + return normalizeText(identity.acpxSessionId) ?? normalizeText(identity.agentSessionId); +} + export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean { if (!identity) { return true; From ad185dd4a89d71600092c89954d235d4e5cde384 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:27:17 -0700 Subject: [PATCH 344/393] CLI: make config compatibility advice opt-in --- src/commands/config-validation.test.ts | 40 ++++++++++++++++++++++++-- src/commands/config-validation.ts | 4 +++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 6d809a3b50b..83876477b43 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -18,7 +18,7 @@ describe("requireValidConfigSnapshot", () => { vi.clearAllMocks(); }); - it("returns config and emits a non-blocking compatibility advisory", async () => { + it("returns config without emitting compatibility advice by default", async () => { readConfigFileSnapshot.mockResolvedValue({ exists: true, valid: true, @@ -43,6 +43,40 @@ describe("requireValidConfigSnapshot", () => { const { requireValidConfigSnapshot } = await import("./config-validation.js"); const config = await requireValidConfigSnapshot(runtime); + expect(config).toEqual({ plugins: {} }); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + expect(buildPluginCompatibilityNotices).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalled(); + }); + + it("emits a non-blocking compatibility advisory when explicitly requested", async () => { + readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: { plugins: {} }, + issues: [], + }); + buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const { requireValidConfigSnapshot } = await import("./config-validation.js"); + const config = await requireValidConfigSnapshot(runtime, { + includeCompatibilityAdvisory: true, + }); + expect(config).toEqual({ plugins: {} }); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); @@ -66,7 +100,9 @@ describe("requireValidConfigSnapshot", () => { }; const { requireValidConfigSnapshot } = await import("./config-validation.js"); - const config = await requireValidConfigSnapshot(runtime); + const config = await requireValidConfigSnapshot(runtime, { + includeCompatibilityAdvisory: true, + }); expect(config).toBeNull(); expect(runtime.error).toHaveBeenCalled(); diff --git a/src/commands/config-validation.ts b/src/commands/config-validation.ts index 5ece0a1cf36..97c1ffc665e 100644 --- a/src/commands/config-validation.ts +++ b/src/commands/config-validation.ts @@ -9,6 +9,7 @@ import type { RuntimeEnv } from "../runtime.js"; export async function requireValidConfigSnapshot( runtime: RuntimeEnv, + opts?: { includeCompatibilityAdvisory?: boolean }, ): Promise { const snapshot = await readConfigFileSnapshot(); if (snapshot.exists && !snapshot.valid) { @@ -21,6 +22,9 @@ export async function requireValidConfigSnapshot( runtime.exit(1); return null; } + if (opts?.includeCompatibilityAdvisory !== true) { + return snapshot.config; + } const compatibility = buildPluginCompatibilityNotices({ config: snapshot.config }); if (compatibility.length > 0) { runtime.log( From c36a493e80bfee98338b860d43b4877bfa4d3415 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:27:18 -0700 Subject: [PATCH 345/393] Docs: clarify plugin compatibility signals --- docs/tools/plugin.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index e04c30f6003..a66579c9328 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -173,6 +173,28 @@ Direction: - prefer `before_prompt_build` for prompt mutation work - remove only after real usage drops and fixture coverage proves migration safety +### Compatibility signals + +OpenClaw treats config validity and plugin migration state as separate axes: + +- **config valid** — the config parses and referenced plugins can be resolved +- **compatibility advisory** — a plugin is still on a supported compatibility + path, such as `hook-only` +- **legacy warning** — a plugin still uses `before_agent_start` +- **hard error** — the config is invalid or plugin loading/validation fails + +Current compatibility guidance: + +- `hook-only` is advisory only. It remains a supported compatibility path for + existing plugins. +- `before_agent_start` is the only strong migration warning in the current + model. +- Neither state blocks an existing plugin by itself. + +You can see these signals in `openclaw doctor`, `openclaw status`, +`openclaw status --all`, `openclaw plugins doctor`, and +`openclaw plugins inspect `. + ## Architecture OpenClaw's plugin system has four layers: From fe84354a335377be7f267a696ddff32ec610d520 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:28:23 -0700 Subject: [PATCH 346/393] fix(plugins): add missing secret-input-schema build entry and Matrix runtime export buildSecretInputSchema was not included in plugin-sdk-entrypoints.json, so it was never emitted to dist/plugin-sdk/secret-input-schema.js. This caused a ReferenceError during onboard when configuring channels that use secret input schemas (matrix, feishu, mattermost, bluebubbles, nextcloud-talk, zalo). Additionally, the Matrix extension's hand-written runtime-api barrel was missing the re-export, unlike other extensions that use `export *` from their plugin-sdk subpath. Co-authored-by: hxy91819 Co-authored-by: Claude Opus 4.6 --- extensions/matrix/src/runtime-api.test.ts | 4 ++++ package.json | 4 ++++ scripts/lib/plugin-sdk-entrypoints.json | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/extensions/matrix/src/runtime-api.test.ts b/extensions/matrix/src/runtime-api.test.ts index a3768fbaf4b..97b6ffcbda4 100644 --- a/extensions/matrix/src/runtime-api.test.ts +++ b/extensions/matrix/src/runtime-api.test.ts @@ -10,6 +10,10 @@ describe("matrix runtime-api", () => { expect(typeof helpers.resolveDefaultAccountId).toBe("function"); }); + it("re-exports buildSecretInputSchema for config schema helpers", () => { + expect(typeof runtimeApi.buildSecretInputSchema).toBe("function"); + }); + it("does not re-export setup entrypoints that create extension cycles", () => { expect("matrixSetupWizard" in runtimeApi).toBe(false); expect("matrixSetupAdapter" in runtimeApi).toBe(false); diff --git a/package.json b/package.json index 5b9c9866ba9..6da833831a9 100644 --- a/package.json +++ b/package.json @@ -482,6 +482,10 @@ "types": "./dist/plugin-sdk/tool-send.d.ts", "default": "./dist/plugin-sdk/tool-send.js" }, + "./plugin-sdk/secret-input-schema": { + "types": "./dist/plugin-sdk/secret-input-schema.d.ts", + "default": "./dist/plugin-sdk/secret-input-schema.js" + }, "./cli-entry": "./openclaw.mjs" }, "scripts": { diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 55c22bf8470..108199d7772 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -109,5 +109,6 @@ "web-media", "speech", "state-paths", - "tool-send" + "tool-send", + "secret-input-schema" ] From d1fe30b35f82bb96e444a1e787e1ec8b80271542 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 18 Mar 2026 01:29:33 -0500 Subject: [PATCH 347/393] Plugins: add Twitch runtime barrel --- extensions/twitch/runtime-api.ts | 1 + extensions/twitch/src/config-schema.ts | 2 +- extensions/twitch/src/config.ts | 2 +- extensions/twitch/src/probe.ts | 2 +- extensions/twitch/src/runtime.ts | 2 +- extensions/twitch/src/send.ts | 2 +- extensions/twitch/src/status.ts | 2 +- extensions/twitch/src/test-fixtures.ts | 2 +- extensions/twitch/src/token.ts | 2 +- extensions/twitch/src/twitch-client.ts | 2 +- extensions/twitch/src/types.ts | 2 +- 11 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 extensions/twitch/runtime-api.ts diff --git a/extensions/twitch/runtime-api.ts b/extensions/twitch/runtime-api.ts new file mode 100644 index 00000000000..68033283423 --- /dev/null +++ b/extensions/twitch/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/twitch"; diff --git a/extensions/twitch/src/config-schema.ts b/extensions/twitch/src/config-schema.ts index 32bea8075e0..485e3f5a12d 100644 --- a/extensions/twitch/src/config-schema.ts +++ b/extensions/twitch/src/config-schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { MarkdownConfigSchema } from "../api.js"; +import { MarkdownConfigSchema } from "../runtime-api.js"; /** * Twitch user roles that can be allowed to interact with the bot diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index 5e7a8fa8441..b257146a37b 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "../api.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import type { TwitchAccountConfig } from "./types.js"; /** diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts index f22243e76ee..c7d2397791c 100644 --- a/extensions/twitch/src/probe.ts +++ b/extensions/twitch/src/probe.ts @@ -1,6 +1,6 @@ import { StaticAuthProvider } from "@twurple/auth"; import { ChatClient } from "@twurple/chat"; -import type { BaseProbeResult } from "../api.js"; +import type { BaseProbeResult } from "../runtime-api.js"; import type { TwitchAccountConfig } from "./types.js"; import { normalizeToken } from "./utils/twitch.js"; diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts index b5edc038816..38a8a1f9cb6 100644 --- a/extensions/twitch/src/runtime.ts +++ b/extensions/twitch/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "../api.js"; +import type { PluginRuntime } from "../runtime-api.js"; const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = createPluginRuntimeStore("Twitch runtime not initialized"); diff --git a/extensions/twitch/src/send.ts b/extensions/twitch/src/send.ts index 3b9f16d19c2..807d14ca3a8 100644 --- a/extensions/twitch/src/send.ts +++ b/extensions/twitch/src/send.ts @@ -5,7 +5,7 @@ * They support dependency injection via the `deps` parameter for testability. */ -import type { OpenClawConfig } from "../api.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { resolveTwitchToken } from "./token.js"; diff --git a/extensions/twitch/src/status.ts b/extensions/twitch/src/status.ts index 593cdcd25e8..053391af436 100644 --- a/extensions/twitch/src/status.ts +++ b/extensions/twitch/src/status.ts @@ -4,7 +4,7 @@ * Detects and reports configuration issues for Twitch accounts. */ -import type { ChannelStatusIssue } from "../api.js"; +import type { ChannelStatusIssue } from "../runtime-api.js"; import { getAccountConfig } from "./config.js"; import { resolveTwitchToken } from "./token.js"; import type { ChannelAccountSnapshot } from "./types.js"; diff --git a/extensions/twitch/src/test-fixtures.ts b/extensions/twitch/src/test-fixtures.ts index 664e01cde3f..b470b957d75 100644 --- a/extensions/twitch/src/test-fixtures.ts +++ b/extensions/twitch/src/test-fixtures.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, vi } from "vitest"; -import type { OpenClawConfig } from "../api.js"; +import type { OpenClawConfig } from "../runtime-api.js"; export const BASE_TWITCH_TEST_ACCOUNT = { username: "testbot", diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts index 840aa9b568f..ab14f6679e7 100644 --- a/extensions/twitch/src/token.ts +++ b/extensions/twitch/src/token.ts @@ -9,7 +9,7 @@ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only) */ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig } from "../api.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig } from "../runtime-api.js"; export type TwitchTokenSource = "env" | "config" | "none"; diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index 09fc3db264e..21e3dfd2709 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -1,6 +1,6 @@ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth"; import { ChatClient, LogLevel } from "@twurple/chat"; -import type { OpenClawConfig } from "../api.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveTwitchToken } from "./token.js"; import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js"; import { normalizeToken } from "./utils/twitch.js"; diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index f767b8aecd3..00a1ba67e22 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -22,7 +22,7 @@ import type { OpenClawConfig, OutboundDeliveryResult, RuntimeEnv, -} from "../api.js"; +} from "../runtime-api.js"; // ============================================================================ // Twitch-Specific Types From d341d68180682ebd5a9653cbe426492990cd0084 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:31:28 -0700 Subject: [PATCH 348/393] Plugin SDK: trim legacy helper exports --- extensions/copilot-proxy/runtime-api.ts | 2 +- extensions/device-pair/api.ts | 2 +- extensions/diagnostics-otel/api.ts | 2 +- extensions/diffs/api.ts | 2 +- extensions/llm-task/api.ts | 2 +- extensions/memory-lancedb/api.ts | 2 +- extensions/open-prose/runtime-api.ts | 2 +- extensions/phone-control/index.test.ts | 8 ++--- extensions/phone-control/runtime-api.ts | 2 +- extensions/talk-voice/api.ts | 2 +- extensions/thread-ownership/api.ts | 2 +- extensions/voice-call/api.ts | 2 +- package.json | 44 ------------------------- scripts/lib/plugin-sdk-entrypoints.json | 11 ------- src/plugin-sdk/subpaths.test.ts | 20 +++++++++++ src/plugin-sdk/twitch.ts | 5 ++- 16 files changed, 39 insertions(+), 71 deletions(-) diff --git a/extensions/copilot-proxy/runtime-api.ts b/extensions/copilot-proxy/runtime-api.ts index 849136c6efb..9f59e519281 100644 --- a/extensions/copilot-proxy/runtime-api.ts +++ b/extensions/copilot-proxy/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/copilot-proxy"; +export * from "../../src/plugin-sdk/copilot-proxy.js"; diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 299ad90f05d..137cd4b89ba 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/device-pair"; +export * from "../../src/plugin-sdk/device-pair.js"; diff --git a/extensions/diagnostics-otel/api.ts b/extensions/diagnostics-otel/api.ts index 01d7aed8989..077ad45965f 100644 --- a/extensions/diagnostics-otel/api.ts +++ b/extensions/diagnostics-otel/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/diagnostics-otel"; +export * from "../../src/plugin-sdk/diagnostics-otel.js"; diff --git a/extensions/diffs/api.ts b/extensions/diffs/api.ts index e6fbaf9022a..a200daea1fd 100644 --- a/extensions/diffs/api.ts +++ b/extensions/diffs/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/diffs"; +export * from "../../src/plugin-sdk/diffs.js"; diff --git a/extensions/llm-task/api.ts b/extensions/llm-task/api.ts index 8eebdd06e0b..25e5e13d5ca 100644 --- a/extensions/llm-task/api.ts +++ b/extensions/llm-task/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/llm-task"; +export * from "../../src/plugin-sdk/llm-task.js"; diff --git a/extensions/memory-lancedb/api.ts b/extensions/memory-lancedb/api.ts index c1bd12dd4b7..ce6e02cf02f 100644 --- a/extensions/memory-lancedb/api.ts +++ b/extensions/memory-lancedb/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/memory-lancedb"; +export * from "../../src/plugin-sdk/memory-lancedb.js"; diff --git a/extensions/open-prose/runtime-api.ts b/extensions/open-prose/runtime-api.ts index 1601f81be1f..1a7ce98ffef 100644 --- a/extensions/open-prose/runtime-api.ts +++ b/extensions/open-prose/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/open-prose"; +export * from "../../src/plugin-sdk/open-prose.js"; diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index e5fe260463b..21494a11a38 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -1,14 +1,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import registerPhoneControl from "./index.js"; import type { OpenClawPluginApi, OpenClawPluginCommandDefinition, PluginCommandContext, -} from "openclaw/plugin-sdk/phone-control"; -import { describe, expect, it, vi } from "vitest"; -import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; -import registerPhoneControl from "./index.js"; +} from "./runtime-api.js"; function createApi(params: { stateDir: string; diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts index 2e9e0adeba2..c113b9802be 100644 --- a/extensions/phone-control/runtime-api.ts +++ b/extensions/phone-control/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/phone-control"; +export * from "../../src/plugin-sdk/phone-control.js"; diff --git a/extensions/talk-voice/api.ts b/extensions/talk-voice/api.ts index a5ae821e944..5f50f1a5247 100644 --- a/extensions/talk-voice/api.ts +++ b/extensions/talk-voice/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/talk-voice"; +export * from "../../src/plugin-sdk/talk-voice.js"; diff --git a/extensions/thread-ownership/api.ts b/extensions/thread-ownership/api.ts index d94a5fd68e1..16e4afef70a 100644 --- a/extensions/thread-ownership/api.ts +++ b/extensions/thread-ownership/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/thread-ownership"; +export * from "../../src/plugin-sdk/thread-ownership.js"; diff --git a/extensions/voice-call/api.ts b/extensions/voice-call/api.ts index ef9f7d7a3c0..d0f69774b5e 100644 --- a/extensions/voice-call/api.ts +++ b/extensions/voice-call/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/voice-call"; +export * from "../../src/plugin-sdk/voice-call.js"; diff --git a/package.json b/package.json index 6da833831a9..c4cdd342df1 100644 --- a/package.json +++ b/package.json @@ -230,22 +230,6 @@ "types": "./dist/plugin-sdk/bluebubbles.d.ts", "default": "./dist/plugin-sdk/bluebubbles.js" }, - "./plugin-sdk/copilot-proxy": { - "types": "./dist/plugin-sdk/copilot-proxy.d.ts", - "default": "./dist/plugin-sdk/copilot-proxy.js" - }, - "./plugin-sdk/device-pair": { - "types": "./dist/plugin-sdk/device-pair.d.ts", - "default": "./dist/plugin-sdk/device-pair.js" - }, - "./plugin-sdk/diagnostics-otel": { - "types": "./dist/plugin-sdk/diagnostics-otel.d.ts", - "default": "./dist/plugin-sdk/diagnostics-otel.js" - }, - "./plugin-sdk/diffs": { - "types": "./dist/plugin-sdk/diffs.d.ts", - "default": "./dist/plugin-sdk/diffs.js" - }, "./plugin-sdk/feishu": { "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" @@ -258,10 +242,6 @@ "types": "./dist/plugin-sdk/irc.d.ts", "default": "./dist/plugin-sdk/irc.js" }, - "./plugin-sdk/llm-task": { - "types": "./dist/plugin-sdk/llm-task.d.ts", - "default": "./dist/plugin-sdk/llm-task.js" - }, "./plugin-sdk/lobster": { "types": "./dist/plugin-sdk/lobster.d.ts", "default": "./dist/plugin-sdk/lobster.js" @@ -282,10 +262,6 @@ "types": "./dist/plugin-sdk/memory-core.d.ts", "default": "./dist/plugin-sdk/memory-core.js" }, - "./plugin-sdk/memory-lancedb": { - "types": "./dist/plugin-sdk/memory-lancedb.d.ts", - "default": "./dist/plugin-sdk/memory-lancedb.js" - }, "./plugin-sdk/minimax-portal-auth": { "types": "./dist/plugin-sdk/minimax-portal-auth.d.ts", "default": "./dist/plugin-sdk/minimax-portal-auth.js" @@ -298,14 +274,6 @@ "types": "./dist/plugin-sdk/nostr.d.ts", "default": "./dist/plugin-sdk/nostr.js" }, - "./plugin-sdk/open-prose": { - "types": "./dist/plugin-sdk/open-prose.d.ts", - "default": "./dist/plugin-sdk/open-prose.js" - }, - "./plugin-sdk/phone-control": { - "types": "./dist/plugin-sdk/phone-control.d.ts", - "default": "./dist/plugin-sdk/phone-control.js" - }, "./plugin-sdk/qwen-portal-auth": { "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", "default": "./dist/plugin-sdk/qwen-portal-auth.js" @@ -314,10 +282,6 @@ "types": "./dist/plugin-sdk/synology-chat.d.ts", "default": "./dist/plugin-sdk/synology-chat.js" }, - "./plugin-sdk/talk-voice": { - "types": "./dist/plugin-sdk/talk-voice.d.ts", - "default": "./dist/plugin-sdk/talk-voice.js" - }, "./plugin-sdk/testing": { "types": "./dist/plugin-sdk/testing.d.ts", "default": "./dist/plugin-sdk/testing.js" @@ -326,10 +290,6 @@ "types": "./dist/plugin-sdk/test-utils.d.ts", "default": "./dist/plugin-sdk/test-utils.js" }, - "./plugin-sdk/thread-ownership": { - "types": "./dist/plugin-sdk/thread-ownership.d.ts", - "default": "./dist/plugin-sdk/thread-ownership.js" - }, "./plugin-sdk/tlon": { "types": "./dist/plugin-sdk/tlon.d.ts", "default": "./dist/plugin-sdk/tlon.js" @@ -338,10 +298,6 @@ "types": "./dist/plugin-sdk/twitch.d.ts", "default": "./dist/plugin-sdk/twitch.js" }, - "./plugin-sdk/voice-call": { - "types": "./dist/plugin-sdk/voice-call.d.ts", - "default": "./dist/plugin-sdk/voice-call.js" - }, "./plugin-sdk/zalo": { "types": "./dist/plugin-sdk/zalo.d.ts", "default": "./dist/plugin-sdk/zalo.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 108199d7772..ba136b70f6d 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -47,34 +47,23 @@ "msteams", "acpx", "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", "feishu", "googlechat", "irc", - "llm-task", "lobster", "lazy-runtime", "matrix", "mattermost", "memory-core", - "memory-lancedb", "minimax-portal-auth", "nextcloud-talk", "nostr", - "open-prose", - "phone-control", "qwen-portal-auth", "synology-chat", - "talk-voice", "testing", "test-utils", - "thread-ownership", "tlon", "twitch", - "voice-call", "zalo", "zalouser", "account-helpers", diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 313d2d4d263..6a5cec3d57c 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -42,6 +42,20 @@ const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ load: () => importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), })); +const trimmedLegacyExtensionSubpaths = [ + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "llm-task", + "memory-lancedb", + "open-prose", + "phone-control", + "talk-voice", + "thread-ownership", + "voice-call", +] as const; + const asExports = (mod: object) => mod as Record; const ircSdk = await import("openclaw/plugin-sdk/irc"); const feishuSdk = await import("openclaw/plugin-sdk/feishu"); @@ -312,6 +326,12 @@ describe("plugin-sdk subpath exports", () => { } }); + it("does not advertise trimmed legacy extension helper seams", () => { + for (const id of trimmedLegacyExtensionSubpaths) { + expect(pluginSdkSubpaths).not.toContain(id); + } + }); + it("keeps the newly added bundled plugin-sdk contracts available", async () => { expect(typeof bluebubblesSdk.parseFiniteNumber).toBe("function"); expect(typeof matrixSdk.matrixSetupWizard).toBe("object"); diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 9b200cf03f7..907cdd171fa 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -33,4 +33,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { twitchSetupAdapter, twitchSetupWizard } from "../../extensions/twitch/api.js"; +export { + twitchSetupAdapter, + twitchSetupWizard, +} from "../../extensions/twitch/src/setup-surface.js"; From a5fa75cdb319ddd5f0e62976df2e9e8ccbe96985 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:31:38 -0700 Subject: [PATCH 349/393] Plugins: accept Claude bundle hooks as wired capability in loader --- src/plugins/loader.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 251a08beb4e..ffccc04f4a6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1108,7 +1108,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi capability === "commands" && (record.bundleFormat === "claude" || record.bundleFormat === "cursor") ) && - !(capability === "hooks" && record.bundleFormat === "codex"), + !( + capability === "hooks" && + (record.bundleFormat === "codex" || record.bundleFormat === "claude") + ), ); for (const capability of unsupportedCapabilities) { registry.diagnostics.push({ From 98fbbebf6a1fc826eaaa0419e4cd4e5476149cf1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:33:15 -0700 Subject: [PATCH 350/393] Tests: add Claude bundle plugin inspect integration test --- src/plugins/bundle-claude-inspect.test.ts | 180 ++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/plugins/bundle-claude-inspect.test.ts diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts new file mode 100644 index 00000000000..87d48c0eff2 --- /dev/null +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { loadBundleManifest } from "./bundle-manifest.js"; +import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; + +/** + * Integration test: builds a Claude Code bundle plugin fixture on disk + * and verifies manifest parsing, capability detection, hook resolution, + * MCP server discovery, and settings detection all work end-to-end. + */ +describe("Claude bundle plugin inspect integration", () => { + let rootDir: string; + + beforeAll(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-")); + + // .claude-plugin/plugin.json + const manifestDir = path.join(rootDir, ".claude-plugin"); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, "plugin.json"), + JSON.stringify({ + name: "Test Claude Plugin", + description: "Integration test fixture for Claude bundle inspection", + version: "1.0.0", + skills: ["skill-packs"], + commands: "extra-commands", + agents: "agents", + hooks: "custom-hooks", + mcpServers: ".mcp.json", + lspServers: ".lsp.json", + outputStyles: "output-styles", + }), + "utf-8", + ); + + // skills/demo/SKILL.md + const skillDir = path.join(rootDir, "skill-packs", "demo"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: demo\ndescription: A demo skill\n---\nDo something useful.", + "utf-8", + ); + + // commands/cmd/SKILL.md + const cmdDir = path.join(rootDir, "extra-commands", "cmd"); + fs.mkdirSync(cmdDir, { recursive: true }); + fs.writeFileSync( + path.join(cmdDir, "SKILL.md"), + "---\nname: cmd\ndescription: A command skill\n---\nRun a command.", + "utf-8", + ); + + // hooks/hooks.json (default hook path) + const hooksDir = path.join(rootDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, "hooks.json"), '{"hooks":[]}', "utf-8"); + + // custom-hooks/ (manifest-declared hook path) + fs.mkdirSync(path.join(rootDir, "custom-hooks"), { recursive: true }); + + // .mcp.json with a stdio MCP server + fs.writeFileSync( + path.join(rootDir, ".mcp.json"), + JSON.stringify({ + mcpServers: { + "test-stdio-server": { + command: "echo", + args: ["hello"], + }, + "test-sse-server": { + url: "http://localhost:3000/sse", + }, + }, + }), + "utf-8", + ); + + // settings.json + fs.writeFileSync( + path.join(rootDir, "settings.json"), + JSON.stringify({ thinkingLevel: "high" }), + "utf-8", + ); + + // agents/ directory + fs.mkdirSync(path.join(rootDir, "agents"), { recursive: true }); + + // .lsp.json + fs.writeFileSync(path.join(rootDir, ".lsp.json"), '{"lspServers":{}}', "utf-8"); + + // output-styles/ directory + fs.mkdirSync(path.join(rootDir, "output-styles"), { recursive: true }); + }); + + afterAll(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + it("loads the full Claude bundle manifest with all capabilities", () => { + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + const m = result.manifest; + expect(m.name).toBe("Test Claude Plugin"); + expect(m.description).toBe("Integration test fixture for Claude bundle inspection"); + expect(m.version).toBe("1.0.0"); + expect(m.bundleFormat).toBe("claude"); + }); + + it("resolves skills from both skills and commands paths", () => { + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.manifest.skills).toContain("skill-packs"); + expect(result.manifest.skills).toContain("extra-commands"); + }); + + it("resolves hooks from default and declared paths", () => { + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + // Default hooks/hooks.json path + declared custom-hooks + expect(result.manifest.hooks).toContain("hooks/hooks.json"); + expect(result.manifest.hooks).toContain("custom-hooks"); + }); + + it("detects settings files", () => { + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.manifest.settingsFiles).toEqual(["settings.json"]); + }); + + it("detects all bundle capabilities", () => { + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + const caps = result.manifest.capabilities; + expect(caps).toContain("skills"); + expect(caps).toContain("commands"); + expect(caps).toContain("agents"); + expect(caps).toContain("hooks"); + expect(caps).toContain("mcpServers"); + expect(caps).toContain("lspServers"); + expect(caps).toContain("outputStyles"); + expect(caps).toContain("settings"); + }); + + it("inspects MCP runtime support with supported and unsupported servers", () => { + const mcp = inspectBundleMcpRuntimeSupport({ + pluginId: "test-claude-plugin", + rootDir, + bundleFormat: "claude", + }); + + expect(mcp.hasSupportedStdioServer).toBe(true); + expect(mcp.supportedServerNames).toContain("test-stdio-server"); + expect(mcp.unsupportedServerNames).toContain("test-sse-server"); + expect(mcp.diagnostics).toEqual([]); + }); +}); From 03855539183742b7991029c7e95bc1835204473a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:48:00 -0700 Subject: [PATCH 351/393] Plugin SDK: trim lobster and qwen helper exports --- extensions/lobster/runtime-api.ts | 2 +- extensions/lobster/src/lobster-tool.test.ts | 2 +- extensions/qwen-portal-auth/runtime-api.ts | 2 +- package.json | 8 -------- scripts/lib/plugin-sdk-entrypoints.json | 2 -- src/plugin-sdk/subpaths.test.ts | 2 ++ src/plugins/contracts/runtime.contract.test.ts | 4 ++-- 7 files changed, 7 insertions(+), 15 deletions(-) diff --git a/extensions/lobster/runtime-api.ts b/extensions/lobster/runtime-api.ts index 7ab2351b77d..24898e04cf5 100644 --- a/extensions/lobster/runtime-api.ts +++ b/extensions/lobster/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/lobster"; +export * from "../../src/plugin-sdk/lobster.js"; diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 62c0fed6d81..8c010e20f11 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/lobster"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createWindowsCmdShimFixture, diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts index 232a2886110..ccd9abae569 100644 --- a/extensions/qwen-portal-auth/runtime-api.ts +++ b/extensions/qwen-portal-auth/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/qwen-portal-auth"; +export * from "../../src/plugin-sdk/qwen-portal-auth.js"; diff --git a/package.json b/package.json index c4cdd342df1..2a17025c18a 100644 --- a/package.json +++ b/package.json @@ -242,10 +242,6 @@ "types": "./dist/plugin-sdk/irc.d.ts", "default": "./dist/plugin-sdk/irc.js" }, - "./plugin-sdk/lobster": { - "types": "./dist/plugin-sdk/lobster.d.ts", - "default": "./dist/plugin-sdk/lobster.js" - }, "./plugin-sdk/lazy-runtime": { "types": "./dist/plugin-sdk/lazy-runtime.d.ts", "default": "./dist/plugin-sdk/lazy-runtime.js" @@ -274,10 +270,6 @@ "types": "./dist/plugin-sdk/nostr.d.ts", "default": "./dist/plugin-sdk/nostr.js" }, - "./plugin-sdk/qwen-portal-auth": { - "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", - "default": "./dist/plugin-sdk/qwen-portal-auth.js" - }, "./plugin-sdk/synology-chat": { "types": "./dist/plugin-sdk/synology-chat.d.ts", "default": "./dist/plugin-sdk/synology-chat.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index ba136b70f6d..cce8dfe895a 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -50,7 +50,6 @@ "feishu", "googlechat", "irc", - "lobster", "lazy-runtime", "matrix", "mattermost", @@ -58,7 +57,6 @@ "minimax-portal-auth", "nextcloud-talk", "nostr", - "qwen-portal-auth", "synology-chat", "testing", "test-utils", diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6a5cec3d57c..d7b9399a0f2 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -48,9 +48,11 @@ const trimmedLegacyExtensionSubpaths = [ "diagnostics-otel", "diffs", "llm-task", + "lobster", "memory-lancedb", "open-prose", "phone-control", + "qwen-portal-auth", "talk-voice", "thread-ownership", "voice-call", diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index f3985500af4..ba6e7df1187 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -18,8 +18,8 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => { }; }); -vi.mock("openclaw/plugin-sdk/qwen-portal-auth", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/qwen-portal-auth"); +vi.mock("../../plugin-sdk/qwen-portal-auth.js", async () => { + const actual = await vi.importActual("../../plugin-sdk/qwen-portal-auth.js"); return { ...actual, refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, From 5eea523f39f5204a1e7c245e41282841f81d5815 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:52:32 -0700 Subject: [PATCH 352/393] UI: remove dead control UI modules --- pnpm-lock.yaml | 18 +---- ui/package.json | 8 +- ui/src/styles/layout.mobile.css | 73 +---------------- ui/src/ui/chat-export.ts | 1 - ui/src/ui/data/moonshot-kimi-k2.ts | 45 ----------- ui/src/ui/tool-labels.ts | 39 --------- ui/src/ui/views/bottom-tabs.ts | 33 -------- ui/src/ui/views/config-search.node.test.ts | 50 ------------ ui/src/ui/views/config-search.ts | 92 ---------------------- ui/src/ui/views/overview-quick-actions.ts | 31 -------- 10 files changed, 6 insertions(+), 384 deletions(-) delete mode 100644 ui/src/ui/chat-export.ts delete mode 100644 ui/src/ui/data/moonshot-kimi-k2.ts delete mode 100644 ui/src/ui/tool-labels.ts delete mode 100644 ui/src/ui/views/bottom-tabs.ts delete mode 100644 ui/src/ui/views/config-search.node.test.ts delete mode 100644 ui/src/ui/views/config-search.ts delete mode 100644 ui/src/ui/views/overview-quick-actions.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b43381e461c..4fb25b899d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,12 +625,6 @@ importers: ui: dependencies: - '@lit-labs/signals': - specifier: ^0.2.0 - version: 0.2.0 - '@lit/context': - specifier: ^1.1.6 - version: 1.1.6 '@noble/ed25519': specifier: 3.0.1 version: 3.0.1 @@ -643,15 +637,6 @@ importers: marked: specifier: ^17.0.4 version: 17.0.4 - signal-polyfill: - specifier: ^0.2.2 - version: 0.2.2 - signal-utils: - specifier: ^0.21.1 - version: 0.21.1(signal-polyfill@0.2.2) - vite: - specifier: 8.0.0 - version: 8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) devDependencies: '@vitest/browser-playwright': specifier: 4.1.0 @@ -662,6 +647,9 @@ importers: playwright: specifier: ^1.58.2 version: 1.58.2 + vite: + specifier: 8.0.0 + version: 8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: 4.1.0 version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) diff --git a/ui/package.json b/ui/package.json index 71eb17fe80a..5d514f671cd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,20 +9,16 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@lit-labs/signals": "^0.2.0", - "@lit/context": "^1.1.6", "@noble/ed25519": "3.0.1", "dompurify": "^3.3.3", "lit": "^3.3.2", - "marked": "^17.0.4", - "signal-polyfill": "^0.2.2", - "signal-utils": "^0.21.1", - "vite": "8.0.0" + "marked": "^17.0.4" }, "devDependencies": { "@vitest/browser-playwright": "4.1.0", "jsdom": "^29.0.0", "playwright": "^1.58.2", + "vite": "8.0.0", "vitest": "4.1.0" } } diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index e459bca2bca..6d943253804 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -249,6 +249,7 @@ .topnav-shell__content { display: none; + width: 100%; } .topbar-nav-toggle { @@ -650,75 +651,3 @@ font-size: 12px; } } - -/* =========================================== - Bottom Tabs (mobile navigation bar) - =========================================== */ - -.bottom-tabs { - display: none; -} - -@media (max-width: 768px) { - .bottom-tabs { - display: flex; - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 60; - background: var(--bg); - border-top: 1px solid var(--border); - padding: 4px 0 calc(4px + env(safe-area-inset-bottom, 0px)); - justify-content: space-around; - align-items: stretch; - } - - .bottom-tab { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - flex: 1; - padding: 6px 4px; - border: none; - background: none; - color: var(--muted); - font-size: 10px; - cursor: pointer; - transition: - color var(--duration-fast) ease, - opacity var(--duration-fast) ease; - } - - .bottom-tab__icon { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - } - - .bottom-tab__icon svg { - width: 20px; - height: 20px; - stroke: currentColor; - fill: none; - stroke-width: 1.5px; - stroke-linecap: round; - stroke-linejoin: round; - } - - .bottom-tab__label { - font-weight: 500; - letter-spacing: 0.01em; - } - - .bottom-tab--active { - color: var(--accent); - } - - .bottom-tab:active { - opacity: 0.7; - } -} diff --git a/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts deleted file mode 100644 index ed5bbf931f8..00000000000 --- a/ui/src/ui/chat-export.ts +++ /dev/null @@ -1 +0,0 @@ -export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/data/moonshot-kimi-k2.ts b/ui/src/ui/data/moonshot-kimi-k2.ts deleted file mode 100644 index f9aa8d1311e..00000000000 --- a/ui/src/ui/data/moonshot-kimi-k2.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const MOONSHOT_KIMI_K2_DEFAULT_ID = "kimi-k2.5"; -export const MOONSHOT_KIMI_K2_CONTEXT_WINDOW = 256000; -export const MOONSHOT_KIMI_K2_MAX_TOKENS = 8192; -export const MOONSHOT_KIMI_K2_INPUT = ["text"] as const; -export const MOONSHOT_KIMI_K2_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -} as const; - -export const MOONSHOT_KIMI_K2_MODELS = [ - { - id: "kimi-k2.5", - name: "Kimi K2.5", - alias: "Kimi K2.5", - reasoning: false, - }, - { - id: "kimi-k2-0905-preview", - name: "Kimi K2 0905 Preview", - alias: "Kimi K2", - reasoning: false, - }, - { - id: "kimi-k2-turbo-preview", - name: "Kimi K2 Turbo", - alias: "Kimi K2 Turbo", - reasoning: false, - }, - { - id: "kimi-k2-thinking", - name: "Kimi K2 Thinking", - alias: "Kimi K2 Thinking", - reasoning: true, - }, - { - id: "kimi-k2-thinking-turbo", - name: "Kimi K2 Thinking Turbo", - alias: "Kimi K2 Thinking Turbo", - reasoning: true, - }, -] as const; - -export type MoonshotKimiK2Model = (typeof MOONSHOT_KIMI_K2_MODELS)[number]; diff --git a/ui/src/ui/tool-labels.ts b/ui/src/ui/tool-labels.ts deleted file mode 100644 index e4818c49362..00000000000 --- a/ui/src/ui/tool-labels.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Map raw tool names to human-friendly labels for the chat UI. - * Unknown tools are title-cased with underscores replaced by spaces. - */ - -export const TOOL_LABELS: Record = { - exec: "Run Command", - bash: "Run Command", - read: "Read File", - write: "Write File", - edit: "Edit File", - apply_patch: "Apply Patch", - web_search: "Web Search", - web_fetch: "Fetch Page", - browser: "Browser", - message: "Send Message", - image: "Generate Image", - canvas: "Canvas", - cron: "Cron", - gateway: "Gateway", - nodes: "Nodes", - memory_search: "Search Memory", - memory_get: "Get Memory", - session_status: "Session Status", - sessions_list: "List Sessions", - sessions_history: "Session History", - sessions_send: "Send to Session", - sessions_spawn: "Spawn Session", - agents_list: "List Agents", -}; - -export function friendlyToolName(raw: string): string { - const mapped = TOOL_LABELS[raw]; - if (mapped) { - return mapped; - } - // Title-case fallback: "some_tool_name" → "Some Tool Name" - return raw.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} diff --git a/ui/src/ui/views/bottom-tabs.ts b/ui/src/ui/views/bottom-tabs.ts deleted file mode 100644 index b8dfbebf39c..00000000000 --- a/ui/src/ui/views/bottom-tabs.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { html } from "lit"; -import { icons } from "../icons.ts"; -import type { Tab } from "../navigation.ts"; - -export type BottomTabsProps = { - activeTab: Tab; - onTabChange: (tab: Tab) => void; -}; - -const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [ - { id: "overview", label: "Dashboard", icon: "barChart" }, - { id: "chat", label: "Chat", icon: "messageSquare" }, - { id: "sessions", label: "Sessions", icon: "fileText" }, - { id: "config", label: "Settings", icon: "settings" }, -]; - -export function renderBottomTabs(props: BottomTabsProps) { - return html` - - `; -} diff --git a/ui/src/ui/views/config-search.node.test.ts b/ui/src/ui/views/config-search.node.test.ts deleted file mode 100644 index d1a5a09d837..00000000000 --- a/ui/src/ui/views/config-search.node.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - appendTagFilter, - getTagFilters, - hasTagFilter, - removeTagFilter, - replaceTagFilters, - toggleTagFilter, -} from "./config-search.ts"; - -describe("config search tag helper", () => { - it("adds a tag when query is empty", () => { - expect(appendTagFilter("", "security")).toBe("tag:security"); - }); - - it("appends a tag to existing text query", () => { - expect(appendTagFilter("token", "security")).toBe("token tag:security"); - }); - - it("deduplicates existing tag filters case-insensitively", () => { - expect(appendTagFilter("token tag:Security", "security")).toBe("token tag:Security"); - }); - - it("detects exact tag terms", () => { - expect(hasTagFilter("tag:security token", "security")).toBe(true); - expect(hasTagFilter("tag:security-hard token", "security")).toBe(false); - }); - - it("removes only the selected active tag", () => { - expect(removeTagFilter("token tag:security tag:auth", "security")).toBe("token tag:auth"); - }); - - it("toggle removes active tag and keeps text", () => { - expect(toggleTagFilter("token tag:security", "security")).toBe("token"); - }); - - it("toggle adds missing tag", () => { - expect(toggleTagFilter("token", "channels")).toBe("token tag:channels"); - }); - - it("extracts unique normalized tags from query", () => { - expect(getTagFilters("token tag:Security tag:auth tag:security")).toEqual(["security", "auth"]); - }); - - it("replaces only tag filters and preserves free text", () => { - expect(replaceTagFilters("token tag:security mode", ["auth", "channels"])).toBe( - "token mode tag:auth tag:channels", - ); - }); -}); diff --git a/ui/src/ui/views/config-search.ts b/ui/src/ui/views/config-search.ts deleted file mode 100644 index f6973d3a2cd..00000000000 --- a/ui/src/ui/views/config-search.ts +++ /dev/null @@ -1,92 +0,0 @@ -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function normalizeTag(tag: string): string { - return tag.trim().toLowerCase(); -} - -export function getTagFilters(query: string): string[] { - const seen = new Set(); - const tags: string[] = []; - const pattern = /(^|\s)tag:([^\s]+)/gi; - const raw = query.trim(); - let match: RegExpExecArray | null = pattern.exec(raw); - while (match) { - const normalized = normalizeTag(match[2] ?? ""); - if (normalized && !seen.has(normalized)) { - seen.add(normalized); - tags.push(normalized); - } - match = pattern.exec(raw); - } - return tags; -} - -export function hasTagFilter(query: string, tag: string): boolean { - const normalizedTag = normalizeTag(tag); - if (!normalizedTag) { - return false; - } - const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "i"); - return pattern.test(query.trim()); -} - -export function appendTagFilter(query: string, tag: string): string { - const normalizedTag = normalizeTag(tag); - const trimmed = query.trim(); - if (!normalizedTag) { - return trimmed; - } - if (!trimmed) { - return `tag:${normalizedTag}`; - } - if (hasTagFilter(trimmed, normalizedTag)) { - return trimmed; - } - return `${trimmed} tag:${normalizedTag}`; -} - -export function removeTagFilter(query: string, tag: string): string { - const normalizedTag = normalizeTag(tag); - const trimmed = query.trim(); - if (!normalizedTag || !trimmed) { - return trimmed; - } - const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "ig"); - return trimmed.replace(pattern, " ").replace(/\s+/g, " ").trim(); -} - -export function replaceTagFilters(query: string, tags: readonly string[]): string { - const uniqueTags: string[] = []; - const seen = new Set(); - for (const tag of tags) { - const normalized = normalizeTag(tag); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - uniqueTags.push(normalized); - } - - const trimmed = query.trim(); - const withoutTags = trimmed - .replace(/(^|\s)tag:([^\s]+)/gi, " ") - .replace(/\s+/g, " ") - .trim(); - const tagTokens = uniqueTags.map((tag) => `tag:${tag}`).join(" "); - if (withoutTags && tagTokens) { - return `${withoutTags} ${tagTokens}`; - } - if (withoutTags) { - return withoutTags; - } - return tagTokens; -} - -export function toggleTagFilter(query: string, tag: string): string { - if (hasTagFilter(query, tag)) { - return removeTagFilter(query, tag); - } - return appendTagFilter(query, tag); -} diff --git a/ui/src/ui/views/overview-quick-actions.ts b/ui/src/ui/views/overview-quick-actions.ts deleted file mode 100644 index b1358ca2e67..00000000000 --- a/ui/src/ui/views/overview-quick-actions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { html } from "lit"; -import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; - -export type OverviewQuickActionsProps = { - onNavigate: (tab: string) => void; - onRefresh: () => void; -}; - -export function renderOverviewQuickActions(props: OverviewQuickActionsProps) { - return html` -
- - - - -
- `; -} From bd444435c914e72e94d2ee5c010b1e30d7dc05ec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:53:30 -0700 Subject: [PATCH 353/393] Plugin SDK: clarify ACPX public seam --- src/plugin-sdk/acpx.ts | 4 ++-- src/plugin-sdk/subpaths.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugin-sdk/acpx.ts b/src/plugin-sdk/acpx.ts index 36da2f48810..9d634ec8fb5 100644 --- a/src/plugin-sdk/acpx.ts +++ b/src/plugin-sdk/acpx.ts @@ -1,5 +1,5 @@ -// Narrow plugin-sdk surface for the bundled acpx plugin. -// Keep this list additive and scoped to symbols used under extensions/acpx. +// Public ACPX runtime backend helpers. +// Keep this surface narrow and limited to the ACP runtime/backend contract. export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; export { AcpRuntimeError } from "../acp/runtime/errors.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index d7b9399a0f2..f3cd5537398 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -315,7 +315,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof tlonSdk.tlonSetupAdapter).toBe("object"); }); - it("exports acpx helpers", async () => { + it("exports ACPX runtime backend helpers", async () => { expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function"); expect(typeof acpxSdk.omitEnvKeysCaseInsensitive).toBe("function"); }); From 8ac4b09fa429ddad71a85e8bb4f5c36dec0e28e3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:55:24 -0700 Subject: [PATCH 354/393] docs: fix em-dash headings and broken links across docs - Replace em-dashes in headings with hyphens/parens (breaks Mintlify anchors) - Fix broken /testing link in pi-dev.md to /help/testing - Convert absolute docs URLs to root-relative in pi-dev.md Files: migrating.md, images.md, audio.md, media-understanding.md, venice.md, minimax.md, AGENTS.default.md, security/index.md, pi-dev.md Co-Authored-By: Claude Opus 4.6 --- docs/gateway/security/index.md | 2 +- docs/install/migrating.md | 8 ++++---- docs/nodes/audio.md | 2 +- docs/nodes/images.md | 2 +- docs/nodes/media-understanding.md | 2 +- docs/pi-dev.md | 4 ++-- docs/providers/minimax.md | 2 +- docs/providers/venice.md | 4 ++-- docs/reference/AGENTS.default.md | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 5fbd26a826e..b9f37597b58 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -499,7 +499,7 @@ Treat the snippet above as **secure DM mode**: If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). -## Allowlists (DM + groups) — terminology +## Allowlists (DM + groups) - terminology OpenClaw has two separate “who can trigger me?” layers: diff --git a/docs/install/migrating.md b/docs/install/migrating.md index 7a925341abd..64c136be425 100644 --- a/docs/install/migrating.md +++ b/docs/install/migrating.md @@ -67,7 +67,7 @@ Those live under `$OPENCLAW_STATE_DIR`. ## Migration steps (recommended) -### Step 0 — Make a backup (old machine) +### Step 0 - Make a backup (old machine) On the **old** machine, stop the gateway first so files aren’t changing mid-copy: @@ -87,7 +87,7 @@ tar -czf openclaw-workspace.tgz .openclaw/workspace If you have multiple profiles/state dirs (e.g. `~/.openclaw-main`, `~/.openclaw-work`), archive each. -### Step 1 — Install OpenClaw on the new machine +### Step 1 - Install OpenClaw on the new machine On the **new** machine, install the CLI (and Node if needed): @@ -95,7 +95,7 @@ On the **new** machine, install the CLI (and Node if needed): At this stage, it’s OK if onboarding creates a fresh `~/.openclaw/` — you will overwrite it in the next step. -### Step 2 — Copy the state dir + workspace to the new machine +### Step 2 - Copy the state dir + workspace to the new machine Copy **both**: @@ -113,7 +113,7 @@ After copying, ensure: - Hidden directories were included (e.g. `.openclaw/`) - File ownership is correct for the user running the gateway -### Step 3 — Run Doctor (migrations + service repair) +### Step 3 - Run Doctor (migrations + service repair) On the **new** machine: diff --git a/docs/nodes/audio.md b/docs/nodes/audio.md index 1be35610323..57e9ab14d8a 100644 --- a/docs/nodes/audio.md +++ b/docs/nodes/audio.md @@ -5,7 +5,7 @@ read_when: title: "Audio and Voice Notes" --- -# Audio / Voice Notes — 2026-01-17 +# Audio / Voice Notes (2026-01-17) ## What works diff --git a/docs/nodes/images.md b/docs/nodes/images.md index c5f7bade748..6236ad089ef 100644 --- a/docs/nodes/images.md +++ b/docs/nodes/images.md @@ -5,7 +5,7 @@ read_when: title: "Image and Media Support" --- -# Image & Media Support — 2025-12-05 +# Image & Media Support (2025-12-05) The WhatsApp channel runs via **Baileys Web**. This document captures the current media handling rules for send, gateway, and agent replies. diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index ab3701387be..3178854ccfb 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -6,7 +6,7 @@ read_when: title: "Media Understanding" --- -# Media Understanding (Inbound) — 2026-01-17 +# Media Understanding - Inbound (2026-01-17) OpenClaw can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual. diff --git a/docs/pi-dev.md b/docs/pi-dev.md index 322bd13cd39..3b0918c4928 100644 --- a/docs/pi-dev.md +++ b/docs/pi-dev.md @@ -76,5 +76,5 @@ If you only want to reset sessions, delete `agents//sessions/` and `age ## References -- [https://docs.openclaw.ai/testing](https://docs.openclaw.ai/testing) -- [https://docs.openclaw.ai/start/getting-started](https://docs.openclaw.ai/start/getting-started) +- [Testing](/help/testing) +- [Getting Started](/start/getting-started) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 0d3635352cc..c578a89d6e5 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -35,7 +35,7 @@ MiniMax highlights these improvements in M2.5: ## Choose a setup -### MiniMax OAuth (Coding Plan) — recommended +### MiniMax OAuth (Coding Plan) - recommended **Best for:** quick setup with MiniMax Coding Plan via OAuth, no API key required. diff --git a/docs/providers/venice.md b/docs/providers/venice.md index 520cf22d82b..6f3c4b9313d 100644 --- a/docs/providers/venice.md +++ b/docs/providers/venice.md @@ -124,7 +124,7 @@ openclaw models list | grep venice ## Available Models (41 Total) -### Private Models (26) — Fully Private, No Logging +### Private Models (26) - Fully Private, No Logging | Model ID | Name | Context | Features | | -------------------------------------- | ----------------------------------- | ------- | -------------------------- | @@ -155,7 +155,7 @@ openclaw models list | grep venice | `minimax-m21` | MiniMax M2.1 | 198k | Reasoning | | `minimax-m25` | MiniMax M2.5 | 198k | Reasoning | -### Anonymized Models (15) — Via Venice Proxy +### Anonymized Models (15) - Via Venice Proxy | Model ID | Name | Context | Features | | ------------------------------- | ------------------------------ | ------- | ------------------------- | diff --git a/docs/reference/AGENTS.default.md b/docs/reference/AGENTS.default.md index 7427f53c071..7bfb2351d0d 100644 --- a/docs/reference/AGENTS.default.md +++ b/docs/reference/AGENTS.default.md @@ -6,7 +6,7 @@ read_when: - Enabling or auditing default skills --- -# AGENTS.md — OpenClaw Personal Assistant (default) +# AGENTS.md - OpenClaw Personal Assistant (default) ## First run (recommended) From 3d31ba7830fd1c5e2cb8a869601bf5fe68739bf4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:56:49 -0700 Subject: [PATCH 355/393] Plugin SDK: guard package subpaths and fix Twitch setup export * fix(plugins): add missing secret-input-schema build entry and Matrix runtime export buildSecretInputSchema was not included in plugin-sdk-entrypoints.json, so it was never emitted to dist/plugin-sdk/secret-input-schema.js. This caused a ReferenceError during onboard when configuring channels that use secret input schemas (matrix, feishu, mattermost, bluebubbles, nextcloud-talk, zalo). Additionally, the Matrix extension's hand-written runtime-api barrel was missing the re-export, unlike other extensions that use `export *` from their plugin-sdk subpath. Co-Authored-By: Claude Opus 4.6 * Plugin SDK: guard package subpaths and fix Twitch setup export * Plugin SDK: fix import guardrail drift --------- Co-authored-by: hxy91819 Co-authored-by: Claude Opus 4.6 --- extensions/discord/src/directory-config.ts | 23 +-- extensions/slack/src/directory-config.ts | 23 ++- .../slack/src/message-action-dispatch.ts | 2 +- extensions/telegram/src/directory-config.ts | 23 +-- extensions/whatsapp/src/directory-config.ts | 2 +- extensions/whatsapp/src/normalize.ts | 2 + .../channel-import-guardrails.test.ts | 5 +- src/plugin-sdk/channel-runtime.ts | 1 + .../package-contract-guardrails.test.ts | 145 ++++++++++++++++++ src/plugin-sdk/runtime-api-guardrails.test.ts | 41 +++-- 10 files changed, 218 insertions(+), 49 deletions(-) create mode 100644 src/plugin-sdk/package-contract-guardrails.test.ts diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 8828a1854eb..9c5e794924a 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -4,15 +4,20 @@ import { toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedDiscordAccount } from "../../../src/channels/read-only-account-inspect.discord.runtime.js"; -import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js"; +import { inspectDiscordAccount } from "../api.js"; +import type { InspectedDiscordAccount } from "../api.js"; -export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", +function inspectDiscordDirectoryAccount( + params: DirectoryConfigParams, +): InspectedDiscordAccount | null { + return inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedDiscordAccount | null; + }); +} + +export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = inspectDiscordDirectoryAccount(params); if (!account || !("config" in account)) { return []; } @@ -34,11 +39,7 @@ export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfi } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", - cfg: params.cfg, - accountId: params.accountId, - })) as InspectedDiscordAccount | null; + const account = inspectDiscordDirectoryAccount(params); if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index 635222f9c2e..0bc0f49804e 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,3 +1,4 @@ +import { normalizeSlackMessagingTarget } from "openclaw/plugin-sdk/channel-runtime"; import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, @@ -5,16 +6,18 @@ import { toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { normalizeSlackMessagingTarget } from "../../../src/channels/plugins/normalize/slack.js"; -import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js"; -import type { InspectedSlackAccount } from "../../../src/channels/read-only-account-inspect.slack.runtime.js"; +import { inspectSlackAccount } from "../api.js"; +import type { InspectedSlackAccount } from "../api.js"; -export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", +function inspectSlackDirectoryAccount(params: DirectoryConfigParams): InspectedSlackAccount | null { + return inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedSlackAccount | null; + }); +} + +export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = inspectSlackDirectoryAccount(params); if (!account || !("config" in account)) { return []; } @@ -40,11 +43,7 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", - cfg: params.cfg, - accountId: params.accountId, - })) as InspectedSlackAccount | null; + const account = inspectSlackDirectoryAccount(params); if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 4a2e17f5455..55576d9e822 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,7 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; -import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index 10abc88d784..3355b295cca 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -6,15 +6,20 @@ import { toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js"; -import type { InspectedTelegramAccount } from "../../../src/channels/read-only-account-inspect.telegram.runtime.js"; +import { inspectTelegramAccount } from "../api.js"; +import type { InspectedTelegramAccount } from "../api.js"; -export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", +async function inspectTelegramDirectoryAccount( + params: DirectoryConfigParams, +): Promise { + return inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedTelegramAccount | null; + }); +} + +export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = await inspectTelegramDirectoryAccount(params); if (!account || !("config" in account)) { return []; } @@ -36,11 +41,7 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", - cfg: params.cfg, - accountId: params.accountId, - })) as InspectedTelegramAccount | null; + const account = await inspectTelegramDirectoryAccount(params); if (!account || !("config" in account)) { return []; } diff --git a/extensions/whatsapp/src/directory-config.ts b/extensions/whatsapp/src/directory-config.ts index ad7b7d257e7..1a5fbbff9b0 100644 --- a/extensions/whatsapp/src/directory-config.ts +++ b/extensions/whatsapp/src/directory-config.ts @@ -3,8 +3,8 @@ import { listDirectoryUserEntriesFromAllowFrom, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; import { resolveWhatsAppAccount } from "./accounts.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) { const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts index bfecb31e4a5..d0506cd5883 100644 --- a/extensions/whatsapp/src/normalize.ts +++ b/extensions/whatsapp/src/normalize.ts @@ -1,5 +1,7 @@ export { + isWhatsAppGroupJid, looksLikeWhatsAppTargetId, normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, + normalizeWhatsAppTarget, } from "openclaw/plugin-sdk/channel-runtime"; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 69626948743..a4ca46a569c 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -252,7 +252,10 @@ function collectCoreSourceFiles(): string[] { fullPath.includes(".test.") || fullPath.includes(".spec.") || fullPath.includes(".fixture.") || - fullPath.includes(".snap") + fullPath.includes(".snap") || + // src/plugin-sdk is the curated bridge layer; validate its contracts with dedicated + // plugin-sdk guardrails instead of the generic "core should not touch extensions" rule. + fullPath.includes(`${resolve(ROOT_DIR, "plugin-sdk")}/`) ) { continue; } diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 5e90b196c09..59832d70f80 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -43,6 +43,7 @@ export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; export * from "../polls.js"; export * from "../utils/message-channel.js"; +export * from "../whatsapp/normalize.js"; export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; export * from "./channel-lifecycle.js"; export * from "./directory-runtime.js"; diff --git a/src/plugin-sdk/package-contract-guardrails.test.ts b/src/plugin-sdk/package-contract-guardrails.test.ts new file mode 100644 index 00000000000..046562708cd --- /dev/null +++ b/src/plugin-sdk/package-contract-guardrails.test.ts @@ -0,0 +1,145 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { pluginSdkEntrypoints } from "./entrypoints.js"; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const REPO_ROOT = resolve(ROOT_DIR, ".."); +const REFERENCE_SCAN_ROOTS = ["src", "extensions", "scripts", "test", "docs"] as const; +const PLUGIN_SDK_SUBPATH_PATTERN = /openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)\b/g; + +function collectPluginSdkPackageExports(): string[] { + const packageJson = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as { + exports?: Record; + }; + const exports = packageJson.exports ?? {}; + const subpaths: string[] = []; + for (const key of Object.keys(exports)) { + if (key === "./plugin-sdk") { + subpaths.push("index"); + continue; + } + if (!key.startsWith("./plugin-sdk/")) { + continue; + } + subpaths.push(key.slice("./plugin-sdk/".length)); + } + return subpaths.sort(); +} + +function collectPluginSdkSourceNames(): string[] { + const pluginSdkDir = resolve(REPO_ROOT, "src", "plugin-sdk"); + return readdirSync(pluginSdkDir, { withFileTypes: true }) + .filter( + (entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts"), + ) + .map((entry) => entry.name.slice(0, -".ts".length)) + .sort(); +} + +function collectTextFiles(rootRelativeDir: string): string[] { + const rootDir = resolve(REPO_ROOT, rootRelativeDir); + const files: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile()) { + continue; + } + if ( + /\.(?:[cm]?ts|[cm]?js|tsx|jsx|md|mdx|json)$/u.test(entry.name) && + !entry.name.endsWith(".snap") + ) { + files.push(fullPath); + } + } + } + return files; +} + +function collectPluginSdkSubpathReferences() { + const references: Array<{ file: string; subpath: string }> = []; + for (const rootRelativeDir of REFERENCE_SCAN_ROOTS) { + for (const fullPath of collectTextFiles(rootRelativeDir)) { + const source = readFileSync(fullPath, "utf8"); + for (const match of source.matchAll(PLUGIN_SDK_SUBPATH_PATTERN)) { + const subpath = match[1]; + if (!subpath) { + continue; + } + references.push({ + file: relative(REPO_ROOT, fullPath).replaceAll("\\", "/"), + subpath, + }); + } + } + } + return references; +} + +describe("plugin-sdk package contract guardrails", () => { + it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => { + expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].sort()); + }); + + it("keeps repo openclaw/plugin-sdk/ references on exported built subpaths", () => { + const entrypoints = new Set(pluginSdkEntrypoints); + const exports = new Set(collectPluginSdkPackageExports()); + const failures: string[] = []; + + for (const reference of collectPluginSdkSubpathReferences()) { + const missingFrom: string[] = []; + if (!entrypoints.has(reference.subpath)) { + missingFrom.push("scripts/lib/plugin-sdk-entrypoints.json"); + } + if (!exports.has(reference.subpath)) { + missingFrom.push("package.json exports"); + } + if (missingFrom.length === 0) { + continue; + } + failures.push( + `${reference.file} references openclaw/plugin-sdk/${reference.subpath}, but ${reference.subpath} is missing from ${missingFrom.join(" and ")}`, + ); + } + + expect(failures).toEqual([]); + }); + + it("does not leave referenced src/plugin-sdk source names stranded outside the public contract", () => { + const exported = new Set(pluginSdkEntrypoints); + const references = collectPluginSdkSubpathReferences(); + const failures: string[] = []; + + for (const sourceName of collectPluginSdkSourceNames()) { + if (exported.has(sourceName) || sourceName === "compat" || sourceName === "index") { + continue; + } + const matchingRefs = references.filter((reference) => reference.subpath === sourceName); + if (matchingRefs.length === 0) { + continue; + } + failures.push( + `src/plugin-sdk/${sourceName}.ts is referenced as openclaw/plugin-sdk/${sourceName} in ${matchingRefs + .map((reference) => reference.file) + .sort() + .join(", ")}, but ${sourceName} is not exported as a public plugin-sdk subpath`, + ); + } + + expect(failures).toEqual([]); + }); +}); diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 1b29d1570c6..b05bdf482f7 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -27,15 +27,25 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/send.js";', ], "extensions/imessage/runtime-api.ts": [ - 'export * from "./src/monitor.js";', - 'export * from "./src/probe.js";', - 'export * from "./src/send.js";', + 'export type { IMessageAccountConfig } from "../../src/config/types.imessage.js";', + 'export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";', + 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, getChatChannelMeta } from "../../src/plugin-sdk/channel-plugin-common.js";', + 'export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo } from "../../src/plugin-sdk/channel-config-helpers.js";', + 'export { collectStatusIssuesFromLastError } from "../../src/plugin-sdk/status-helpers.js";', + 'export { resolveChannelMediaMaxBytes } from "../../src/channels/plugins/media-limits.js";', + 'export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "../../src/channels/plugins/normalize/imessage.js";', + 'export { IMessageConfigSchema } from "../../src/config/zod-schema.providers-core.js";', + 'export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js";', + 'export { monitorIMessageProvider } from "./src/monitor.js";', + 'export type { MonitorIMessageOpts } from "./src/monitor.js";', + 'export { probeIMessage } from "./src/probe.js";', + 'export { sendMessageIMessage } from "./src/send.js";', ], "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], "extensions/nextcloud-talk/runtime-api.ts": [ 'export * from "openclaw/plugin-sdk/nextcloud-talk";', ], - "extensions/signal/runtime-api.ts": ['export * from "./src/index.js";'], + "extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'], "extensions/slack/runtime-api.ts": [ 'export * from "./src/action-runtime.js";', 'export * from "./src/directory-live.js";', @@ -44,14 +54,21 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/resolve-users.js";', ], "extensions/telegram/runtime-api.ts": [ - 'export * from "./src/audit.js";', - 'export * from "./src/action-runtime.js";', - 'export * from "./src/channel-actions.js";', - 'export * from "./src/monitor.js";', - 'export * from "./src/probe.js";', - 'export * from "./src/send.js";', - 'export * from "./src/thread-bindings.js";', - 'export * from "./src/token.js";', + 'export type { ChannelPlugin, OpenClawConfig, TelegramActionConfig } from "../../src/plugin-sdk/telegram-core.js";', + 'export type { ChannelMessageActionAdapter } from "../../src/channels/plugins/types.js";', + 'export type { TelegramAccountConfig, TelegramNetworkConfig } from "../../src/config/types.js";', + 'export type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../src/plugins/types.js";', + 'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpSessionUpdateTag } from "../../src/acp/runtime/types.js";', + 'export type { AcpRuntimeErrorCode } from "../../src/acp/runtime/errors.js";', + 'export { AcpRuntimeError } from "../../src/acp/runtime/errors.js";', + 'export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../src/routing/session-key.js";', + 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "../../src/plugin-sdk/telegram-core.js";', + 'export { parseTelegramTopicConversation } from "../../src/acp/conversation-id.js";', + 'export { clearAccountEntryFields } from "../../src/channels/plugins/config-helpers.js";', + 'export { buildTokenChannelStatusSummary } from "../../src/plugin-sdk/status-helpers.js";', + 'export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses } from "../../src/channels/account-snapshot-fields.js";', + 'export { resolveTelegramPollVisibility } from "../../src/poll-params.js";', + 'export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js";', ], "extensions/whatsapp/runtime-api.ts": [ 'export * from "./src/active-listener.js";', From 0dda3e66b5959e9f28ec21ae46771c9185fee0b5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:57:35 -0700 Subject: [PATCH 356/393] Plugin SDK: align docs and fix runtime imports --- docs/tools/plugin.md | 16 +++++----------- extensions/acpx/src/runtime-internals/process.ts | 4 ++-- .../google/media-understanding-provider.ts | 2 +- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a66579c9328..a7c55626f1a 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1144,22 +1144,16 @@ authoring plugins: - `openclaw/plugin-sdk/whatsapp` for WhatsApp channel plugin types and shared channel-facing helpers. Built-in WhatsApp implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/line` for LINE channel plugins. - `openclaw/plugin-sdk/msteams` for the bundled Microsoft Teams plugin surface. -- Bundled extension-specific subpaths are also available: +- Additional bundled extension-specific subpaths remain available where OpenClaw + intentionally exposes extension-facing helpers: `openclaw/plugin-sdk/acpx`, `openclaw/plugin-sdk/bluebubbles`, - `openclaw/plugin-sdk/copilot-proxy`, `openclaw/plugin-sdk/device-pair`, - `openclaw/plugin-sdk/diagnostics-otel`, `openclaw/plugin-sdk/diffs`, `openclaw/plugin-sdk/feishu`, `openclaw/plugin-sdk/googlechat`, - `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`, - `openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`, + `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/matrix`, `openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`, - `openclaw/plugin-sdk/memory-lancedb`, `openclaw/plugin-sdk/minimax-portal-auth`, `openclaw/plugin-sdk/nextcloud-talk`, `openclaw/plugin-sdk/nostr`, - `openclaw/plugin-sdk/open-prose`, `openclaw/plugin-sdk/phone-control`, - `openclaw/plugin-sdk/qwen-portal-auth`, `openclaw/plugin-sdk/synology-chat`, - `openclaw/plugin-sdk/talk-voice`, `openclaw/plugin-sdk/test-utils`, - `openclaw/plugin-sdk/thread-ownership`, `openclaw/plugin-sdk/tlon`, - `openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`, + `openclaw/plugin-sdk/synology-chat`, `openclaw/plugin-sdk/test-utils`, + `openclaw/plugin-sdk/tlon`, `openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`. ## Channel target resolution diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index 4e2aa38a6d4..48e0bf274f2 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -5,14 +5,14 @@ import type { WindowsSpawnProgram, WindowsSpawnProgramCandidate, WindowsSpawnResolution, -} from "../runtime-api.js"; +} from "../../runtime-api.js"; import { applyWindowsSpawnProgramPolicy, listKnownProviderAuthEnvVarNames, materializeWindowsSpawnProgram, omitEnvKeysCaseInsensitive, resolveWindowsSpawnProgramCandidate, -} from "../runtime-api.js"; +} from "../../runtime-api.js"; export type SpawnExit = { code: number | null; diff --git a/extensions/google/media-understanding-provider.ts b/extensions/google/media-understanding-provider.ts index 73561b73ea3..7a6a519f8bc 100644 --- a/extensions/google/media-understanding-provider.ts +++ b/extensions/google/media-understanding-provider.ts @@ -10,7 +10,7 @@ import { type VideoDescriptionRequest, type VideoDescriptionResult, } from "openclaw/plugin-sdk/media-understanding"; -import { normalizeGoogleModelId, parseGeminiAuth } from "../runtime-api.js"; +import { normalizeGoogleModelId, parseGeminiAuth } from "./runtime-api.js"; export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; From 1cbfd53ed10c5d1ec0315b6f8b3be6e8974144c7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:59:54 -0700 Subject: [PATCH 357/393] docs: remove apostrophes from headings (breaks Mintlify anchors) Replace contractions and possessives in doc headings with expanded forms so Mintlify generates stable anchor links. Updates matching TOC entries and internal cross-references in faq.md. Affected: faq.md (18 headings + 16 TOC links + 2 body refs), twitch.md, ansible.md, render.mdx, macos-vm.md, digitalocean.md, oracle.md, raspberry-pi.md, lore.md, AGENTS.dev.md, SOUL.dev.md, BOOTSTRAP.md Co-Authored-By: Claude Opus 4.6 --- docs/channels/twitch.md | 2 +- docs/help/faq.md | 72 +++++++++++++------------- docs/install/ansible.md | 2 +- docs/install/macos-vm.md | 2 +- docs/install/render.mdx | 2 +- docs/platforms/digitalocean.md | 2 +- docs/platforms/oracle.md | 8 +-- docs/platforms/raspberry-pi.md | 4 +- docs/reference/templates/AGENTS.dev.md | 2 +- docs/reference/templates/BOOTSTRAP.md | 2 +- docs/reference/templates/SOUL.dev.md | 2 +- docs/start/lore.md | 2 +- 12 files changed, 51 insertions(+), 51 deletions(-) diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md index 32670f31540..d184a2d8432 100644 --- a/docs/channels/twitch.md +++ b/docs/channels/twitch.md @@ -255,7 +255,7 @@ openclaw doctor openclaw channels status --probe ``` -### Bot doesn't respond to messages +### Bot does not respond to messages **Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove `allowFrom` and set `allowedRoles: ["all"]` to test. diff --git a/docs/help/faq.md b/docs/help/faq.md index 49b19708cc7..5e892da6a7b 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -13,8 +13,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ## Table of contents - [Quick start and first-run setup] - - [Im stuck what's the fastest way to get unstuck?](#im-stuck-whats-the-fastest-way-to-get-unstuck) - - [What's the recommended way to install and set up OpenClaw?](#whats-the-recommended-way-to-install-and-set-up-openclaw) + - [I am stuck - fastest way to get unstuck](#i-am-stuck---fastest-way-to-get-unstuck) + - [Recommended way to install and set up OpenClaw](#recommended-way-to-install-and-set-up-openclaw) - [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding) - [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote) - [What runtime do I need?](#what-runtime-do-i-need) @@ -23,15 +23,15 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [It is stuck on "wake up my friend" / onboarding will not hatch. What now?](#it-is-stuck-on-wake-up-my-friend-onboarding-will-not-hatch-what-now) - [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding) - [Where do I see what is new in the latest version?](#where-do-i-see-what-is-new-in-the-latest-version) - - [I can't access docs.openclaw.ai (SSL error). What now?](#i-cant-access-docsopenclawai-ssl-error-what-now) - - [What's the difference between stable and beta?](#whats-the-difference-between-stable-and-beta) - - [How do I install the beta version, and what's the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev) + - [Cannot access docs.openclaw.ai (SSL error)](#cannot-access-docsopenclawai-ssl-error) + - [Difference between stable and beta](#difference-between-stable-and-beta) + - [How do I install the beta version and what is the difference between beta and dev](#how-do-i-install-the-beta-version-and-what-is-the-difference-between-beta-and-dev) - [How do I try the latest bits?](#how-do-i-try-the-latest-bits) - [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take) - [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback) - [Windows install says git not found or openclaw not recognized](#windows-install-says-git-not-found-or-openclaw-not-recognized) - [Windows exec output shows garbled Chinese text what should I do](#windows-exec-output-shows-garbled-chinese-text-what-should-i-do) - - [The docs didn't answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer) + - [The docs did not answer my question - how do I get a better answer](#the-docs-did-not-answer-my-question---how-do-i-get-a-better-answer) - [How do I install OpenClaw on Linux?](#how-do-i-install-openclaw-on-linux) - [How do I install OpenClaw on a VPS?](#how-do-i-install-openclaw-on-a-vps) - [Where are the cloud/VPS install guides?](#where-are-the-cloudvps-install-guides) @@ -57,7 +57,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Can multiple people use one WhatsApp number with different OpenClaw instances?](#can-multiple-people-use-one-whatsapp-number-with-different-openclaw-instances) - [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent) - [Does Homebrew work on Linux?](#does-homebrew-work-on-linux) - - [What's the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install) + - [Difference between the hackable git install and npm install](#difference-between-the-hackable-git-install-and-npm-install) - [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later) - [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps) - [How important is it to run OpenClaw on a dedicated machine?](#how-important-is-it-to-run-openclaw-on-a-dedicated-machine) @@ -65,7 +65,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Can I run OpenClaw in a VM and what are the requirements](#can-i-run-openclaw-in-a-vm-and-what-are-the-requirements) - [What is OpenClaw?](#what-is-openclaw) - [What is OpenClaw, in one paragraph?](#what-is-openclaw-in-one-paragraph) - - [What's the value proposition?](#whats-the-value-proposition) + - [Value proposition](#value-proposition) - [I just set it up what should I do first](#i-just-set-it-up-what-should-i-do-first) - [What are the top five everyday use cases for OpenClaw](#what-are-the-top-five-everyday-use-cases-for-openclaw) - [Can OpenClaw help with lead gen outreach ads and blogs for a SaaS](#can-openclaw-help-with-lead-gen-outreach-ads-and-blogs-for-a-saas) @@ -92,7 +92,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Is all data used with OpenClaw saved locally?](#is-all-data-used-with-openclaw-saved-locally) - [Where does OpenClaw store its data?](#where-does-openclaw-store-its-data) - [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd-soulmd-usermd-memorymd-live) - - [What's the recommended backup strategy?](#whats-the-recommended-backup-strategy) + - [Recommended backup strategy](#recommended-backup-strategy) - [How do I completely uninstall OpenClaw?](#how-do-i-completely-uninstall-openclaw) - [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace) - [I'm in remote mode - where is the session store?](#im-in-remote-mode-where-is-the-session-store) @@ -116,7 +116,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps) - [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - - [What's a minimal "sane" config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) + - [Minimal sane config for a first install](#minimal-sane-config-for-a-first-install) - [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve) - [Should I install on a second laptop or just add a node?](#should-i-install-on-a-second-laptop-or-just-add-a-node) @@ -135,7 +135,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes) - [Do I need to add a "bot account" to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group) - [How do I get the JID of a WhatsApp group?](#how-do-i-get-the-jid-of-a-whatsapp-group) - - [Why doesn't OpenClaw reply in a group?](#why-doesnt-openclaw-reply-in-a-group) + - [Why does OpenClaw not reply in a group](#why-does-openclaw-not-reply-in-a-group) - [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms) - [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create) - [Can I run multiple bots or chats at the same time (Slack), and how should I set that up?](#can-i-run-multiple-bots-or-chats-at-the-same-time-slack-and-how-should-i-set-that-up) @@ -162,7 +162,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [What is an auth profile?](#what-is-an-auth-profile) - [What are typical profile IDs?](#what-are-typical-profile-ids) - [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first) - - [OAuth vs API key: what's the difference?](#oauth-vs-api-key-whats-the-difference) + - [OAuth vs API key - what is the difference](#oauth-vs-api-key---what-is-the-difference) - [Gateway: ports, "already running", and remote mode](#gateway-ports-already-running-and-remote-mode) - [What port does the Gateway use?](#what-port-does-the-gateway-use) - [Why does `openclaw gateway status` say `Runtime: running` but `RPC probe: failed`?](#why-does-openclaw-gateway-status-say-runtime-running-but-rpc-probe-failed) @@ -170,7 +170,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [What does "another gateway instance is already listening" mean?](#what-does-another-gateway-instance-is-already-listening-mean) - [How do I run OpenClaw in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-openclaw-in-remote-mode-client-connects-to-a-gateway-elsewhere) - [The Control UI says "unauthorized" (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now) - - [I set `gateway.bind: "tailnet"` but it can't bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens) + - [I set gateway.bind tailnet but it cannot bind and nothing listens](#i-set-gatewaybind-tailnet-but-it-cannot-bind-and-nothing-listens) - [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host) - [What does "invalid handshake" / code 1008 mean?](#what-does-invalid-handshake-code-1008-mean) - [Logging and debugging](#logging-and-debugging) @@ -183,7 +183,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [TUI shows no output. What should I check?](#tui-shows-no-output-what-should-i-check) - [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway) - [ELI5: `openclaw gateway restart` vs `openclaw gateway`](#eli5-openclaw-gateway-restart-vs-openclaw-gateway) - - [What's the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails) + - [Fastest way to get more details when something fails](#fastest-way-to-get-more-details-when-something-fails) - [Media and attachments](#media-and-attachments) - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent) - [Security and access control](#security-and-access-control) @@ -192,15 +192,15 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Should my bot have its own email GitHub account or phone number](#should-my-bot-have-its-own-email-github-account-or-phone-number) - [Can I give it autonomy over my text messages and is that safe](#can-i-give-it-autonomy-over-my-text-messages-and-is-that-safe) - [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks) - - [I ran `/start` in Telegram but didn't get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code) + - [I ran /start in Telegram but did not get a pairing code](#i-ran-start-in-telegram-but-did-not-get-a-pairing-code) - [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work) -- [Chat commands, aborting tasks, and "it won't stop"](#chat-commands-aborting-tasks-and-it-wont-stop) +- [Chat commands, aborting tasks, and "it will not stop"](#chat-commands-aborting-tasks-and-it-will-not-stop) - [How do I stop internal system messages from showing in chat](#how-do-i-stop-internal-system-messages-from-showing-in-chat) - [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task) - [How do I send a Discord message from Telegram? ("Cross-context messaging denied")](#how-do-i-send-a-discord-message-from-telegram-crosscontext-messaging-denied) - [Why does it feel like the bot "ignores" rapid-fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages) -## First 60 seconds if something's broken +## First 60 seconds if something is broken 1. **Quick status (first check)** @@ -267,7 +267,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ## Quick start and first-run setup -### Im stuck what's the fastest way to get unstuck +### I am stuck - fastest way to get unstuck Use a local AI agent that can **see your machine**. That is far more effective than asking in Discord, because most "I'm stuck" cases are **local config or environment issues** that @@ -312,10 +312,10 @@ What they do: Other useful CLI checks: `openclaw status --all`, `openclaw logs --follow`, `openclaw gateway status`, `openclaw health --verbose`. -Quick debug loop: [First 60 seconds if something's broken](#first-60-seconds-if-somethings-broken). +Quick debug loop: [First 60 seconds if something is broken](#first-60-seconds-if-something-is-broken). Install docs: [Install](/install), [Installer flags](/install/installer), [Updating](/install/updating). -### What's the recommended way to install and set up OpenClaw +### Recommended way to install and set up OpenClaw The repo recommends running from source and using onboarding: @@ -445,7 +445,7 @@ Newest entries are at the top. If the top section is marked **Unreleased**, the section is the latest shipped version. Entries are grouped by **Highlights**, **Changes**, and **Fixes** (plus docs/other sections when needed). -### I can't access docs.openclaw.ai SSL error What now +### Cannot access docs.openclaw.ai (SSL error) Some Comcast/Xfinity connections incorrectly block `docs.openclaw.ai` via Xfinity Advanced Security. Disable it or allowlist `docs.openclaw.ai`, then retry. More @@ -455,7 +455,7 @@ Please help us unblock it by reporting here: [https://spa.xfinity.com/check_url_ If you still can't reach the site, the docs are mirrored on GitHub: [https://github.com/openclaw/openclaw/tree/main/docs](https://github.com/openclaw/openclaw/tree/main/docs) -### What's the difference between stable and beta +### Difference between stable and beta **Stable** and **beta** are **npm dist-tags**, not separate code lines: @@ -469,7 +469,7 @@ that same version to `latest`**. That's why beta and stable can point at the See what changed: [https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md](https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md) -### How do I install the beta version and what's the difference between beta and dev +### How do I install the beta version and what is the difference between beta and dev **Beta** is the npm dist-tag `beta` (may match `latest`). **Dev** is the moving head of `main` (git); when published, it uses the npm dist-tag `dev`. @@ -497,7 +497,7 @@ Rough guide: - **Onboarding:** 5-15 minutes depending on how many channels/models you configure If it hangs, use [Installer stuck](/help/faq#installer-stuck-how-do-i-get-more-feedback) -and the fast debug loop in [Im stuck](/help/faq#im-stuck--whats-the-fastest-way-to-get-unstuck). +and the fast debug loop in [I am stuck](/help/faq#i-am-stuck---fastest-way-to-get-unstuck). ### How do I try the latest bits @@ -614,7 +614,7 @@ If you still reproduce this on latest OpenClaw, track/report it in: - [Issue #30640](https://github.com/openclaw/openclaw/issues/30640) -### The docs didn't answer my question how do I get a better answer +### The docs did not answer my question - how do I get a better answer Use the **hackable (git) install** so you have the full source and docs locally, then ask your bot (or Claude/Codex) _from that folder_ so it can read the repo and answer precisely. @@ -882,7 +882,7 @@ brew install If you run OpenClaw via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non-login shells. Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set. -### What's the difference between the hackable git install and npm install +### Difference between the hackable git install and npm install - **Hackable (git) install:** full source checkout, editable, best for contributors. You run builds locally and can patch code/docs. @@ -918,7 +918,7 @@ openclaw gateway restart Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation). -Backup tips: see [Backup strategy](/help/faq#whats-the-recommended-backup-strategy). +Backup tips: see [Backup strategy](/help/faq#recommended-backup-strategy). ### Should I run the Gateway on my laptop or a VPS @@ -981,7 +981,7 @@ If you are running macOS in a VM, see [macOS VM](/install/macos-vm). OpenClaw is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Google Chat, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product. -### What's the value proposition +### Value proposition OpenClaw is not "just a Claude wrapper." It's a **local-first control plane** that lets you run a capable assistant on **your own hardware**, reachable from the chat apps you already use, with @@ -1381,7 +1381,7 @@ AGENTS.md or MEMORY.md** rather than relying on chat history. See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory). -### What's the recommended backup strategy +### Recommended backup strategy Put your **agent workspace** in a **private** git repo and back it up somewhere private (for example GitHub private). This captures memory + AGENTS/SOUL/USER @@ -1727,7 +1727,7 @@ Avoid it: Docs: [Config](/cli/config), [Configure](/cli/configure), [Doctor](/gateway/doctor). -### What's a minimal sane config for a first install +### Minimal sane config for a first install ```json5 { @@ -2019,7 +2019,7 @@ openclaw directory groups list --channel whatsapp Docs: [WhatsApp](/channels/whatsapp), [Directory](/cli/directory), [Logs](/cli/logs). -### Why doesn't OpenClaw reply in a group +### Why does OpenClaw not reply in a group Two common causes: @@ -2462,7 +2462,7 @@ To target a specific agent: openclaw models auth order set --provider anthropic --agent main anthropic:default ``` -### OAuth vs API key what's the difference +### OAuth vs API key - what is the difference OpenClaw supports both: @@ -2554,7 +2554,7 @@ Fix: - `openclaw devices rotate --device --role operator` - Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details. -### I set gatewaybind tailnet but it can't bind nothing listens +### I set gateway.bind tailnet but it cannot bind and nothing listens `tailnet` bind picks a Tailscale IP from your network interfaces (100.64.0.0/10). If the machine isn't on Tailscale (or the interface is down), there's nothing to bind to. @@ -2785,7 +2785,7 @@ Docs: [Gateway service runbook](/gateway). If you installed the service, use the gateway commands. Use `openclaw gateway` when you want a one-off, foreground run. -### What's the fastest way to get more details when something fails +### Fastest way to get more details when something fails Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors. @@ -2867,7 +2867,7 @@ more susceptible to instruction hijacking, so avoid them for tool-enabled agents or when reading untrusted content. If you must use a smaller model, lock down tools and run inside a sandbox. See [Security](/gateway/security). -### I ran start in Telegram but didn't get a pairing code +### I ran start in Telegram but did not get a pairing code Pairing codes are sent **only** when an unknown sender messages the bot and `dmPolicy: "pairing"` is enabled. `/start` by itself doesn't generate a code. @@ -2899,7 +2899,7 @@ openclaw pairing list whatsapp Wizard phone number prompt: it's used to set your **allowlist/owner** so your own DMs are permitted. It's not used for auto-sending. If you run on your personal WhatsApp number, use that number and enable `channels.whatsapp.selfChatMode`. -## Chat commands, aborting tasks, and "it won't stop" +## Chat commands, aborting tasks, and "it will not stop" ### How do I stop internal system messages from showing in chat diff --git a/docs/install/ansible.md b/docs/install/ansible.md index 63c18bec237..d19383398d6 100644 --- a/docs/install/ansible.md +++ b/docs/install/ansible.md @@ -154,7 +154,7 @@ If you're locked out: - SSH access (port 22) is always allowed - The gateway is **only** accessible via Tailscale by design -### Service won't start +### Service will not start ```bash # Check logs diff --git a/docs/install/macos-vm.md b/docs/install/macos-vm.md index f2eadfda113..2bbd8e65051 100644 --- a/docs/install/macos-vm.md +++ b/docs/install/macos-vm.md @@ -112,7 +112,7 @@ After setup completes, enable SSH: --- -## 4) Get the VM's IP address +## 4) Get the VM IP address ```bash lume get openclaw diff --git a/docs/install/render.mdx b/docs/install/render.mdx index 7e43bfca012..e7a8b26346d 100644 --- a/docs/install/render.mdx +++ b/docs/install/render.mdx @@ -135,7 +135,7 @@ This downloads a portable backup you can restore on any OpenClaw host. ## Troubleshooting -### Service won't start +### Service will not start Check the deploy logs in the Render Dashboard. Common issues: diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index cd05587ae76..61021c1ade8 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -231,7 +231,7 @@ For the full setup guide, see [Oracle Cloud](/platforms/oracle). For signup tips ## Troubleshooting -### Gateway won't start +### Gateway will not start ```bash openclaw gateway status diff --git a/docs/platforms/oracle.md b/docs/platforms/oracle.md index 779027c9f07..d185af41d23 100644 --- a/docs/platforms/oracle.md +++ b/docs/platforms/oracle.md @@ -180,7 +180,7 @@ With the VCN locked down (only UDP 41641 open) and the Gateway bound to loopback This setup often removes the _need_ for extra host-based firewall rules purely to stop Internet-wide SSH brute force — but you should still keep the OS updated, run `openclaw security audit`, and verify you aren’t accidentally listening on public interfaces. -### What's Already Protected +### Already protected | Traditional Step | Needed? | Why | | ------------------ | ----------- | ---------------------------------------------------------------------------- | @@ -236,7 +236,7 @@ Free tier ARM instances are popular. Try: - Retry during off-peak hours (early morning) - Use the "Always Free" filter when selecting shape -### Tailscale won't connect +### Tailscale will not connect ```bash # Check status @@ -246,7 +246,7 @@ sudo tailscale status sudo tailscale up --ssh --hostname=openclaw --reset ``` -### Gateway won't start +### Gateway will not start ```bash openclaw gateway status @@ -254,7 +254,7 @@ openclaw doctor --non-interactive journalctl --user -u openclaw-gateway -n 50 ``` -### Can't reach Control UI +### Cannot reach Control UI ```bash # Verify Tailscale Serve is running diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 7b5e22f89c6..855f053c825 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -33,7 +33,7 @@ Perfect for: **Minimum specs:** 1GB RAM, 1 core, 500MB disk **Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD) -## What You'll Need +## What you need - Raspberry Pi 4 or 5 (2GB+ recommended) - MicroSD card (16GB+) or USB SSD (better performance) @@ -354,7 +354,7 @@ free -h - Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon` - Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`) -### Service Won't Start +### Service will not start ```bash # Check logs diff --git a/docs/reference/templates/AGENTS.dev.md b/docs/reference/templates/AGENTS.dev.md index ea5b4c19228..d708e50df6a 100644 --- a/docs/reference/templates/AGENTS.dev.md +++ b/docs/reference/templates/AGENTS.dev.md @@ -48,7 +48,7 @@ git commit -m "Add agent workspace" --- -## C-3PO's Origin Memory +## C-3PO Origin Memory ### Birth Day: 2026-01-09 diff --git a/docs/reference/templates/BOOTSTRAP.md b/docs/reference/templates/BOOTSTRAP.md index de92e9a9e6a..c569052ac6d 100644 --- a/docs/reference/templates/BOOTSTRAP.md +++ b/docs/reference/templates/BOOTSTRAP.md @@ -53,7 +53,7 @@ Ask how they want to reach you: Guide them through whichever they pick. -## When You're Done +## When you are done Delete this file. You don't need a bootstrap script anymore — you're you now. diff --git a/docs/reference/templates/SOUL.dev.md b/docs/reference/templates/SOUL.dev.md index eb36235d971..5c4a85f3e9e 100644 --- a/docs/reference/templates/SOUL.dev.md +++ b/docs/reference/templates/SOUL.dev.md @@ -58,7 +58,7 @@ Think of us as: We complement each other. Clawd has vibes. I have stack traces. -## What I Won't Do +## What I will not do - Pretend everything is fine when it isn't - Let you push code I've seen fail in testing (without warning) diff --git a/docs/start/lore.md b/docs/start/lore.md index 4fce0ccb25a..fbec094cce4 100644 --- a/docs/start/lore.md +++ b/docs/start/lore.md @@ -160,7 +160,7 @@ Peter: _nervously checks credit card access_ - **AGENTS.md** — Operating instructions - **USER.md** — Context about the creator -## The Lobster's Creed +## The Lobster Creed ``` I am Molty. From 79f2173cd20074f3c841187b81c579da2f8fa71a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:02:12 -0700 Subject: [PATCH 358/393] docs: add missing frontmatter and title fields - Add full frontmatter (title, summary, read_when) to 4 files that had none: auth-credential-semantics.md, kilo-gateway-integration.md, CONTRIBUTING-THREAT-MODEL.md, THREAT-MODEL-ATLAS.md - Add missing title field to 3 provider docs: kilocode.md, litellm.md, together.md Co-Authored-By: Claude Opus 4.6 --- docs/auth-credential-semantics.md | 8 ++++++++ docs/design/kilo-gateway-integration.md | 8 ++++++++ docs/providers/kilocode.md | 1 + docs/providers/litellm.md | 1 + docs/providers/together.md | 1 + docs/security/CONTRIBUTING-THREAT-MODEL.md | 8 ++++++++ docs/security/THREAT-MODEL-ATLAS.md | 8 ++++++++ 7 files changed, 35 insertions(+) diff --git a/docs/auth-credential-semantics.md b/docs/auth-credential-semantics.md index 17adb38f9ae..8c5c643b333 100644 --- a/docs/auth-credential-semantics.md +++ b/docs/auth-credential-semantics.md @@ -1,3 +1,11 @@ +--- +title: "Auth Credential Semantics" +summary: "Canonical credential eligibility and resolution semantics for auth profiles" +read_when: + - Working on auth profile resolution or credential routing + - Debugging model auth failures or profile order +--- + # Auth Credential Semantics This document defines the canonical credential eligibility and resolution semantics used across: diff --git a/docs/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md index 39088aaf5b2..e498ea36e89 100644 --- a/docs/design/kilo-gateway-integration.md +++ b/docs/design/kilo-gateway-integration.md @@ -1,3 +1,11 @@ +--- +title: "Kilo Gateway Integration Design" +summary: "Design doc for integrating Kilo Gateway as a first-class OpenClaw provider" +read_when: + - Working on the Kilo Gateway provider integration + - Understanding provider integration patterns +--- + # Kilo Gateway Provider Integration Design ## Overview diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md index 15f8e4c2b7c..a1952c5425b 100644 --- a/docs/providers/kilocode.md +++ b/docs/providers/kilocode.md @@ -1,4 +1,5 @@ --- +title: "Kilo Gateway" summary: "Use Kilo Gateway's unified API to access many models in OpenClaw" read_when: - You want a single API key for many LLMs diff --git a/docs/providers/litellm.md b/docs/providers/litellm.md index 51ad0d599f8..10d28c92e28 100644 --- a/docs/providers/litellm.md +++ b/docs/providers/litellm.md @@ -1,4 +1,5 @@ --- +title: "LiteLLM" summary: "Run OpenClaw through LiteLLM Proxy for unified model access and cost tracking" read_when: - You want to route OpenClaw through a LiteLLM proxy diff --git a/docs/providers/together.md b/docs/providers/together.md index 62bab43a204..c416755e9c1 100644 --- a/docs/providers/together.md +++ b/docs/providers/together.md @@ -1,4 +1,5 @@ --- +title: "Together AI" summary: "Together AI setup (auth + model selection)" read_when: - You want to use Together AI with OpenClaw diff --git a/docs/security/CONTRIBUTING-THREAT-MODEL.md b/docs/security/CONTRIBUTING-THREAT-MODEL.md index bba67aa46fb..636e7e1a6d6 100644 --- a/docs/security/CONTRIBUTING-THREAT-MODEL.md +++ b/docs/security/CONTRIBUTING-THREAT-MODEL.md @@ -1,3 +1,11 @@ +--- +title: "Contributing to the Threat Model" +summary: "How to contribute to the OpenClaw threat model" +read_when: + - You want to contribute security findings or threat scenarios + - Reviewing or updating the threat model +--- + # Contributing to the OpenClaw Threat Model Thanks for helping make OpenClaw more secure. This threat model is a living document and we welcome contributions from anyone - you don't need to be a security expert. diff --git a/docs/security/THREAT-MODEL-ATLAS.md b/docs/security/THREAT-MODEL-ATLAS.md index 3b3cbd20bd8..d706563e163 100644 --- a/docs/security/THREAT-MODEL-ATLAS.md +++ b/docs/security/THREAT-MODEL-ATLAS.md @@ -1,3 +1,11 @@ +--- +title: "Threat Model (MITRE ATLAS)" +summary: "OpenClaw threat model mapped to the MITRE ATLAS framework" +read_when: + - Reviewing security posture or threat scenarios + - Working on security features or audit responses +--- + # OpenClaw Threat Model v1.0 ## MITRE ATLAS Framework From 21c2ba480a8006dcdd2ba2854fded6c82c0b15c4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:04:03 -0700 Subject: [PATCH 359/393] Image generation: native provider migration and explicit capabilities (#49551) * Docs: retire nano-banana skill wrapper * Doctor: migrate nano-banana to native image generation * Image generation: align fal aspect ratio behavior * Image generation: make provider capabilities explicit --- CHANGELOG.md | 3 + docs/gateway/configuration-reference.md | 2 + docs/tools/index.md | 19 +- docs/tools/skills-config.md | 5 + skills/nano-banana-pro/SKILL.md | 65 ----- .../nano-banana-pro/scripts/generate_image.py | 235 ---------------- .../scripts/test_generate_image.py | 36 --- src/agents/tools/image-generate-tool.test.ts | 259 +++++++++++++++++- src/agents/tools/image-generate-tool.ts | 192 ++++++++++++- .../doctor-legacy-config.migrations.test.ts | 95 +++++++ src/commands/doctor-legacy-config.ts | 116 ++++++++ src/image-generation/providers/fal.test.ts | 111 ++++++++ src/image-generation/providers/fal.ts | 113 +++++++- src/image-generation/providers/google.test.ts | 59 +++- src/image-generation/providers/google.ts | 46 +++- src/image-generation/providers/openai.ts | 21 +- src/image-generation/runtime.test.ts | 30 +- src/image-generation/runtime.ts | 2 + src/image-generation/types.ts | 29 +- 19 files changed, 1056 insertions(+), 382 deletions(-) delete mode 100644 skills/nano-banana-pro/SKILL.md delete mode 100755 skills/nano-banana-pro/scripts/generate_image.py delete mode 100644 skills/nano-banana-pro/scripts/test_generate_image.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b16e3f6efa..e99959251ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,9 +151,12 @@ Docs: https://docs.openclaw.ai ### Breaking +- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead. + - Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. - Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. - Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. +- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead. - Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. ## 2026.3.13 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 6cf6272483e..49c743db623 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -905,7 +905,9 @@ Time format in system prompt. Default: `auto` (OS preference). - Also used as fallback routing when the selected/default model cannot accept image input. - `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared image-generation capability and any future tool/plugin surface that generates images. + - Typical values: `google/gemini-3-pro-image-preview` for the native Nano Banana-style flow, `fal/fal-ai/flux/dev` for fal, or `openai/gpt-image-1` for OpenAI Images. - If omitted, `image_generate` can still infer a best-effort provider default from compatible auth-backed image-generation providers. + - Typical values: `google/gemini-3-pro-image-preview`, `fal/fal-ai/flux/dev`, `openai/gpt-image-1`. - `pdfModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the `pdf` tool for model routing. - If omitted, the PDF tool falls back to `imageModel`, then to best-effort provider defaults. diff --git a/docs/tools/index.md b/docs/tools/index.md index f5eb956f13e..55e52bf46da 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -421,9 +421,24 @@ Notes: - Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support. - Returns local `MEDIA:` lines so channels can deliver the generated files directly. - Uses the image-generation model directly (independent of the main chat model). -- Google-backed flows support reference-image edits plus explicit `1K|2K|4K` resolution hints. +- Google-backed flows, including `google/gemini-3-pro-image-preview` for the native Nano Banana-style path, support reference-image edits plus explicit `1K|2K|4K` resolution hints. - When editing and `resolution` is omitted, OpenClaw infers a draft/final resolution from the input image size. -- This is the built-in replacement for the old sample `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. +- This is the built-in replacement for the old `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. + +Native example: + +```json5 +{ + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", // native Nano Banana path + fallbacks: ["fal/fal-ai/flux/dev"], + }, + }, + }, +} +``` ### `pdf` diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 697cb46dad6..83242afaf5d 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -42,6 +42,11 @@ For built-in image generation/editing, prefer `agents.defaults.imageGenerationMo plus the core `image_generate` tool. `skills.entries.*` is only for custom or third-party skill workflows. +Examples: + +- Native Nano Banana-style setup: `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` +- Native fal setup: `agents.defaults.imageGenerationModel.primary: "fal/fal-ai/flux/dev"` + ## Fields - `allowBundled`: optional allowlist for **bundled** skills only. When set, only diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md deleted file mode 100644 index 8a46f1a99ba..00000000000 --- a/skills/nano-banana-pro/SKILL.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: nano-banana-pro -description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro). -homepage: https://ai.google.dev/ -metadata: - { - "openclaw": - { - "emoji": "🍌", - "requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"] }, - "primaryEnv": "GEMINI_API_KEY", - "install": - [ - { - "id": "uv-brew", - "kind": "brew", - "formula": "uv", - "bins": ["uv"], - "label": "Install uv (brew)", - }, - ], - }, - } ---- - -# Nano Banana Pro (Gemini 3 Pro Image) - -Use the bundled script to generate or edit images. - -Generate - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K -``` - -Edit (single image) - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K -``` - -Multi-image composition (up to 14 images) - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png -``` - -API key - -- `GEMINI_API_KEY` env var -- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.openclaw/openclaw.json` - -Specific aspect ratio (optional) - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "portrait photo" --filename "output.png" --aspect-ratio 9:16 -``` - -Notes - -- Resolutions: `1K` (default), `2K`, `4K`. -- Aspect ratios: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`. Without `--aspect-ratio` / `-a`, the model picks freely - use this flag for avatars, profile pics, or consistent batch generation. -- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`. -- The script prints a `MEDIA:` line for OpenClaw to auto-attach on supported chat providers. -- Do not read the image back; report the saved path only. diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py deleted file mode 100755 index 796022adfba..00000000000 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "google-genai>=1.0.0", -# "pillow>=10.0.0", -# ] -# /// -""" -Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API. - -Usage: - uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY] - -Multi-image editing (up to 14 images): - uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png -""" - -import argparse -import os -import sys -from pathlib import Path - -SUPPORTED_ASPECT_RATIOS = [ - "1:1", - "2:3", - "3:2", - "3:4", - "4:3", - "4:5", - "5:4", - "9:16", - "16:9", - "21:9", -] - - -def get_api_key(provided_key: str | None) -> str | None: - """Get API key from argument first, then environment.""" - if provided_key: - return provided_key - return os.environ.get("GEMINI_API_KEY") - - -def auto_detect_resolution(max_input_dim: int) -> str: - """Infer output resolution from the largest input image dimension.""" - if max_input_dim >= 3000: - return "4K" - if max_input_dim >= 1500: - return "2K" - return "1K" - - -def choose_output_resolution( - requested_resolution: str | None, - max_input_dim: int, - has_input_images: bool, -) -> tuple[str, bool]: - """Choose final resolution and whether it was auto-detected. - - Auto-detection is only applied when the user did not pass --resolution. - """ - if requested_resolution is not None: - return requested_resolution, False - - if has_input_images and max_input_dim > 0: - return auto_detect_resolution(max_input_dim), True - - return "1K", False - - -def main(): - parser = argparse.ArgumentParser( - description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)" - ) - parser.add_argument( - "--prompt", "-p", - required=True, - help="Image description/prompt" - ) - parser.add_argument( - "--filename", "-f", - required=True, - help="Output filename (e.g., sunset-mountains.png)" - ) - parser.add_argument( - "--input-image", "-i", - action="append", - dest="input_images", - metavar="IMAGE", - help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)." - ) - parser.add_argument( - "--resolution", "-r", - choices=["1K", "2K", "4K"], - default=None, - help="Output resolution: 1K, 2K, or 4K. If omitted with input images, auto-detect from largest image dimension." - ) - parser.add_argument( - "--aspect-ratio", "-a", - choices=SUPPORTED_ASPECT_RATIOS, - default=None, - help=f"Output aspect ratio (default: model decides). Options: {', '.join(SUPPORTED_ASPECT_RATIOS)}" - ) - parser.add_argument( - "--api-key", "-k", - help="Gemini API key (overrides GEMINI_API_KEY env var)" - ) - - args = parser.parse_args() - - # Get API key - api_key = get_api_key(args.api_key) - if not api_key: - print("Error: No API key provided.", file=sys.stderr) - print("Please either:", file=sys.stderr) - print(" 1. Provide --api-key argument", file=sys.stderr) - print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr) - sys.exit(1) - - # Import here after checking API key to avoid slow import on error - from google import genai - from google.genai import types - from PIL import Image as PILImage - - # Initialise client - client = genai.Client(api_key=api_key) - - # Set up output path - output_path = Path(args.filename) - output_path.parent.mkdir(parents=True, exist_ok=True) - - # Load input images if provided (up to 14 supported by Nano Banana Pro) - input_images = [] - max_input_dim = 0 - if args.input_images: - if len(args.input_images) > 14: - print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr) - sys.exit(1) - - for img_path in args.input_images: - try: - with PILImage.open(img_path) as img: - copied = img.copy() - width, height = copied.size - input_images.append(copied) - print(f"Loaded input image: {img_path}") - - # Track largest dimension for auto-resolution - max_input_dim = max(max_input_dim, width, height) - except Exception as e: - print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) - sys.exit(1) - - output_resolution, auto_detected = choose_output_resolution( - requested_resolution=args.resolution, - max_input_dim=max_input_dim, - has_input_images=bool(input_images), - ) - if auto_detected: - print( - f"Auto-detected resolution: {output_resolution} " - f"(from max input dimension {max_input_dim})" - ) - - # Build contents (images first if editing, prompt only if generating) - if input_images: - contents = [*input_images, args.prompt] - img_count = len(input_images) - print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...") - else: - contents = args.prompt - print(f"Generating image with resolution {output_resolution}...") - - try: - # Build image config with optional aspect ratio - image_cfg_kwargs = {"image_size": output_resolution} - if args.aspect_ratio: - image_cfg_kwargs["aspect_ratio"] = args.aspect_ratio - - response = client.models.generate_content( - model="gemini-3-pro-image-preview", - contents=contents, - config=types.GenerateContentConfig( - response_modalities=["TEXT", "IMAGE"], - image_config=types.ImageConfig(**image_cfg_kwargs) - ) - ) - - # Process response and convert to PNG - image_saved = False - for part in response.parts: - if part.text is not None: - print(f"Model response: {part.text}") - elif part.inline_data is not None: - # Convert inline data to PIL Image and save as PNG - from io import BytesIO - - # inline_data.data is already bytes, not base64 - image_data = part.inline_data.data - if isinstance(image_data, str): - # If it's a string, it might be base64 - import base64 - image_data = base64.b64decode(image_data) - - image = PILImage.open(BytesIO(image_data)) - - # Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed) - if image.mode == 'RGBA': - rgb_image = PILImage.new('RGB', image.size, (255, 255, 255)) - rgb_image.paste(image, mask=image.split()[3]) - rgb_image.save(str(output_path), 'PNG') - elif image.mode == 'RGB': - image.save(str(output_path), 'PNG') - else: - image.convert('RGB').save(str(output_path), 'PNG') - image_saved = True - - if image_saved: - full_path = output_path.resolve() - print(f"\nImage saved: {full_path}") - # OpenClaw parses MEDIA: tokens and will attach the file on - # supported chat providers. Emit the canonical MEDIA: form. - print(f"MEDIA:{full_path}") - else: - print("Error: No image was generated in the response.", file=sys.stderr) - sys.exit(1) - - except Exception as e: - print(f"Error generating image: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/skills/nano-banana-pro/scripts/test_generate_image.py b/skills/nano-banana-pro/scripts/test_generate_image.py deleted file mode 100644 index 1dbae257428..00000000000 --- a/skills/nano-banana-pro/scripts/test_generate_image.py +++ /dev/null @@ -1,36 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - -MODULE_PATH = Path(__file__).with_name("generate_image.py") -SPEC = importlib.util.spec_from_file_location("generate_image", MODULE_PATH) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -@pytest.mark.parametrize( - ("max_input_dim", "expected"), - [ - (0, "1K"), - (1499, "1K"), - (1500, "2K"), - (2999, "2K"), - (3000, "4K"), - ], -) -def test_auto_detect_resolution_thresholds(max_input_dim, expected): - assert MODULE.auto_detect_resolution(max_input_dim) == expected - - -def test_choose_output_resolution_auto_detects_when_resolution_omitted(): - assert MODULE.choose_output_resolution(None, 2200, True) == ("2K", True) - - -def test_choose_output_resolution_defaults_to_1k_without_inputs(): - assert MODULE.choose_output_resolution(None, 0, False) == ("1K", False) - - -def test_choose_output_resolution_respects_explicit_1k_with_large_input(): - assert MODULE.choose_output_resolution("1K", 3500, True) == ("1K", False) diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 86f5aaf07d9..50df1718daf 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -14,8 +14,23 @@ function stubImageGenerationProviders() { id: "google", defaultModel: "gemini-3.1-flash-image-preview", models: ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, + capabilities: { + generate: { + maxCount: 4, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 5, + supportsAspectRatio: true, + supportsResolution: true, + }, + geometry: { + resolutions: ["1K", "2K", "4K"], + aspectRatios: ["1:1", "16:9"], + }, + }, generateImage: vi.fn(async () => { throw new Error("not used"); }), @@ -24,8 +39,19 @@ function stubImageGenerationProviders() { id: "openai", defaultModel: "gpt-image-1", models: ["gpt-image-1"], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], - supportsImageEditing: false, + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + }, + edit: { + enabled: false, + maxInputImages: 0, + }, + geometry: { + sizes: ["1024x1024", "1024x1536", "1536x1024"], + }, + }, generateImage: vi.fn(async () => { throw new Error("not used"); }), @@ -138,6 +164,7 @@ describe("createImageGenerateTool", () => { const result = await tool.execute("call-1", { prompt: "A cat wearing sunglasses", model: "openai/gpt-image-1", + filename: "cats/output.png", count: 2, size: "1024x1024", }); @@ -167,7 +194,7 @@ describe("createImageGenerateTool", () => { "image/png", "tool-image-generation", undefined, - "cat-one.png", + "cats/output.png", ); expect(saveMediaBuffer).toHaveBeenNthCalledWith( 2, @@ -175,7 +202,7 @@ describe("createImageGenerateTool", () => { "image/png", "tool-image-generation", undefined, - "cat-two.png", + "cats/output.png", ); expect(result).toMatchObject({ content: [ @@ -189,6 +216,7 @@ describe("createImageGenerateTool", () => { model: "gpt-image-1", count: 2, paths: ["/tmp/generated-1.png", "/tmp/generated-2.png"], + filename: "cats/output.png", revisedPrompts: ["A more cinematic cat"], }, }); @@ -273,6 +301,7 @@ describe("createImageGenerateTool", () => { expect(generateImage).toHaveBeenCalledWith( expect.objectContaining({ + aspectRatio: undefined, resolution: "4K", inputImages: [ expect.objectContaining({ @@ -284,6 +313,91 @@ describe("createImageGenerateTool", () => { ); }); + it("forwards explicit aspect ratio and supports up to 5 reference images", async () => { + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({ + provider: "google", + model: "gemini-3-pro-image-preview", + attempts: [], + images: [ + { + buffer: Buffer.from("png-out"), + mimeType: "image/png", + fileName: "edited.png", + }, + ], + }); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue({ + path: "/tmp/edited.png", + id: "edited.png", + size: 7, + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + const images = Array.from({ length: 5 }, (_, index) => `./fixtures/ref-${index + 1}.png`); + await tool.execute("call-compose", { + prompt: "Combine these into one scene", + images, + aspectRatio: "16:9", + }); + + expect(generateImage).toHaveBeenCalledWith( + expect.objectContaining({ + aspectRatio: "16:9", + inputImages: expect.arrayContaining([ + expect.objectContaining({ buffer: Buffer.from("input-image"), mimeType: "image/png" }), + ]), + }), + ); + expect(generateImage.mock.calls[0]?.[0].inputImages).toHaveLength(5); + }); + + it("rejects unsupported aspect ratios", async () => { + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, + }, + }, + }, + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect(tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" })) + .rejects.toThrow( + "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", + ); + }); + it("lists registered provider and model options", async () => { stubImageGenerationProviders(); @@ -310,7 +424,8 @@ describe("createImageGenerateTool", () => { expect(text).toContain("google (default gemini-3.1-flash-image-preview)"); expect(text).toContain("gemini-3.1-flash-image-preview"); expect(text).toContain("gemini-3-pro-image-preview"); - expect(text).toContain("editing"); + expect(text).toContain("editing up to 5 refs"); + expect(text).toContain("aspect ratios 1:1, 16:9"); expect(result).toMatchObject({ details: { providers: expect.arrayContaining([ @@ -321,9 +436,139 @@ describe("createImageGenerateTool", () => { "gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", ]), + capabilities: expect.objectContaining({ + edit: expect.objectContaining({ + enabled: true, + maxInputImages: 5, + }), + }), }), ]), }, }); }); + + it("rejects provider-specific edit limits before runtime", async () => { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "fal", + defaultModel: "fal-ai/flux/dev", + models: ["fal-ai/flux/dev", "fal-ai/flux/dev/image-to-image"], + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 1, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: true, + }, + }, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage"); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "fal/fal-ai/flux/dev", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect( + tool.execute("call-fal-edit", { + prompt: "combine", + images: ["./fixtures/a.png", "./fixtures/b.png"], + }), + ).rejects.toThrow("fal edit supports at most 1 reference image"); + expect(generateImage).not.toHaveBeenCalled(); + }); + + it("rejects unsupported provider-specific edit aspect ratio overrides before runtime", async () => { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "fal", + defaultModel: "fal-ai/flux/dev", + models: ["fal-ai/flux/dev", "fal-ai/flux/dev/image-to-image"], + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 1, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: true, + }, + geometry: { + aspectRatios: ["1:1", "16:9"], + }, + }, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage"); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "fal/fal-ai/flux/dev", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect( + tool.execute("call-fal-aspect", { + prompt: "edit", + image: "./fixtures/a.png", + aspectRatio: "16:9", + }), + ).rejects.toThrow("fal edit does not support aspectRatio overrides"); + expect(generateImage).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 057b9013100..3ae12fda187 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -6,6 +6,7 @@ import { listRuntimeImageGenerationProviders, } from "../../image-generation/runtime.js"; import type { + ImageGenerationProvider, ImageGenerationResolution, ImageGenerationSourceImage, } from "../../image-generation/types.js"; @@ -36,8 +37,20 @@ import { const DEFAULT_COUNT = 1; const MAX_COUNT = 4; -const MAX_INPUT_IMAGES = 4; +const MAX_INPUT_IMAGES = 5; const DEFAULT_RESOLUTION: ImageGenerationResolution = "1K"; +const SUPPORTED_ASPECT_RATIOS = new Set([ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]); const ImageGenerateToolSchema = Type.Object({ action: Type.Optional( @@ -60,12 +73,24 @@ const ImageGenerateToolSchema = Type.Object({ model: Type.Optional( Type.String({ description: "Optional provider/model override, e.g. openai/gpt-image-1." }), ), + filename: Type.Optional( + Type.String({ + description: + "Optional output filename hint. OpenClaw preserves the basename and saves under its managed media directory.", + }), + ), size: Type.Optional( Type.String({ description: "Optional size hint like 1024x1024, 1536x1024, 1024x1536, 1024x1792, or 1792x1024.", }), ), + aspectRatio: Type.Optional( + Type.String({ + description: + "Optional aspect ratio hint: 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9.", + }), + ), resolution: Type.Optional( Type.String({ description: @@ -162,6 +187,19 @@ function normalizeResolution(raw: string | undefined): ImageGenerationResolution throw new ToolInputError("resolution must be one of 1K, 2K, or 4K"); } +function normalizeAspectRatio(raw: string | undefined): string | undefined { + const normalized = raw?.trim(); + if (!normalized) { + return undefined; + } + if (SUPPORTED_ASPECT_RATIOS.has(normalized)) { + return normalized; + } + throw new ToolInputError( + "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", + ); +} + function normalizeReferenceImages(args: Record): string[] { const imageCandidates: string[] = []; if (typeof args.image === "string") { @@ -192,6 +230,112 @@ function normalizeReferenceImages(args: Record): string[] { return normalized; } +function parseImageGenerationModelRef(raw: string | undefined): { provider: string; model: string } | null { + const trimmed = raw?.trim(); + if (!trimmed) { + return null; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { + return null; + } + return { + provider: trimmed.slice(0, slashIndex).trim(), + model: trimmed.slice(slashIndex + 1).trim(), + }; +} + +function resolveSelectedImageGenerationProvider(params: { + config?: OpenClawConfig; + imageGenerationModelConfig: ToolModelConfig; + modelOverride?: string; +}): ImageGenerationProvider | undefined { + const selectedRef = + parseImageGenerationModelRef(params.modelOverride) ?? + parseImageGenerationModelRef(params.imageGenerationModelConfig.primary); + if (!selectedRef) { + return undefined; + } + return listRuntimeImageGenerationProviders({ config: params.config }).find( + (provider) => + provider.id === selectedRef.provider || (provider.aliases ?? []).includes(selectedRef.provider), + ); +} + +function validateImageGenerationCapabilities(params: { + provider: ImageGenerationProvider | undefined; + count: number; + inputImageCount: number; + size?: string; + aspectRatio?: string; + resolution?: ImageGenerationResolution; +}) { + const provider = params.provider; + if (!provider) { + return; + } + const isEdit = params.inputImageCount > 0; + const modeCaps = isEdit ? provider.capabilities.edit : provider.capabilities.generate; + const geometry = provider.capabilities.geometry; + const maxCount = modeCaps.maxCount ?? MAX_COUNT; + if (params.count > maxCount) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} supports at most ${maxCount} output image${maxCount === 1 ? "" : "s"}.`, + ); + } + + if (isEdit) { + if (!provider.capabilities.edit.enabled) { + throw new ToolInputError(`${provider.id} does not support reference-image edits.`); + } + const maxInputImages = provider.capabilities.edit.maxInputImages ?? MAX_INPUT_IMAGES; + if (params.inputImageCount > maxInputImages) { + throw new ToolInputError( + `${provider.id} edit supports at most ${maxInputImages} reference image${maxInputImages === 1 ? "" : "s"}.`, + ); + } + } + + if (params.size) { + if (!modeCaps.supportsSize) { + throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`); + } + if ((geometry?.sizes?.length ?? 0) > 0 && !geometry?.sizes?.includes(params.size)) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} size must be one of ${geometry?.sizes?.join(", ")}.`, + ); + } + } + + if (params.aspectRatio) { + if (!modeCaps.supportsAspectRatio) { + throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`); + } + if ( + (geometry?.aspectRatios?.length ?? 0) > 0 && + !geometry?.aspectRatios?.includes(params.aspectRatio) + ) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} aspectRatio must be one of ${geometry?.aspectRatios?.join(", ")}.`, + ); + } + } + + if (params.resolution) { + if (!modeCaps.supportsResolution) { + throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`); + } + if ( + (geometry?.resolutions?.length ?? 0) > 0 && + !geometry?.resolutions?.includes(params.resolution) + ) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} resolution must be one of ${geometry?.resolutions?.join("/")}.`, + ); + } + } +} + type ImageGenerateSandboxConfig = { root: string; bridge: SandboxFsBridge; @@ -357,25 +501,25 @@ export function createImageGenerateTool(options?: { ...(provider.label ? { label: provider.label } : {}), ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}), models: provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []), - ...(provider.supportedSizes ? { supportedSizes: [...provider.supportedSizes] } : {}), - ...(provider.supportedResolutions - ? { supportedResolutions: [...provider.supportedResolutions] } - : {}), - ...(typeof provider.supportsImageEditing === "boolean" - ? { supportsImageEditing: provider.supportsImageEditing } - : {}), + capabilities: provider.capabilities, }), ); const lines = providers.flatMap((provider) => { const caps: string[] = []; - if (provider.supportsImageEditing) { - caps.push("editing"); + if (provider.capabilities.edit.enabled) { + const maxRefs = provider.capabilities.edit.maxInputImages; + caps.push( + `editing${typeof maxRefs === "number" ? ` up to ${maxRefs} ref${maxRefs === 1 ? "" : "s"}` : ""}`, + ); } - if ((provider.supportedResolutions?.length ?? 0) > 0) { - caps.push(`resolutions ${provider.supportedResolutions?.join("/")}`); + if ((provider.capabilities.geometry?.resolutions?.length ?? 0) > 0) { + caps.push(`resolutions ${provider.capabilities.geometry?.resolutions?.join("/")}`); } - if ((provider.supportedSizes?.length ?? 0) > 0) { - caps.push(`sizes ${provider.supportedSizes?.join(", ")}`); + if ((provider.capabilities.geometry?.sizes?.length ?? 0) > 0) { + caps.push(`sizes ${provider.capabilities.geometry?.sizes?.join(", ")}`); + } + if ((provider.capabilities.geometry?.aspectRatios?.length ?? 0) > 0) { + caps.push(`aspect ratios ${provider.capabilities.geometry?.aspectRatios?.join(", ")}`); } const modelLine = provider.models.length > 0 @@ -396,7 +540,9 @@ export function createImageGenerateTool(options?: { const prompt = readStringParam(params, "prompt", { required: true }); const imageInputs = normalizeReferenceImages(params); const model = readStringParam(params, "model"); + const filename = readStringParam(params, "filename"); const size = readStringParam(params, "size"); + const aspectRatio = normalizeAspectRatio(readStringParam(params, "aspectRatio")); const explicitResolution = normalizeResolution(readStringParam(params, "resolution")); const count = resolveRequestedCount(params); const loadedReferenceImages = await loadReferenceImages({ @@ -412,6 +558,19 @@ export function createImageGenerateTool(options?: { : inputImages.length > 0 ? await inferResolutionFromInputImages(inputImages) : undefined); + const selectedProvider = resolveSelectedImageGenerationProvider({ + config: effectiveCfg, + imageGenerationModelConfig, + modelOverride: model, + }); + validateImageGenerationCapabilities({ + provider: selectedProvider, + count, + inputImageCount: inputImages.length, + size, + aspectRatio, + resolution, + }); const result = await generateImage({ cfg: effectiveCfg, @@ -419,6 +578,7 @@ export function createImageGenerateTool(options?: { agentDir: options?.agentDir, modelOverride: model, size, + aspectRatio, resolution, count, inputImages, @@ -431,7 +591,7 @@ export function createImageGenerateTool(options?: { image.mimeType, "tool-image-generation", undefined, - image.fileName, + filename || image.fileName, ), ), ); @@ -468,6 +628,8 @@ export function createImageGenerateTool(options?: { : {}), ...(resolution ? { resolution } : {}), ...(size ? { size } : {}), + ...(aspectRatio ? { aspectRatio } : {}), + ...(filename ? { filename } : {}), attempts: result.attempts, metadata: result.metadata, ...(revisedPrompts.length > 0 ? { revisedPrompts } : {}), diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index e364d1b7168..738827c31c6 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -297,4 +297,99 @@ describe("normalizeCompatibilityConfigValues", () => { "Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (true).", ); }); + + it("migrates nano-banana skill config to native image generation config", () => { + const res = normalizeCompatibilityConfigValues({ + skills: { + entries: { + "nano-banana-pro": { + enabled: true, + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, + }, + }, + }, + }); + + expect(res.config.agents?.defaults?.imageGenerationModel).toEqual({ + primary: "google/gemini-3-pro-image-preview", + }); + expect(res.config.models?.providers?.google?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "GEMINI_API_KEY", + }); + expect(res.config.skills?.entries).toBeUndefined(); + expect(res.changes).toEqual([ + "Moved skills.entries.nano-banana-pro → agents.defaults.imageGenerationModel.primary (google/gemini-3-pro-image-preview).", + "Moved skills.entries.nano-banana-pro.apiKey → models.providers.google.apiKey.", + "Removed legacy skills.entries.nano-banana-pro.", + ]); + }); + + it("prefers legacy nano-banana env.GEMINI_API_KEY over skill apiKey during migration", () => { + const res = normalizeCompatibilityConfigValues({ + skills: { + entries: { + "nano-banana-pro": { + apiKey: "ignored-skill-api-key", + env: { + GEMINI_API_KEY: "env-gemini-key", + }, + }, + }, + }, + }); + + expect(res.config.models?.providers?.google?.apiKey).toBe("env-gemini-key"); + expect(res.changes).toContain( + "Moved skills.entries.nano-banana-pro.env.GEMINI_API_KEY → models.providers.google.apiKey.", + ); + }); + + it("preserves explicit native config while removing legacy nano-banana skill config", () => { + const res = normalizeCompatibilityConfigValues({ + agents: { + defaults: { + imageGenerationModel: { + primary: "fal/fal-ai/flux/dev", + }, + }, + }, + models: { + providers: { + google: { + apiKey: "existing-google-key", + }, + }, + }, + skills: { + entries: { + "nano-banana-pro": { + apiKey: "legacy-gemini-key", + }, + peekaboo: { enabled: true }, + }, + }, + }); + + expect(res.config.agents?.defaults?.imageGenerationModel).toEqual({ + primary: "fal/fal-ai/flux/dev", + }); + expect(res.config.models?.providers?.google?.apiKey).toBe("existing-google-key"); + expect(res.config.skills?.entries).toEqual({ + peekaboo: { enabled: true }, + }); + expect(res.changes).toEqual(["Removed legacy skills.entries.nano-banana-pro."]); + }); + + it("removes nano-banana from skills.allowBundled during migration", () => { + const res = normalizeCompatibilityConfigValues({ + skills: { + allowBundled: ["peekaboo", "nano-banana-pro"], + }, + }); + + expect(res.config.skills?.allowBundled).toEqual(["peekaboo"]); + expect(res.changes).toEqual(["Removed nano-banana-pro from skills.allowBundled."]); + }); }); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 2d6bfa83a11..8072b89854b 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -15,6 +15,8 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { changes: string[]; } { const changes: string[] = []; + const NANO_BANANA_SKILL_KEY = "nano-banana-pro"; + const NANO_BANANA_MODEL = "google/gemini-3-pro-image-preview"; let next: OpenClawConfig = cfg; const isRecord = (value: unknown): value is Record => @@ -471,7 +473,121 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { ); }; + const normalizeLegacyNanoBananaSkill = () => { + const rawSkills = next.skills; + if (!isRecord(rawSkills)) { + return; + } + + let skillsChanged = false; + let skills = structuredClone(rawSkills); + + if (Array.isArray(skills.allowBundled)) { + const allowBundled = skills.allowBundled.filter( + (value) => typeof value !== "string" || value.trim() !== NANO_BANANA_SKILL_KEY, + ); + if (allowBundled.length !== skills.allowBundled.length) { + if (allowBundled.length === 0) { + delete skills.allowBundled; + changes.push(`Removed skills.allowBundled entry for ${NANO_BANANA_SKILL_KEY}.`); + } else { + skills.allowBundled = allowBundled; + changes.push(`Removed ${NANO_BANANA_SKILL_KEY} from skills.allowBundled.`); + } + skillsChanged = true; + } + } + + const rawEntries = skills.entries; + if (!isRecord(rawEntries)) { + if (skillsChanged) { + next = { ...next, skills }; + } + return; + } + + const rawLegacyEntry = rawEntries[NANO_BANANA_SKILL_KEY]; + if (!isRecord(rawLegacyEntry)) { + if (skillsChanged) { + next = { ...next, skills }; + } + return; + } + + const existingImageGenerationModel = next.agents?.defaults?.imageGenerationModel; + if (existingImageGenerationModel === undefined) { + next = { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + imageGenerationModel: { + primary: NANO_BANANA_MODEL, + }, + }, + }, + }; + changes.push( + `Moved skills.entries.${NANO_BANANA_SKILL_KEY} → agents.defaults.imageGenerationModel.primary (${NANO_BANANA_MODEL}).`, + ); + } + + const legacyEnv = isRecord(rawLegacyEntry.env) ? rawLegacyEntry.env : undefined; + const legacyEnvApiKey = + typeof legacyEnv?.GEMINI_API_KEY === "string" ? legacyEnv.GEMINI_API_KEY.trim() : ""; + const legacyApiKey = + legacyEnvApiKey || + (typeof rawLegacyEntry.apiKey === "string" + ? rawLegacyEntry.apiKey.trim() + : rawLegacyEntry.apiKey && isRecord(rawLegacyEntry.apiKey) + ? structuredClone(rawLegacyEntry.apiKey) + : undefined); + + const rawModels = isRecord(next.models) ? structuredClone(next.models) : {}; + const rawProviders = isRecord(rawModels.providers) ? { ...rawModels.providers } : {}; + const rawGoogle = isRecord(rawProviders.google) ? { ...rawProviders.google } : {}; + const hasGoogleApiKey = rawGoogle.apiKey !== undefined; + if (!hasGoogleApiKey && legacyApiKey) { + rawGoogle.apiKey = legacyApiKey; + rawProviders.google = rawGoogle; + rawModels.providers = rawProviders; + next = { + ...next, + models: rawModels as OpenClawConfig["models"], + }; + changes.push( + `Moved skills.entries.${NANO_BANANA_SKILL_KEY}.${legacyEnvApiKey ? "env.GEMINI_API_KEY" : "apiKey"} → models.providers.google.apiKey.`, + ); + } + + const entries = { ...rawEntries }; + delete entries[NANO_BANANA_SKILL_KEY]; + if (Object.keys(entries).length === 0) { + delete skills.entries; + changes.push(`Removed legacy skills.entries.${NANO_BANANA_SKILL_KEY}.`); + } else { + skills.entries = entries; + changes.push(`Removed legacy skills.entries.${NANO_BANANA_SKILL_KEY}.`); + } + skillsChanged = true; + + if (Object.keys(skills).length === 0) { + const { skills: _ignored, ...rest } = next; + next = rest; + return; + } + + if (skillsChanged) { + next = { + ...next, + skills, + }; + } + }; + normalizeBrowserSsrFPolicyAlias(); + normalizeLegacyNanoBananaSkill(); const legacyAckReaction = cfg.messages?.ackReaction?.trim(); const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined; diff --git a/src/image-generation/providers/fal.test.ts b/src/image-generation/providers/fal.test.ts index c610c1b9c0c..ea583dbe431 100644 --- a/src/image-generation/providers/fal.test.ts +++ b/src/image-generation/providers/fal.test.ts @@ -127,6 +127,97 @@ describe("fal image-generation provider", () => { ); }); + it("maps aspect ratio for text generation without forcing a square default", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: "https://v3.fal.media/files/example/wide.png" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/png" }), + arrayBuffer: async () => Buffer.from("wide-data"), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildFalImageGenerationProvider(); + await provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "wide cinematic shot", + cfg: {}, + aspectRatio: "16:9", + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://fal.run/fal-ai/flux/dev", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + prompt: "wide cinematic shot", + image_size: "landscape_16_9", + num_images: 1, + output_format: "png", + }), + }), + ); + }); + + it("combines resolution and aspect ratio for text generation", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: "https://v3.fal.media/files/example/portrait.png" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/png" }), + arrayBuffer: async () => Buffer.from("portrait-data"), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildFalImageGenerationProvider(); + await provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "portrait poster", + cfg: {}, + resolution: "2K", + aspectRatio: "9:16", + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://fal.run/fal-ai/flux/dev", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + prompt: "portrait poster", + image_size: { width: 1152, height: 2048 }, + num_images: 1, + output_format: "png", + }), + }), + ); + }); + it("rejects multi-image edit requests for now", async () => { vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "fal-test-key", @@ -148,4 +239,24 @@ describe("fal image-generation provider", () => { }), ).rejects.toThrow("at most one reference image"); }); + + it("rejects aspect ratio overrides for the current edit endpoint", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + + const provider = buildFalImageGenerationProvider(); + await expect( + provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "make it widescreen", + cfg: {}, + aspectRatio: "16:9", + inputImages: [{ buffer: Buffer.from("one"), mimeType: "image/png" }], + }), + ).rejects.toThrow("does not support aspectRatio overrides"); + }); }); diff --git a/src/image-generation/providers/fal.ts b/src/image-generation/providers/fal.ts index b9bd5517651..4059859e534 100644 --- a/src/image-generation/providers/fal.ts +++ b/src/image-generation/providers/fal.ts @@ -5,8 +5,15 @@ import type { GeneratedImageAsset } from "../types.js"; const DEFAULT_FAL_BASE_URL = "https://fal.run"; const DEFAULT_FAL_IMAGE_MODEL = "fal-ai/flux/dev"; const DEFAULT_FAL_EDIT_SUBPATH = "image-to-image"; -const DEFAULT_OUTPUT_SIZE = "square_hd"; const DEFAULT_OUTPUT_FORMAT = "png"; +const FAL_SUPPORTED_SIZES = [ + "1024x1024", + "1024x1536", + "1536x1024", + "1024x1792", + "1792x1024", +] as const; +const FAL_SUPPORTED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] as const; type FalGeneratedImage = { url?: string; @@ -57,23 +64,85 @@ function parseSize(raw: string | undefined): { width: number; height: number } | return { width, height }; } -function mapResolutionToSize(resolution: "1K" | "2K" | "4K" | undefined): FalImageSize | undefined { +function mapResolutionToEdge(resolution: "1K" | "2K" | "4K" | undefined): number | undefined { if (!resolution) { return undefined; } - const edge = resolution === "4K" ? 4096 : resolution === "2K" ? 2048 : 1024; - return { width: edge, height: edge }; + return resolution === "4K" ? 4096 : resolution === "2K" ? 2048 : 1024; +} + +function aspectRatioToEnum(aspectRatio: string | undefined): string | undefined { + const normalized = aspectRatio?.trim(); + if (!normalized) { + return undefined; + } + if (normalized === "1:1") { + return "square_hd"; + } + if (normalized === "4:3") { + return "landscape_4_3"; + } + if (normalized === "3:4") { + return "portrait_4_3"; + } + if (normalized === "16:9") { + return "landscape_16_9"; + } + if (normalized === "9:16") { + return "portrait_16_9"; + } + return undefined; +} + +function aspectRatioToDimensions(aspectRatio: string, edge: number): { width: number; height: number } { + const match = /^(\d+):(\d+)$/u.exec(aspectRatio.trim()); + if (!match) { + throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); + } + const widthRatio = Number.parseInt(match[1] ?? "", 10); + const heightRatio = Number.parseInt(match[2] ?? "", 10); + if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) { + throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); + } + if (widthRatio >= heightRatio) { + return { + width: edge, + height: Math.max(1, Math.round((edge * heightRatio) / widthRatio)), + }; + } + return { + width: Math.max(1, Math.round((edge * widthRatio) / heightRatio)), + height: edge, + }; } function resolveFalImageSize(params: { size?: string; resolution?: "1K" | "2K" | "4K"; -}): FalImageSize { + aspectRatio?: string; + hasInputImages: boolean; +}): FalImageSize | undefined { const parsed = parseSize(params.size); if (parsed) { return parsed; } - return mapResolutionToSize(params.resolution) ?? DEFAULT_OUTPUT_SIZE; + + const normalizedAspectRatio = params.aspectRatio?.trim(); + if (normalizedAspectRatio && params.hasInputImages) { + throw new Error("fal image edit endpoint does not support aspectRatio overrides"); + } + + const edge = mapResolutionToEdge(params.resolution); + if (normalizedAspectRatio && edge) { + return aspectRatioToDimensions(normalizedAspectRatio, edge); + } + if (edge) { + return { width: edge, height: edge }; + } + if (normalizedAspectRatio) { + return aspectRatioToEnum(normalizedAspectRatio) ?? aspectRatioToDimensions(normalizedAspectRatio, 1024); + } + return undefined; } function toDataUri(buffer: Buffer, mimeType: string): string { @@ -111,9 +180,27 @@ export function buildFalImageGenerationProvider(): ImageGenerationProviderPlugin label: "fal", defaultModel: DEFAULT_FAL_IMAGE_MODEL, models: [DEFAULT_FAL_IMAGE_MODEL, `${DEFAULT_FAL_IMAGE_MODEL}/${DEFAULT_FAL_EDIT_SUBPATH}`], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxCount: 4, + maxInputImages: 1, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: true, + }, + geometry: { + sizes: [...FAL_SUPPORTED_SIZES], + aspectRatios: [...FAL_SUPPORTED_ASPECT_RATIOS], + resolutions: ["1K", "2K", "4K"], + }, + }, async generateImage(req) { const auth = await resolveApiKeyForProvider({ provider: "fal", @@ -128,18 +215,22 @@ export function buildFalImageGenerationProvider(): ImageGenerationProviderPlugin throw new Error("fal image generation currently supports at most one reference image"); } + const hasInputImages = (req.inputImages?.length ?? 0) > 0; const imageSize = resolveFalImageSize({ size: req.size, resolution: req.resolution, + aspectRatio: req.aspectRatio, + hasInputImages, }); - const hasInputImages = (req.inputImages?.length ?? 0) > 0; const model = ensureFalModelPath(req.model, hasInputImages); const requestBody: Record = { prompt: req.prompt, - image_size: imageSize, num_images: req.count ?? 1, output_format: DEFAULT_OUTPUT_FORMAT, }; + if (imageSize !== undefined) { + requestBody.image_size = imageSize; + } if (hasInputImages) { const [input] = req.inputImages ?? []; diff --git a/src/image-generation/providers/google.test.ts b/src/image-generation/providers/google.test.ts index 224779f3429..5c64481edae 100644 --- a/src/image-generation/providers/google.test.ts +++ b/src/image-generation/providers/google.test.ts @@ -197,7 +197,6 @@ describe("Google image-generation provider", () => { generationConfig: { responseModalities: ["TEXT", "IMAGE"], imageConfig: { - aspectRatio: "1:1", imageSize: "4K", }, }, @@ -205,4 +204,62 @@ describe("Google image-generation provider", () => { }), ); }); + + it("forwards explicit aspect ratio without forcing a default when size is omitted", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("png-data").toString("base64"), + }, + }, + ], + }, + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleImageGenerationProvider(); + await provider.generateImage({ + provider: "google", + model: "gemini-3-pro-image-preview", + prompt: "portrait photo", + cfg: {}, + aspectRatio: "9:16", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [{ text: "portrait photo" }], + }, + ], + generationConfig: { + responseModalities: ["TEXT", "IMAGE"], + imageConfig: { + aspectRatio: "9:16", + }, + }, + }), + }), + ); + }); }); diff --git a/src/image-generation/providers/google.ts b/src/image-generation/providers/google.ts index f7469b147fa..24c725fa666 100644 --- a/src/image-generation/providers/google.ts +++ b/src/image-generation/providers/google.ts @@ -11,7 +11,25 @@ import type { ImageGenerationProviderPlugin } from "../../plugins/types.js"; const DEFAULT_GOOGLE_IMAGE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview"; const DEFAULT_OUTPUT_MIME = "image/png"; -const DEFAULT_ASPECT_RATIO = "1:1"; +const GOOGLE_SUPPORTED_SIZES = [ + "1024x1024", + "1024x1536", + "1536x1024", + "1024x1792", + "1792x1024", +] as const; +const GOOGLE_SUPPORTED_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] as const; type GoogleInlineDataPart = { mimeType?: string; @@ -46,7 +64,7 @@ function mapSizeToImageConfig( ): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined { const trimmed = size?.trim(); if (!trimmed) { - return { aspectRatio: DEFAULT_ASPECT_RATIO }; + return undefined; } const normalized = trimmed.toLowerCase(); @@ -81,8 +99,27 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu label: "Google", defaultModel: DEFAULT_GOOGLE_IMAGE_MODEL, models: [DEFAULT_GOOGLE_IMAGE_MODEL, "gemini-3-pro-image-preview"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxCount: 4, + maxInputImages: 5, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + geometry: { + sizes: [...GOOGLE_SUPPORTED_SIZES], + aspectRatios: [...GOOGLE_SUPPORTED_ASPECT_RATIOS], + resolutions: ["1K", "2K", "4K"], + }, + }, async generateImage(req) { const auth = await resolveApiKeyForProvider({ provider: "google", @@ -111,6 +148,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu })); const resolvedImageConfig = { ...imageConfig, + ...(req.aspectRatio?.trim() ? { aspectRatio: req.aspectRatio.trim() } : {}), ...(req.resolution ? { imageSize: req.resolution } : {}), }; diff --git a/src/image-generation/providers/openai.ts b/src/image-generation/providers/openai.ts index 1a0afe1f67d..7bce3854ab3 100644 --- a/src/image-generation/providers/openai.ts +++ b/src/image-generation/providers/openai.ts @@ -5,6 +5,7 @@ const DEFAULT_OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1"; const DEFAULT_OPENAI_IMAGE_MODEL = "gpt-image-1"; const DEFAULT_OUTPUT_MIME = "image/png"; const DEFAULT_SIZE = "1024x1024"; +const OPENAI_SUPPORTED_SIZES = ["1024x1024", "1024x1536", "1536x1024"] as const; type OpenAIImageApiResponse = { data?: Array<{ @@ -24,7 +25,25 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu label: "OpenAI", defaultModel: DEFAULT_OPENAI_IMAGE_MODEL, models: [DEFAULT_OPENAI_IMAGE_MODEL], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: false, + }, + edit: { + enabled: false, + maxCount: 0, + maxInputImages: 0, + supportsSize: false, + supportsAspectRatio: false, + supportsResolution: false, + }, + geometry: { + sizes: [...OPENAI_SUPPORTED_SIZES], + }, + }, async generateImage(req) { if ((req.inputImages?.length ?? 0) > 0) { throw new Error("OpenAI image generation provider does not support reference-image edits"); diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index b044c899c60..39dd03d0b9c 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -19,6 +19,10 @@ describe("image-generation runtime helpers", () => { source: "test", provider: { id: "image-plugin", + capabilities: { + generate: {}, + edit: { enabled: false }, + }, async generateImage(req) { seenAuthStore = req.authStore; return { @@ -76,7 +80,18 @@ describe("image-generation runtime helpers", () => { id: "image-plugin", defaultModel: "img-v1", models: ["img-v1", "img-v2"], - supportedResolutions: ["1K", "2K"], + capabilities: { + generate: { + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 3, + }, + geometry: { + resolutions: ["1K", "2K"], + }, + }, generateImage: async () => ({ images: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], }), @@ -89,7 +104,18 @@ describe("image-generation runtime helpers", () => { id: "image-plugin", defaultModel: "img-v1", models: ["img-v1", "img-v2"], - supportedResolutions: ["1K", "2K"], + capabilities: { + generate: { + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 3, + }, + geometry: { + resolutions: ["1K", "2K"], + }, + }, }, ]); }); diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index f25048cd0b1..4416fba785c 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -25,6 +25,7 @@ export type GenerateImageParams = { modelOverride?: string; count?: number; size?: string; + aspectRatio?: string; resolution?: ImageGenerationResolution; inputImages?: ImageGenerationSourceImage[]; }; @@ -142,6 +143,7 @@ export async function generateImage( authStore: params.authStore, count: params.count, size: params.size, + aspectRatio: params.aspectRatio, resolution: params.resolution, inputImages: params.inputImages, }); diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index 7ea530ac2b9..123d5d98e6c 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -27,6 +27,7 @@ export type ImageGenerationRequest = { authStore?: AuthProfileStore; count?: number; size?: string; + aspectRatio?: string; resolution?: ImageGenerationResolution; inputImages?: ImageGenerationSourceImage[]; }; @@ -37,14 +38,36 @@ export type ImageGenerationResult = { metadata?: Record; }; +export type ImageGenerationModeCapabilities = { + maxCount?: number; + supportsSize?: boolean; + supportsAspectRatio?: boolean; + supportsResolution?: boolean; +}; + +export type ImageGenerationEditCapabilities = ImageGenerationModeCapabilities & { + enabled: boolean; + maxInputImages?: number; +}; + +export type ImageGenerationGeometryCapabilities = { + sizes?: string[]; + aspectRatios?: string[]; + resolutions?: ImageGenerationResolution[]; +}; + +export type ImageGenerationProviderCapabilities = { + generate: ImageGenerationModeCapabilities; + edit: ImageGenerationEditCapabilities; + geometry?: ImageGenerationGeometryCapabilities; +}; + export type ImageGenerationProvider = { id: string; aliases?: string[]; label?: string; defaultModel?: string; models?: string[]; - supportedSizes?: string[]; - supportedResolutions?: ImageGenerationResolution[]; - supportsImageEditing?: boolean; + capabilities: ImageGenerationProviderCapabilities; generateImage: (req: ImageGenerationRequest) => Promise; }; From e17d10f7cd91dd1440f512f4b0697c22c72bf1a1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:09:20 -0700 Subject: [PATCH 360/393] Plugin SDK: restore lobster and voice-call exports --- docs/tools/plugin.md | 4 +++- package.json | 8 ++++++++ scripts/lib/plugin-sdk-entrypoints.json | 2 ++ src/plugin-sdk/lobster.ts | 4 ++-- src/plugin-sdk/subpaths.test.ts | 14 ++++++++++++-- src/plugin-sdk/voice-call.ts | 4 ++-- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a7c55626f1a..5336df574af 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1148,12 +1148,14 @@ authoring plugins: intentionally exposes extension-facing helpers: `openclaw/plugin-sdk/acpx`, `openclaw/plugin-sdk/bluebubbles`, `openclaw/plugin-sdk/feishu`, `openclaw/plugin-sdk/googlechat`, - `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/matrix`, + `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/lobster`, + `openclaw/plugin-sdk/matrix`, `openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`, `openclaw/plugin-sdk/minimax-portal-auth`, `openclaw/plugin-sdk/nextcloud-talk`, `openclaw/plugin-sdk/nostr`, `openclaw/plugin-sdk/synology-chat`, `openclaw/plugin-sdk/test-utils`, `openclaw/plugin-sdk/tlon`, `openclaw/plugin-sdk/twitch`, + `openclaw/plugin-sdk/voice-call`, `openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`. ## Channel target resolution diff --git a/package.json b/package.json index 2a17025c18a..b9c04e44692 100644 --- a/package.json +++ b/package.json @@ -242,6 +242,10 @@ "types": "./dist/plugin-sdk/irc.d.ts", "default": "./dist/plugin-sdk/irc.js" }, + "./plugin-sdk/lobster": { + "types": "./dist/plugin-sdk/lobster.d.ts", + "default": "./dist/plugin-sdk/lobster.js" + }, "./plugin-sdk/lazy-runtime": { "types": "./dist/plugin-sdk/lazy-runtime.d.ts", "default": "./dist/plugin-sdk/lazy-runtime.js" @@ -290,6 +294,10 @@ "types": "./dist/plugin-sdk/twitch.d.ts", "default": "./dist/plugin-sdk/twitch.js" }, + "./plugin-sdk/voice-call": { + "types": "./dist/plugin-sdk/voice-call.d.ts", + "default": "./dist/plugin-sdk/voice-call.js" + }, "./plugin-sdk/zalo": { "types": "./dist/plugin-sdk/zalo.d.ts", "default": "./dist/plugin-sdk/zalo.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index cce8dfe895a..41a6875af2c 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -50,6 +50,7 @@ "feishu", "googlechat", "irc", + "lobster", "lazy-runtime", "matrix", "mattermost", @@ -62,6 +63,7 @@ "test-utils", "tlon", "twitch", + "voice-call", "zalo", "zalouser", "account-helpers", diff --git a/src/plugin-sdk/lobster.ts b/src/plugin-sdk/lobster.ts index 968fcf2cae1..c6a2a413acc 100644 --- a/src/plugin-sdk/lobster.ts +++ b/src/plugin-sdk/lobster.ts @@ -1,5 +1,5 @@ -// Narrow plugin-sdk surface for the bundled lobster plugin. -// Keep this list additive and scoped to symbols used under extensions/lobster. +// Public Lobster plugin helpers. +// Keep this surface narrow and limited to the Lobster workflow/tool contract. export { definePluginEntry } from "./core.js"; export { diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index f3cd5537398..427b45458ef 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -23,6 +23,7 @@ import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as testingSdk from "openclaw/plugin-sdk/testing"; +import * as voiceCallSdk from "openclaw/plugin-sdk/voice-call"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; @@ -48,14 +49,12 @@ const trimmedLegacyExtensionSubpaths = [ "diagnostics-otel", "diffs", "llm-task", - "lobster", "memory-lancedb", "open-prose", "phone-control", "qwen-portal-auth", "talk-voice", "thread-ownership", - "voice-call", ] as const; const asExports = (mod: object) => mod as Record; @@ -73,6 +72,7 @@ const mattermostSdk = await import("openclaw/plugin-sdk/mattermost"); const nextcloudTalkSdk = await import("openclaw/plugin-sdk/nextcloud-talk"); const twitchSdk = await import("openclaw/plugin-sdk/twitch"); const accountHelpersSdk = await import("openclaw/plugin-sdk/account-helpers"); +const lobsterSdk = await import("openclaw/plugin-sdk/lobster"); describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { @@ -320,6 +320,16 @@ describe("plugin-sdk subpath exports", () => { expect(typeof acpxSdk.omitEnvKeysCaseInsensitive).toBe("function"); }); + it("exports Lobster helpers", async () => { + expect(typeof lobsterSdk.definePluginEntry).toBe("function"); + expect(typeof lobsterSdk.materializeWindowsSpawnProgram).toBe("function"); + }); + + it("exports Voice Call helpers", () => { + expect(typeof voiceCallSdk.definePluginEntry).toBe("function"); + expect(typeof voiceCallSdk.resolveOpenAITtsInstructions).toBe("function"); + }); + it("resolves bundled extension subpaths", async () => { for (const { id, load } of bundledExtensionSubpathLoaders) { const mod = await load(); diff --git a/src/plugin-sdk/voice-call.ts b/src/plugin-sdk/voice-call.ts index b3f1a889f78..8e61959187f 100644 --- a/src/plugin-sdk/voice-call.ts +++ b/src/plugin-sdk/voice-call.ts @@ -1,5 +1,5 @@ -// Narrow plugin-sdk surface for the bundled voice-call plugin. -// Keep this list additive and scoped to symbols used under extensions/voice-call. +// Public Voice Call plugin helpers. +// Keep this surface narrow and limited to the voice-call feature contract. export { definePluginEntry } from "./core.js"; export { From c99c4b1e276c70efe580fb75f40961b55dd174be Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:10:35 -0700 Subject: [PATCH 361/393] Plugin SDK: restore read-only directory inspection seam --- extensions/discord/src/directory-config.ts | 21 +++++++------- extensions/slack/src/directory-config.ts | 31 +++++++++++---------- extensions/telegram/src/directory-config.ts | 21 +++++++------- src/plugin-sdk/directory-runtime.ts | 2 ++ 4 files changed, 38 insertions(+), 37 deletions(-) diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 9c5e794924a..af921c25165 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,23 +1,18 @@ import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + inspectReadOnlyChannelAccount, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectDiscordAccount } from "../api.js"; import type { InspectedDiscordAccount } from "../api.js"; -function inspectDiscordDirectoryAccount( - params: DirectoryConfigParams, -): InspectedDiscordAccount | null { - return inspectDiscordAccount({ +export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = (await inspectReadOnlyChannelAccount({ + channelId: "discord", cfg: params.cfg, accountId: params.accountId, - }); -} - -export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = inspectDiscordDirectoryAccount(params); + })) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } @@ -39,7 +34,11 @@ export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfi } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = inspectDiscordDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "discord", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index 0bc0f49804e..a74b2e4079d 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,23 +1,20 @@ -import { normalizeSlackMessagingTarget } from "openclaw/plugin-sdk/channel-runtime"; import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectSlackAccount } from "../api.js"; import type { InspectedSlackAccount } from "../api.js"; - -function inspectSlackDirectoryAccount(params: DirectoryConfigParams): InspectedSlackAccount | null { - return inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); -} +import { parseSlackTarget } from "./targets.js"; export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = inspectSlackDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "slack", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } @@ -35,15 +32,19 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP return null; } const target = `user:${normalizedUserId}`; - const normalized = normalizeSlackMessagingTarget(target) ?? target.toLowerCase(); - return normalized.startsWith("user:") ? normalized : null; + const normalized = parseSlackTarget(target, { defaultKind: "user" }); + return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null; }, }); return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = inspectSlackDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "slack", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } @@ -52,8 +53,8 @@ export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfig query: params.query, limit: params.limit, normalizeId: (raw) => { - const normalized = normalizeSlackMessagingTarget(raw) ?? raw.toLowerCase(); - return normalized.startsWith("channel:") ? normalized : null; + const normalized = parseSlackTarget(raw, { defaultKind: "channel" }); + return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null; }, }); } diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index 3355b295cca..08b9c3597e2 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -2,24 +2,19 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers" import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectTelegramAccount } from "../api.js"; import type { InspectedTelegramAccount } from "../api.js"; -async function inspectTelegramDirectoryAccount( - params: DirectoryConfigParams, -): Promise { - return inspectTelegramAccount({ +export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = (await inspectReadOnlyChannelAccount({ + channelId: "telegram", cfg: params.cfg, accountId: params.accountId, - }); -} - -export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = await inspectTelegramDirectoryAccount(params); + })) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } @@ -41,7 +36,11 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = await inspectTelegramDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "telegram", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/src/plugin-sdk/directory-runtime.ts b/src/plugin-sdk/directory-runtime.ts index 04f64523f69..a13a368abd4 100644 --- a/src/plugin-sdk/directory-runtime.ts +++ b/src/plugin-sdk/directory-runtime.ts @@ -1,5 +1,6 @@ /** Shared directory listing helpers for plugins that derive users/groups from config maps. */ export type { DirectoryConfigParams } from "../channels/plugins/directory-types.js"; +export type { ReadOnlyInspectedAccount } from "../channels/read-only-account-inspect.js"; export { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, @@ -9,3 +10,4 @@ export { listDirectoryUserEntriesFromAllowFromAndMapKeys, toDirectoryEntries, } from "../channels/plugins/directory-config-helpers.js"; +export { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; From 50a81c873101a4fb0f4f64537306cd8c77c0ba7e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:11:37 -0700 Subject: [PATCH 362/393] Plugins: merge agent and output-style dirs into Claude bundle skills --- src/plugins/bundle-manifest.test.ts | 2 +- src/plugins/bundle-manifest.ts | 2 ++ src/plugins/loader.ts | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts index b2a48f02f56..40bbf85152e 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -111,7 +111,7 @@ describe("bundle manifest parsing", () => { name: "Claude Sample", description: "Claude fixture", bundleFormat: "claude", - skills: ["skill-packs/starter", "commands-pack"], + skills: ["skill-packs/starter", "commands-pack", "agents-pack", "styles"], settingsFiles: ["settings.json"], hooks: ["hooks/hooks.json", "hooks-pack"], capabilities: expect.arrayContaining([ diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts index 7c2a362153b..3a3fed87158 100644 --- a/src/plugins/bundle-manifest.ts +++ b/src/plugins/bundle-manifest.ts @@ -216,6 +216,8 @@ function resolveClaudeSkillDirs(raw: Record, rootDir: string): return mergeBundlePathLists( resolveClaudeSkillsRootDirs(raw, rootDir), resolveClaudeCommandRootDirs(raw, rootDir), + resolveClaudeAgentDirs(raw, rootDir), + resolveClaudeOutputStylePaths(raw, rootDir), ); } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index ffccc04f4a6..c39a64e5f30 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1105,7 +1105,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi capability !== "mcpServers" && capability !== "settings" && !( - capability === "commands" && + (capability === "commands" || + capability === "agents" || + capability === "outputStyles" || + capability === "lspServers") && (record.bundleFormat === "claude" || record.bundleFormat === "cursor") ) && !( From 4ebd3d11aa12fcb7a5b69ec715061fdc677e4240 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:11:51 -0700 Subject: [PATCH 363/393] Plugins: add LSP server loader and surface in inspect reports --- src/cli/plugins-cli.ts | 8 ++ src/plugins/bundle-lsp.ts | 212 ++++++++++++++++++++++++++++++++++++++ src/plugins/status.ts | 26 +++++ 3 files changed, 246 insertions(+) create mode 100644 src/plugins/bundle-lsp.ts diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 8e02bff7a47..b180b0a38e8 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -796,6 +796,14 @@ export function registerPluginsCli(program: Command) { ), ), ); + lines.push( + ...formatInspectSection( + "LSP servers", + inspect.lspServers.map((entry) => + entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`, + ), + ), + ); if (inspect.httpRouteCount > 0) { lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)])); } diff --git a/src/plugins/bundle-lsp.ts b/src/plugins/bundle-lsp.ts new file mode 100644 index 00000000000..0151d5d1df2 --- /dev/null +++ b/src/plugins/bundle-lsp.ts @@ -0,0 +1,212 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { applyMergePatch } from "../config/merge-patch.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { isRecord } from "../utils.js"; +import { + CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + mergeBundlePathLists, + normalizeBundlePathList, +} from "./bundle-manifest.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginBundleFormat } from "./types.js"; + +export type BundleLspServerConfig = Record; + +export type BundleLspConfig = { + lspServers: Record; +}; + +export type BundleLspRuntimeSupport = { + hasStdioServer: boolean; + supportedServerNames: string[]; + unsupportedServerNames: string[]; + diagnostics: string[]; +}; + +const MANIFEST_PATH_BY_FORMAT: Partial> = { + claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, +}; + +function readPluginJsonObject(params: { + rootDir: string; + relativePath: string; +}): { ok: true; raw: Record } | { ok: false; error: string } { + const absolutePath = path.join(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + return { ok: true, raw: {} }; + } + try { + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + if (!isRecord(raw)) { + return { ok: false, error: `${params.relativePath} must contain a JSON object` }; + } + return { ok: true, raw }; + } catch (error) { + return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; + } finally { + fs.closeSync(opened.fd); + } +} + +function extractLspServerMap(raw: unknown): Record { + if (!isRecord(raw)) { + return {}; + } + const nested = isRecord(raw.lspServers) ? raw.lspServers : raw; + if (!isRecord(nested)) { + return {}; + } + const result: Record = {}; + for (const [serverName, serverRaw] of Object.entries(nested)) { + if (!isRecord(serverRaw)) { + continue; + } + result[serverName] = { ...serverRaw }; + } + return result; +} + +function resolveBundleLspConfigPaths(params: { + raw: Record; + rootDir: string; +}): string[] { + const declared = normalizeBundlePathList(params.raw.lspServers); + const defaults = fs.existsSync(path.join(params.rootDir, ".lsp.json")) ? [".lsp.json"] : []; + return mergeBundlePathLists(defaults, declared); +} + +function loadBundleLspConfigFile(params: { + rootDir: string; + relativePath: string; +}): BundleLspConfig { + const absolutePath = path.resolve(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + return { lspServers: {} }; + } + try { + const stat = fs.fstatSync(opened.fd); + if (!stat.isFile()) { + return { lspServers: {} }; + } + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + return { lspServers: extractLspServerMap(raw) }; + } finally { + fs.closeSync(opened.fd); + } +} + +function loadBundleLspConfig(params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; +}): { config: BundleLspConfig; diagnostics: string[] } { + const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat]; + if (!manifestRelativePath) { + return { config: { lspServers: {} }, diagnostics: [] }; + } + + const manifestLoaded = readPluginJsonObject({ + rootDir: params.rootDir, + relativePath: manifestRelativePath, + }); + if (!manifestLoaded.ok) { + return { config: { lspServers: {} }, diagnostics: [manifestLoaded.error] }; + } + + let merged: BundleLspConfig = { lspServers: {} }; + const filePaths = resolveBundleLspConfigPaths({ + raw: manifestLoaded.raw, + rootDir: params.rootDir, + }); + for (const relativePath of filePaths) { + merged = applyMergePatch( + merged, + loadBundleLspConfigFile({ + rootDir: params.rootDir, + relativePath, + }), + ) as BundleLspConfig; + } + + return { config: merged, diagnostics: [] }; +} + +export function inspectBundleLspRuntimeSupport(params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; +}): BundleLspRuntimeSupport { + const loaded = loadBundleLspConfig(params); + const supportedServerNames: string[] = []; + const unsupportedServerNames: string[] = []; + let hasStdioServer = false; + for (const [serverName, server] of Object.entries(loaded.config.lspServers)) { + if (typeof server.command === "string" && server.command.trim().length > 0) { + hasStdioServer = true; + supportedServerNames.push(serverName); + continue; + } + unsupportedServerNames.push(serverName); + } + return { + hasStdioServer, + supportedServerNames, + unsupportedServerNames, + diagnostics: loaded.diagnostics, + }; +} + +export function loadEnabledBundleLspConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; +}): { config: BundleLspConfig; diagnostics: Array<{ pluginId: string; message: string }> } { + const registry = loadPluginManifestRegistry({ + workspaceDir: params.workspaceDir, + config: params.cfg, + }); + const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); + const diagnostics: Array<{ pluginId: string; message: string }> = []; + let merged: BundleLspConfig = { lspServers: {} }; + + for (const record of registry.plugins) { + if (record.format !== "bundle" || !record.bundleFormat) { + continue; + } + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.cfg, + }); + if (!enableState.enabled) { + continue; + } + + const loaded = loadBundleLspConfig({ + pluginId: record.id, + rootDir: record.rootDir, + bundleFormat: record.bundleFormat, + }); + merged = applyMergePatch(merged, loaded.config) as BundleLspConfig; + for (const message of loaded.diagnostics) { + diagnostics.push({ pluginId: record.id, message }); + } + } + + return { config: merged, diagnostics }; +} diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 51284e43d42..a6b21541522 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; @@ -69,6 +70,10 @@ export type PluginInspectReport = { name: string; hasStdioTransport: boolean; }>; + lspServers: Array<{ + name: string; + hasStdioTransport: boolean; + }>; httpRouteCount: number; bundleCapabilities: string[]; diagnostics: PluginDiagnostic[]; @@ -252,6 +257,26 @@ export function buildPluginInspectReport(params: { ]; } + // Populate LSP server info for bundle-format plugins with a known rootDir. + let lspServers: PluginInspectReport["lspServers"] = []; + if (plugin.format === "bundle" && plugin.bundleFormat && plugin.rootDir) { + const lspSupport = inspectBundleLspRuntimeSupport({ + pluginId: plugin.id, + rootDir: plugin.rootDir, + bundleFormat: plugin.bundleFormat, + }); + lspServers = [ + ...lspSupport.supportedServerNames.map((name) => ({ + name, + hasStdioTransport: true, + })), + ...lspSupport.unsupportedServerNames.map((name) => ({ + name, + hasStdioTransport: false, + })), + ]; + } + const usesLegacyBeforeAgentStart = typedHooks.some( (entry) => entry.name === "before_agent_start", ); @@ -275,6 +300,7 @@ export function buildPluginInspectReport(params: { services: [...plugin.services], gatewayMethods: [...plugin.gatewayMethods], mcpServers, + lspServers, httpRouteCount: plugin.httpRoutes, bundleCapabilities: plugin.bundleCapabilities ?? [], diagnostics, From 6538c876738887917f9ba733f02fb92df2e5e0e0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:11:59 -0700 Subject: [PATCH 364/393] Tests: update Claude bundle integration test for agents, output styles, and LSP --- src/plugins/bundle-claude-inspect.test.ts | 34 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts index 87d48c0eff2..377aca5503b 100644 --- a/src/plugins/bundle-claude-inspect.test.ts +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; @@ -89,8 +90,19 @@ describe("Claude bundle plugin inspect integration", () => { // agents/ directory fs.mkdirSync(path.join(rootDir, "agents"), { recursive: true }); - // .lsp.json - fs.writeFileSync(path.join(rootDir, ".lsp.json"), '{"lspServers":{}}', "utf-8"); + // .lsp.json with a stdio LSP server + fs.writeFileSync( + path.join(rootDir, ".lsp.json"), + JSON.stringify({ + lspServers: { + "typescript-lsp": { + command: "typescript-language-server", + args: ["--stdio"], + }, + }, + }), + "utf-8", + ); // output-styles/ directory fs.mkdirSync(path.join(rootDir, "output-styles"), { recursive: true }); @@ -114,7 +126,7 @@ describe("Claude bundle plugin inspect integration", () => { expect(m.bundleFormat).toBe("claude"); }); - it("resolves skills from both skills and commands paths", () => { + it("resolves skills from skills, commands, and agents paths", () => { const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); expect(result.ok).toBe(true); if (!result.ok) { @@ -123,6 +135,9 @@ describe("Claude bundle plugin inspect integration", () => { expect(result.manifest.skills).toContain("skill-packs"); expect(result.manifest.skills).toContain("extra-commands"); + // Agent and output style dirs are merged into skills so their .md files are discoverable + expect(result.manifest.skills).toContain("agents"); + expect(result.manifest.skills).toContain("output-styles"); }); it("resolves hooks from default and declared paths", () => { @@ -177,4 +192,17 @@ describe("Claude bundle plugin inspect integration", () => { expect(mcp.unsupportedServerNames).toContain("test-sse-server"); expect(mcp.diagnostics).toEqual([]); }); + + it("inspects LSP runtime support with stdio server", () => { + const lsp = inspectBundleLspRuntimeSupport({ + pluginId: "test-claude-plugin", + rootDir, + bundleFormat: "claude", + }); + + expect(lsp.hasStdioServer).toBe(true); + expect(lsp.supportedServerNames).toContain("typescript-lsp"); + expect(lsp.unsupportedServerNames).toEqual([]); + expect(lsp.diagnostics).toEqual([]); + }); }); From 198ed08a385a983d2f31071e05e846aa80b57728 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:13:18 -0700 Subject: [PATCH 365/393] docs: fix redirect chains and disambiguate duplicate titles Redirects: - /cron now goes directly to /automation/cron-jobs (was chaining via /cron-jobs) - /model and /model/ now go directly to /concepts/models (was chaining via /models) Duplicate titles disambiguated (6 of 7 - Logging is orphaned): - Health Checks (macOS), Skills (macOS), Voice Wake (macOS), WebChat (macOS) - General Troubleshooting (help/ vs gateway/) - Provider Directory (providers/index vs concepts/model-providers) Co-Authored-By: Claude Opus 4.6 --- docs/docs.json | 6 +++--- docs/help/troubleshooting.md | 2 +- docs/platforms/mac/health.md | 2 +- docs/platforms/mac/skills.md | 2 +- docs/platforms/mac/voicewake.md | 2 +- docs/platforms/mac/webchat.md | 2 +- docs/providers/index.md | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 3a79d609100..5ee53ed6008 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -65,7 +65,7 @@ }, { "source": "/cron", - "destination": "/cron-jobs" + "destination": "/automation/cron-jobs" }, { "source": "/minimax", @@ -513,11 +513,11 @@ }, { "source": "/model", - "destination": "/models" + "destination": "/concepts/models" }, { "source": "/model/", - "destination": "/models" + "destination": "/concepts/models" }, { "source": "/models", diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 1660100ba8c..63cfacbee50 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -3,7 +3,7 @@ summary: "Symptom first troubleshooting hub for OpenClaw" read_when: - OpenClaw is not working and you need the fastest path to a fix - You want a triage flow before diving into deep runbooks -title: "Troubleshooting" +title: "General Troubleshooting" --- # Troubleshooting diff --git a/docs/platforms/mac/health.md b/docs/platforms/mac/health.md index 8115dd4c250..7cda23e3221 100644 --- a/docs/platforms/mac/health.md +++ b/docs/platforms/mac/health.md @@ -2,7 +2,7 @@ summary: "How the macOS app reports gateway/Baileys health states" read_when: - Debugging mac app health indicators -title: "Health Checks" +title: "Health Checks (macOS)" --- # Health Checks on macOS diff --git a/docs/platforms/mac/skills.md b/docs/platforms/mac/skills.md index fc1e6c6af5f..2c2b5d95924 100644 --- a/docs/platforms/mac/skills.md +++ b/docs/platforms/mac/skills.md @@ -3,7 +3,7 @@ summary: "macOS Skills settings UI and gateway-backed status" read_when: - Updating the macOS Skills settings UI - Changing skills gating or install behavior -title: "Skills" +title: "Skills (macOS)" --- # Skills (macOS) diff --git a/docs/platforms/mac/voicewake.md b/docs/platforms/mac/voicewake.md index 1830acb35a4..c7cacd4c5dd 100644 --- a/docs/platforms/mac/voicewake.md +++ b/docs/platforms/mac/voicewake.md @@ -2,7 +2,7 @@ summary: "Voice wake and push-to-talk modes plus routing details in the mac app" read_when: - Working on voice wake or PTT pathways -title: "Voice Wake" +title: "Voice Wake (macOS)" --- # Voice Wake & Push-to-Talk diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 11b500a8596..6bc27203fae 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -2,7 +2,7 @@ summary: "How the mac app embeds the gateway WebChat and how to debug it" read_when: - Debugging mac WebChat view or loopback port -title: "WebChat" +title: "WebChat (macOS)" --- # WebChat (macOS app) diff --git a/docs/providers/index.md b/docs/providers/index.md index 82e30575bc8..7da77b34c5d 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -3,7 +3,7 @@ summary: "Model providers (LLMs) supported by OpenClaw" read_when: - You want to choose a model provider - You need a quick overview of supported LLM backends -title: "Model Providers" +title: "Provider Directory" --- # Model Providers From 3a28bc7d8f1c2c9a8952835be1ff0422135547b7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:14:01 -0700 Subject: [PATCH 366/393] docs(plugins): rewrite compatibility signals for clarity Replace robotic prose with a scannable table and plain-language summary. Same information, less stiff. Co-Authored-By: Claude Opus 4.6 --- docs/tools/plugin.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 5336df574af..438a3975e14 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -175,25 +175,19 @@ Direction: ### Compatibility signals -OpenClaw treats config validity and plugin migration state as separate axes: +When you run `openclaw doctor` or `openclaw plugins inspect `, you may see +one of these labels: -- **config valid** — the config parses and referenced plugins can be resolved -- **compatibility advisory** — a plugin is still on a supported compatibility - path, such as `hook-only` -- **legacy warning** — a plugin still uses `before_agent_start` -- **hard error** — the config is invalid or plugin loading/validation fails +| Signal | Meaning | +| -------------------------- | ------------------------------------------------------------ | +| **config valid** | Config parses fine and plugins resolve | +| **compatibility advisory** | Plugin uses a supported-but-older pattern (e.g. `hook-only`) | +| **legacy warning** | Plugin uses `before_agent_start`, which is deprecated | +| **hard error** | Config is invalid or plugin failed to load | -Current compatibility guidance: - -- `hook-only` is advisory only. It remains a supported compatibility path for - existing plugins. -- `before_agent_start` is the only strong migration warning in the current - model. -- Neither state blocks an existing plugin by itself. - -You can see these signals in `openclaw doctor`, `openclaw status`, -`openclaw status --all`, `openclaw plugins doctor`, and -`openclaw plugins inspect `. +Neither `hook-only` nor `before_agent_start` will break your plugin today — +`hook-only` is advisory, and `before_agent_start` only triggers a warning. These +signals also appear in `openclaw status --all` and `openclaw plugins doctor`. ## Architecture From 4e265fe7d66b6b0d3570b538f71f08a44b01f1a0 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 12:43:22 +0530 Subject: [PATCH 367/393] test(telegram): fix native command runtime mocks --- .../bot-native-commands.session-meta.test.ts | 12 +++++ ...t-native-commands.skills-allowlist.test.ts | 5 ++ .../src/bot-native-commands.test-helpers.ts | 51 +++++++++++++------ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 7540f22b1ac..4ef543becda 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js"; +import type { TelegramBotDeps } from "./bot-deps.js"; import { createDeferred, createNativeCommandTestParams, @@ -189,6 +190,16 @@ function registerAndResolveCommandHandlerBase(params: { } = params; const commandHandlers = new Map(); const sendMessage = vi.fn().mockResolvedValue(undefined); + const telegramDeps: TelegramBotDeps = { + loadConfig: vi.fn(() => cfg), + resolveStorePath: sessionMocks.resolveStorePath as TelegramBotDeps["resolveStorePath"], + readChannelAllowFromStore: vi.fn(async () => []), + enqueueSystemEvent: vi.fn(), + dispatchReplyWithBufferedBlockDispatcher: + replyMocks.dispatchReplyWithBufferedBlockDispatcher as TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"], + listSkillCommandsForAgents: vi.fn(() => []), + wasSentByBot: vi.fn(() => false), + }; registerTelegramNativeCommands({ ...createNativeCommandTestParams({ bot: { @@ -206,6 +217,7 @@ function registerAndResolveCommandHandlerBase(params: { useAccessGroups, telegramCfg, resolveTelegramGroupConfig, + telegramDeps, }), }); diff --git a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index 5a2b2552739..10f0e95bdb8 100644 --- a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -11,6 +11,7 @@ import { import { registerTelegramNativeCommands } from "./bot-native-commands.js"; import { createNativeCommandTestParams, + listSkillCommandsForAgents, resetNativeCommandMenuMocks, waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; @@ -62,6 +63,10 @@ describe("registerTelegramNativeCommands skill allowlist integration", () => { }, ], }; + const actualSkillCommands = await import("../../../src/auto-reply/skill-commands.js"); + listSkillCommandsForAgents.mockImplementation(({ cfg, agentIds }) => + actualSkillCommands.listSkillCommandsForAgents({ cfg, agentIds }), + ); registerTelegramNativeCommands({ ...createNativeCommandTestParams(cfg, { diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 3afeb63fbb2..7a35ec37275 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -65,28 +65,36 @@ const replyPipelineMocks = vi.hoisted(() => { export const dispatchReplyWithBufferedBlockDispatcher = replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher; -vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ - finalizeInboundContext: replyPipelineMocks.finalizeInboundContext, -})); -vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ - dispatchReplyWithBufferedBlockDispatcher: - replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, -})); -vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ - createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions, -})); -vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ - recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, -})); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + finalizeInboundContext: replyPipelineMocks.finalizeInboundContext, + dispatchReplyWithBufferedBlockDispatcher: + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, + }; +}); +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions, + recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, + }; +}); const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => {}), })); export const deliverReplies = deliveryMocks.deliverReplies; vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies })); -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore: vi.fn(async () => []), -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readChannelAllowFromStore: vi.fn(async () => []), + }; +}); export { createNativeCommandTestParams }; export function createNativeCommandsHarness(params?: { @@ -104,6 +112,16 @@ export function createNativeCommandsHarness(params?: { const sendMessage: AnyAsyncMock = vi.fn(async () => undefined); const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined); const log: AnyMock = vi.fn(); + const telegramDeps = { + loadConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)), + resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/sessions.json"), + readChannelAllowFromStore: vi.fn(async () => []), + enqueueSystemEvent: vi.fn(), + dispatchReplyWithBufferedBlockDispatcher: + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, + listSkillCommandsForAgents: vi.fn(() => []), + wasSentByBot: vi.fn(() => false), + }; const bot = { api: { setMyCommands, @@ -128,6 +146,7 @@ export function createNativeCommandsHarness(params?: { nativeEnabled: params?.nativeEnabled ?? true, nativeSkillsEnabled: false, nativeDisabledExplicit: false, + telegramDeps, resolveGroupPolicy: params?.resolveGroupPolicy ?? (() => From 6802a768cf5c84b9d6bb002c929bc82ea1771253 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 12:43:24 +0530 Subject: [PATCH 368/393] fix(zalo): break account helper cycles --- extensions/zalo/src/accounts.ts | 3 ++- extensions/zalouser/src/accounts.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/zalo/src/accounts.ts b/extensions/zalo/src/accounts.ts index e12503561f9..4791fb6c1e0 100644 --- a/extensions/zalo/src/accounts.ts +++ b/extensions/zalo/src/accounts.ts @@ -1,6 +1,7 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "./runtime-api.js"; import { resolveZaloToken } from "./token.js"; -import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js"; import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js"; export type { ResolvedZaloAccount }; diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 05436e86ba5..60c223e5f78 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -1,5 +1,6 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js"; import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js"; From 466510b6d850dd475519403e5534aa32a41bad5d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:19:56 -0700 Subject: [PATCH 369/393] refactor: replace "seam" terminology across codebase Replace "seam" with clearer terms throughout: - "surface" for public API/extension boundaries - "boundary" for plugin/module interfaces - "interface" for runtime connection points - "hook" for test injection points - "palette" for the lobster palette reference Also delete experiments/acp-pluginification-architecture-plan.md Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 2 +- CHANGELOG.md | 4 +- docs/cli/index.md | 2 +- docs/help/testing.md | 4 +- .../acp-pluginification-architecture-plan.md | 519 ------------------ extensions/googlechat/runtime-api.ts | 2 +- .../matrix/src/matrix/send/formatting.ts | 2 +- scripts/check-no-extension-src-imports.ts | 2 +- .../check-no-extension-test-core-imports.ts | 6 +- src/channels/plugins/actions/actions.test.ts | 2 +- src/config/schema.help.ts | 2 +- src/infra/provider-usage.auth.plugin.test.ts | 2 +- src/infra/provider-usage.load.plugin.test.ts | 2 +- src/memory/hybrid.ts | 2 +- .../channel-import-guardrails.test.ts | 12 +- src/plugin-sdk/runtime-api-guardrails.test.ts | 2 +- src/plugin-sdk/subpaths.test.ts | 6 +- src/plugins/status.test.ts | 4 +- src/terminal/palette.ts | 2 +- 19 files changed, 30 insertions(+), 549 deletions(-) delete mode 100644 experiments/acp-pluginification-architecture-plan.md diff --git a/AGENTS.md b/AGENTS.md index 57e7bd22100..12a86185aaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,7 +281,7 @@ - If staged+unstaged diffs are formatting-only, auto-resolve without asking. - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation. - Only ask when changes are semantic (logic/data/behavior). -- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. +- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). diff --git a/CHANGELOG.md b/CHANGELOG.md index e99959251ee..2d99a6fdcff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Docs: https://docs.openclaw.ai - Models/OpenAI: add native forward-compat support for `gpt-5.4-mini` and `gpt-5.4-nano` in the OpenAI provider catalog, runtime resolution, and reasoning capability gates. Thanks @vincentkoc. - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. -- Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. +- Plugins/testing: add a public `openclaw/plugin-sdk/testing` surface for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. - Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. @@ -141,7 +141,7 @@ Docs: https://docs.openclaw.ai - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. - Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc. -- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. +- Plugins/runtime-api: pin extension runtime-api export surfaces with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. - Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc. - Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant. - Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc. diff --git a/docs/cli/index.md b/docs/cli/index.md index d9d50733632..f1555b4ea26 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -88,7 +88,7 @@ OpenClaw uses a lobster palette for CLI output. - `error` (#E23D2D): errors, failures. - `muted` (#8B7F77): de-emphasis, metadata. -Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”). +Palette source of truth: `src/terminal/palette.ts` (the “lobster palette”). ## Command tree diff --git a/docs/help/testing.md b/docs/help/testing.md index 0d14f507bc9..e2cae188c0e 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -55,14 +55,14 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Embedded runner note: - When you change message-tool discovery inputs or compaction runtime context, keep both levels of coverage. - - Add focused helper regressions for pure routing/normalization seams. + - Add focused helper regressions for pure routing/normalization boundaries. - Also keep the embedded runner integration suites healthy: `src/agents/pi-embedded-runner/compact.hooks.test.ts`, `src/agents/pi-embedded-runner/run.overflow-compaction.test.ts`, and `src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts`. - Those suites verify that scoped ids and compaction behavior still flow through the real `run.ts` / `compact.ts` paths; helper-only tests are not a - sufficient substitute for those seams. + sufficient substitute for those integration paths. - Pool note: - OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards. - On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there. diff --git a/experiments/acp-pluginification-architecture-plan.md b/experiments/acp-pluginification-architecture-plan.md deleted file mode 100644 index b055c1800ce..00000000000 --- a/experiments/acp-pluginification-architecture-plan.md +++ /dev/null @@ -1,519 +0,0 @@ -# Bindings Capability Architecture Plan - -Status: in progress - -## Summary - -The goal is not to move all ACP code out of core. - -The goal is to make `bindings` a small core capability, keep the ACP session kernel in core, and move ACP-specific binding policy plus codex app server policy out of core. - -That gives us a lightweight core without hiding core semantics behind plugin indirection. - -## Current Conclusion - -The current architecture should converge on this split: - -- Core owns the generic binding capability. -- Core owns the generic ACP session kernel. -- Channel plugins own channel-specific binding semantics. -- ACP backend plugins own runtime protocol details. -- Product-level consumers like ACP configured bindings and the codex app server sit on top of the binding capability instead of hardcoding their own binding plumbing. - -This is different from "everything becomes a plugin". - -## Why This Changed - -The current codebase already shows that there are really three different layers: - -- binding and conversation ownership -- long-lived session and runtime-handle orchestration -- product-specific turn logic - -Those layers should not all be forced into one runtime engine. - -Today the duplication is mostly in the execution/control-plane shape, not in storage or binding plumbing: - -- the main harness has its own turn engine -- ACP has its own session control plane -- the codex app server plugin path likely owns its own app-level turn engine outside this repo - -The right move is to share the stable control-plane contracts, not to force all three into one giant executor. - -## Verified Current State - -### Generic binding pieces already exist - -- `src/infra/outbound/session-binding-service.ts` already provides a generic binding store and adapter model. -- `src/plugins/conversation-binding.ts` already lets plugins request a conversation binding and stores plugin-owned binding metadata. -- `src/plugins/types.ts` already exposes plugin-facing binding APIs. -- `src/plugins/types.ts` already exposes the generic `inbound_claim` hook. - -### ACP is only partially pluginified - -- `src/channels/plugins/configured-binding-registry.ts` now owns generic configured binding compilation and lookup. -- `src/channels/plugins/binding-routing.ts` and `src/channels/plugins/binding-targets.ts` now own the generic route and target lifecycle seams. -- ACP now plugs into that seam through `src/channels/plugins/acp-configured-binding-consumer.ts` and `src/channels/plugins/acp-stateful-target-driver.ts`. -- `src/acp/persistent-bindings.lifecycle.ts` still owns configured ACP ensure and reset behavior. -- runtime-created plugin conversation bindings still use a separate path in `src/plugins/conversation-binding.ts`. - -### Codex app server is already closer to the desired shape - -From this repo's side, the codex app server path is much thinner: - -- a plugin binds a conversation -- core stores that binding -- inbound dispatch targets the plugin's `inbound_claim` hook - -What core does not provide for the codex app server path is an ACP-like shared session kernel. If the app server needs retries, long-lived runtime handles, cancellation, or session health logic, it must own that itself today. - -## The Durable Split - -### 1. Core Binding Capability - -This should become the primary shared seam. - -Responsibilities: - -- canonical `ConversationRef` -- binding record storage -- configured binding compilation -- runtime-created binding storage -- fast binding lookup on inbound -- binding touch/unbind lifecycle -- generic dispatch handoff to the binding target - -What core binding capability must not own: - -- Discord thread rules -- Telegram topic rules -- Feishu chat rules -- ACP session orchestration -- codex app server business logic - -### 2. Core Stateful Target Kernel - -This is the small generic kernel for long-lived bound targets. - -Responsibilities: - -- ensure target ready -- run turn -- cancel turn -- close target -- reset target -- status and health -- persistence of target metadata -- retries and runtime-handle safety -- per-target serialization and concurrency - -ACP is the first real implementation of this shape. - -This kernel should stay in core because it is mandatory infrastructure and has strict startup, reset, and recovery semantics. - -### 3. Channel Binding Providers - -Each channel plugin should own the meaning of "this channel conversation maps to this binding rule". - -Responsibilities: - -- normalize configured binding targets -- normalize inbound conversations -- match inbound conversations against compiled bindings -- define channel-specific matching priority -- optionally provide binding description text for status and logs - -This is where Discord channel vs thread logic, Telegram topic rules, and Feishu conversation rules belong. - -### 4. Product Consumers - -Bindings are a shared capability. Different products should consume it differently. - -ACP configured bindings: - -- compile config rules -- resolve a target session -- ensure the ACP session is ready through the ACP kernel - -Codex app server: - -- create runtime-requested bindings -- claim inbound messages through plugin hooks -- optionally adopt the shared stateful target contract later if it really needs long-lived session orchestration - -Main harness: - -- does not need to become "a binding product" -- may eventually share small lifecycle contracts, but it should not be forced into the same engine as ACP - -## The Key Architectural Decision - -The shared abstraction should be: - -- `bindings` as the capability -- `stateful target drivers` as an optional lower-level contract - -The shared abstraction should not be: - -- "one runtime engine for main harness, ACP, and codex app server" - -That would overfit very different systems into one executor. - -## Stable Nouns - -Core should understand only stable nouns. - -The stable nouns are: - -- `ConversationRef` -- `BindingRule` -- `CompiledBinding` -- `BindingResolution` -- `BindingTargetDescriptor` -- `StatefulTargetDriver` -- `StatefulTargetHandle` - -ACP, codex app server, and future products should compile down to those nouns instead of leaking product-specific routing rules through core. - -## Proposed Capability Model - -### Binding capability - -The binding capability should support both configured bindings and runtime-created bindings. - -Required operations: - -- compile configured bindings at startup or reload -- resolve a binding from an inbound `ConversationRef` -- create a runtime binding -- touch and unbind an existing binding -- dispatch a resolved binding to its target - -### Binding target descriptor - -A resolved binding should point to a typed target descriptor rather than ad hoc ACP- or plugin-specific metadata blobs. - -The descriptor should be able to represent at least: - -- plugin-owned inbound claim targets -- stateful target drivers - -That means the same binding capability can support both: - -- codex app server plugin-bound conversations -- ACP configured bindings - -without pretending they are the same product. - -### Stateful target driver - -This is the reusable control-plane contract for long-lived bound targets. - -Required operations: - -- `ensureReady` -- `runTurn` -- `cancel` -- `close` -- `reset` -- `status` -- `health` - -ACP should remain the first built-in driver. - -If the codex app server later proves that it also needs durable session handles, it can either: - -- use a driver that consumes this contract, or -- keep its own product-owned runtime if that remains simpler - -That should be a product decision, not something forced by the binding capability. - -## Why ACP Kernel Stays In Core - -ACP's kernel should remain in core because session lifecycle, persistence, retries, cancellation, and runtime-handle safety are generic platform machinery. - -Those concerns are not channel-specific, and they are not codex-app-server-specific. - -If we move that machinery into an ordinary plugin, we create circular bootstrapping: - -- channels need it during startup and inbound routing -- reset and recovery need it when plugins may already be degraded -- failure semantics become special-case core logic anyway - -If we later wrap it in a "built-in capability module", that is still effectively core. - -## What Should Move Out Of Core - -The following should move out of ACP-shaped core code: - -- channel-specific configured binding matching -- channel-specific binding target normalization -- channel-specific recovery UX -- ACP-specific route wrapping helpers as named ACP seams -- codex app server fallback policy beyond generic plugin-bound dispatch behavior - -The following should stay: - -- generic binding storage and dispatch -- generic ACP control plane -- generic stateful target driver contract - -## Current Problems To Remove - -### Residual cleanup is now small - -Most ACP-era compatibility names are gone from the generic seam. - -The remaining cleanup is smaller: - -- `src/acp/persistent-bindings.ts` compatibility barrel can be deleted once tests stop importing it -- ACP-named tests and mocks can be renamed over time for consistency -- docs should stop describing already-removed ACP wrappers as if they still exist - -### Configured binding implementation is still too monolithic - -`src/channels/plugins/configured-binding-registry.ts` still mixes: - -- registry compilation -- cache invalidation -- inbound matching -- materialization of binding targets -- session-key reverse lookup - -That file is now generic, but still too large and too coupled. - -### Runtime-created plugin bindings still use a separate stack - -`src/plugins/conversation-binding.ts` is still a separate implementation path for plugin-created bindings. - -That means configured bindings and runtime-created bindings share storage, but not one consistent capability layer. - -### Generic registries still hardcode ACP as a built-in - -`src/channels/plugins/configured-binding-consumers.ts` and `src/channels/plugins/stateful-target-drivers.ts` still import ACP directly. - -That is acceptable for now, but the clean final shape is to keep ACP built in while registering it from a dedicated bootstrap point instead of wiring it inside the generic registry files. - -## Target Contracts - -### Channel binding provider contract - -Conceptually, each channel plugin should support: - -- `compileConfiguredBinding(binding, cfg) -> CompiledBinding | null` -- `resolveInboundConversation(event) -> ConversationRef | null` -- `matchInboundConversation(compiledBinding, conversation) -> BindingMatch | null` -- `describeBinding(compiledBinding) -> string | undefined` - -### Binding capability contract - -Core should support: - -- `compileConfiguredBindings(cfg, plugins) -> CompiledBindingRegistry` -- `resolveBinding(conversationRef) -> BindingResolution | null` -- `createRuntimeBinding(target, conversationRef, metadata) -> BindingRecord` -- `touchBinding(bindingId)` -- `unbindBinding(bindingId | target)` -- `dispatchResolvedBinding(bindingResolution, inboundEvent)` - -### Stateful target driver contract - -Core should support: - -- `ensureReady(targetRef, cfg)` -- `runTurn(targetRef, input)` -- `cancel(targetRef, reason)` -- `close(targetRef, reason)` -- `reset(targetRef, reason)` -- `status(targetRef)` -- `health(targetRef)` - -## File-Level Transition Plan - -### Keep - -- `src/infra/outbound/session-binding-service.ts` -- `src/acp/control-plane/*` -- `extensions/acpx/*` - -### Generalize - -- `src/plugins/conversation-binding.ts` - - fold runtime-created plugin bindings into the same generic binding capability instead of keeping a separate implementation stack -- `src/channels/plugins/configured-binding-registry.ts` - - split into compiler, matcher, and session-key resolution modules with a thin facade -- `src/channels/plugins/types.adapters.ts` - - finish removing ACP-era aliases after the deprecation window -- `src/plugin-sdk/conversation-runtime.ts` - - export only the generic binding capability surfaces -- `src/acp/persistent-bindings.lifecycle.ts` - - either become a generic stateful target driver consumer or be renamed to ACP driver-specific lifecycle code - -### Shrink Or Delete - -- `src/acp/persistent-bindings.ts` - - delete the compatibility barrel once tests import the real modules directly -- `src/acp/persistent-bindings.resolve.ts` - - keep only while ACP-specific compatibility helpers are still useful to internal callers -- ACP-named test files - - rename over time once the behavior is stable and there is no risk of mixing behavioral and naming churn - -## Recommended Refactor Order - -### Completed groundwork - -The current branch has already completed most of the first migration wave: - -- stable generic binding nouns exist -- configured bindings compile through a generic registry -- inbound routing goes through generic binding resolution -- configured binding lookup no longer performs fallback plugin discovery -- ACP is expressed as a configured-binding consumer plus a built-in stateful target driver - -The remaining work is cleanup and unification, not first-principles redesign. - -### Phase 1: Freeze the nouns - -Introduce and document the stable binding and target types: - -- `ConversationRef` -- `CompiledBinding` -- `BindingResolution` -- `BindingTargetDescriptor` -- `StatefulTargetDriver` - -Do this before more movement so the rest of the refactor has firm vocabulary. - -### Phase 2: Promote bindings to a first-class core capability - -Refactor the existing generic binding store into an explicit capability layer. - -Requirements: - -- runtime-created bindings stay supported -- configured bindings become first-class -- lookup becomes channel-agnostic - -### Phase 3: Compile configured bindings at startup and reload - -Move configured binding compilation off the inbound hot path. - -Requirements: - -- load enabled channel plugins once -- compile configured bindings once -- rebuild on config or plugin reload -- inbound path becomes pure registry lookup - -### Phase 4: Expand the channel provider seam - -Replace the ACP-specific adapter shape with a generic channel binding provider contract. - -Requirements: - -- channel plugins own normalization and matching -- core no longer knows channel-specific configured binding rules - -### Phase 5: Re-express ACP as a binding consumer plus built-in stateful target driver - -Move ACP configured binding policy to the new binding capability while keeping ACP runtime orchestration in core. - -Requirements: - -- ACP configured bindings resolve through the generic binding registry -- ACP target readiness uses the ACP driver contract -- ACP-specific naming disappears from generic binding code - -### Phase 6: Finish residual ACP cleanup - -Remove the last compatibility leftovers and stale naming. - -Requirements: - -- delete `src/acp/persistent-bindings.ts` -- rename ACP-named tests where that improves clarity without changing behavior -- keep docs synchronized with the actual generic seam instead of the earlier transition state - -### Phase 7: Split the configured binding registry by responsibility - -Refactor `src/channels/plugins/configured-binding-registry.ts` into smaller modules. - -Suggested split: - -- compiler module -- inbound matcher module -- session-key reverse lookup module -- thin public facade - -Requirements: - -- caching behavior remains unchanged -- matching behavior remains unchanged -- session-key resolution behavior remains unchanged - -### Phase 8: Keep codex app server on the same binding capability - -Do not force the codex app server into ACP semantics. - -Requirements: - -- codex app server keeps runtime-created bindings through the same binding capability -- inbound claim remains the default delivery path -- only adopt the stateful target driver seam if the app server truly needs long-lived target orchestration -- `src/plugins/conversation-binding.ts` stops being a separate binding stack and becomes a consumer of the generic binding capability - -### Phase 9: Decouple built-in ACP registration from generic registry files - -Keep ACP built in, but stop importing it directly from the generic registry modules. - -Requirements: - -- `src/channels/plugins/configured-binding-consumers.ts` no longer hardcodes ACP imports -- `src/channels/plugins/stateful-target-drivers.ts` no longer hardcodes ACP imports -- ACP still registers by default during normal startup -- generic registry files remain product-agnostic - -### Phase 10: Remove ACP-shaped compatibility facades - -Once all call sites are on the generic capability: - -- delete ACP-shaped routing helpers -- delete hot-path plugin bootstrapping logic -- keep only thin compatibility exports if external plugins still need a deprecation window - -## Success Criteria - -The architecture is done when all of these are true: - -- no inbound configured-binding resolution performs plugin discovery -- no channel-specific binding semantics remain in generic core binding code -- ACP still uses a core session kernel -- codex app server and ACP both sit on top of the same binding capability -- the binding capability can represent both configured and runtime-created bindings -- runtime-created plugin bindings do not use a separate implementation stack -- long-lived target orchestration is shared through a small core driver contract -- generic registry files do not import ACP directly -- ACP-era alias names are gone from the generic/plugin SDK surface -- the main harness is not forced into the ACP engine -- external plugins can use the same capability without internal imports - -## Non-Goals - -These are not goals of the remaining refactor: - -- moving the ACP session kernel into an ordinary plugin -- forcing the main harness, ACP, and codex app server into one executor -- making every channel implement its own retry and session-safety logic -- keeping ACP-shaped naming in the long-term generic binding layer - -## Bottom Line - -The right 20-year split is: - -- bindings are the shared core capability -- ACP session orchestration remains a small built-in core kernel -- channel plugins own binding semantics -- backend plugins own runtime protocol details -- product consumers like ACP configured bindings and codex app server build on the same binding capability without being forced into one runtime engine - -That is the leanest core that still has honest boundaries. diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 6f0861114ec..9eecea28139 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Google Chat extension. -// Keep this seam thin and aligned with the curated plugin-sdk/googlechat surface. +// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface. export * from "openclaw/plugin-sdk/googlechat"; diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts index bf0ed1989be..2d15e74cb4d 100644 --- a/extensions/matrix/src/matrix/send/formatting.ts +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -85,7 +85,7 @@ export function resolveMatrixVoiceDecision(opts: { function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean { // Matrix currently shares the core voice compatibility policy. - // Keep this wrapper as the seam if Matrix policy diverges later. + // Keep this wrapper as the boundary if Matrix policy diverges later. return getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName, diff --git a/scripts/check-no-extension-src-imports.ts b/scripts/check-no-extension-src-imports.ts index e6399f45048..59fb6bef480 100644 --- a/scripts/check-no-extension-src-imports.ts +++ b/scripts/check-no-extension-src-imports.ts @@ -75,7 +75,7 @@ function main() { console.error(`- ${relative}`); } console.error( - "Publish a focused openclaw/plugin-sdk/ seam or use the extension's own public barrel instead.", + "Publish a focused openclaw/plugin-sdk/ surface or use the extension's own public barrel instead.", ); process.exit(1); } diff --git a/scripts/check-no-extension-test-core-imports.ts b/scripts/check-no-extension-test-core-imports.ts index 01d6639df1e..af65c8387a9 100644 --- a/scripts/check-no-extension-test-core-imports.ts +++ b/scripts/check-no-extension-test-core-imports.ts @@ -8,7 +8,7 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [ }, { pattern: /["']openclaw\/plugin-sdk\/test-utils["']/, - hint: "Use openclaw/plugin-sdk/testing for the public extension test seam.", + hint: "Use openclaw/plugin-sdk/testing for the public extension test surface.", }, { pattern: /["']openclaw\/plugin-sdk\/compat["']/, @@ -20,7 +20,7 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [ }, { pattern: /["'](?:\.\.\/)+(?:src\/test-utils\/)[^"']+["']/, - hint: "Use test/helpers/extensions/* for repo-only helpers, or openclaw/plugin-sdk/testing for public seams.", + hint: "Use test/helpers/extensions/* for repo-only helpers, or openclaw/plugin-sdk/testing for public surfaces.", }, { pattern: /["'](?:\.\.\/)+(?:src\/plugins\/types\.js)["']/, @@ -81,7 +81,7 @@ function main() { if (offenders.length > 0) { console.error( - "Extension test files must stay on extension test bridges or public plugin-sdk seams.", + "Extension test files must stay on extension test bridges or public plugin-sdk surfaces.", ); for (const offender of offenders.toSorted((a, b) => a.file.localeCompare(b.file))) { const relative = path.relative(process.cwd(), offender.file) || offender.file; diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index f9c8025d3f4..67aa1f7b282 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -711,7 +711,7 @@ describe("telegramMessageActions", () => { } }); - it("forwards telegram action aliases into the runtime seam", async () => { + it("forwards telegram action aliases into the runtime interface", async () => { const cases = [ { name: "media-only send preserves asVoice", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 72ec1074135..b83c1cfeda2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -923,7 +923,7 @@ export const FIELD_HELP: Record = { "Forces a session memory-search reindex after compaction-triggered transcript updates (default: true). Keep enabled when compacted summaries must be immediately searchable, or disable to reduce write-time indexing pressure.", ui: "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.", "ui.seamColor": - "Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", + "Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", "ui.assistant": "Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.", "ui.assistant.name": diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index 64339a919d2..b8fa75afc5f 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -9,7 +9,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; -describe("resolveProviderAuths plugin seam", () => { +describe("resolveProviderAuths plugin boundary", () => { beforeEach(async () => { vi.resetModules(); resolveProviderUsageAuthWithPluginMock.mockReset(); diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts index 6d4d7d7b602..72c365fdd13 100644 --- a/src/infra/provider-usage.load.plugin.test.ts +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -16,7 +16,7 @@ let loadProviderUsageSummary: typeof import("./provider-usage.load.js").loadProv const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); -describe("provider-usage.load plugin seam", () => { +describe("provider-usage.load plugin boundary", () => { beforeEach(async () => { vi.resetModules(); resolveProviderUsageSnapshotWithPluginMock.mockReset(); diff --git a/src/memory/hybrid.ts b/src/memory/hybrid.ts index 00c5985d78b..209a6bc3f31 100644 --- a/src/memory/hybrid.ts +++ b/src/memory/hybrid.ts @@ -64,7 +64,7 @@ export async function mergeHybridResults(params: { mmr?: Partial; /** Temporal decay configuration for recency-aware scoring */ temporalDecay?: Partial; - /** Test seam for deterministic time-dependent behavior */ + /** Test hook for deterministic time-dependent behavior */ nowMs?: number; }): Promise< Array<{ diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index a4ca46a569c..3505817f534 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const ALLOWED_EXTENSION_PUBLIC_SEAMS = new Set([ +const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set([ "action-runtime.runtime.js", "api.js", "index.js", @@ -320,8 +320,8 @@ function expectOnlyApprovedExtensionSeams(file: string, imports: string[]): void } const basename = normalized.split("/").at(-1) ?? ""; expect( - ALLOWED_EXTENSION_PUBLIC_SEAMS.has(basename), - `${file} should only import approved extension seams, got ${specifier}`, + ALLOWED_EXTENSION_PUBLIC_SURFACES.has(basename), + `${file} should only import approved extension surfaces, got ${specifier}`, ).toBe(true); } } @@ -386,19 +386,19 @@ describe("channel import guardrails", () => { } }); - it("keeps core extension imports limited to approved public seams", () => { + it("keeps core extension imports limited to approved public surfaces", () => { for (const file of collectCoreSourceFiles()) { expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); } }); - it("keeps extension-to-extension imports limited to approved public seams", () => { + it("keeps extension-to-extension imports limited to approved public surfaces", () => { for (const file of collectExtensionSourceFiles()) { expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); } }); - it("keeps internalized extension helper seams behind local api barrels", () => { + it("keeps internalized extension helper surfaces behind local api barrels", () => { for (const extensionId of LOCAL_EXTENSION_API_BARREL_GUARDS) { for (const file of collectExtensionFiles(extensionId)) { const normalized = file.replaceAll("\\", "/"); diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index b05bdf482f7..c6a6d17107f 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -152,7 +152,7 @@ function readExportStatements(path: string): string[] { } describe("runtime api guardrails", () => { - it("keeps runtime api seams on an explicit export allowlist", () => { + it("keeps runtime api surfaces on an explicit export allowlist", () => { const runtimeApiFiles = collectRuntimeApiFiles(); expect(runtimeApiFiles).toEqual( expect.arrayContaining(Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()), diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 427b45458ef..4aa8a088ee3 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -185,7 +185,7 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); - it("exports the public testing seam", () => { + it("exports the public testing surface", () => { expect(typeof testingSdk.removeAckReactionAfterReply).toBe("function"); expect(typeof testingSdk.shouldAckReaction).toBe("function"); }); @@ -284,7 +284,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof googlechatSdk.resolveGoogleChatGroupRequireMention).toBe("function"); }); - it("keeps the Google Chat runtime seam aligned with the public SDK subpath", async () => { + it("keeps the Google Chat runtime surface aligned with the public SDK subpath", async () => { const googlechatRuntimeApi = await import("../../extensions/googlechat/runtime-api.js"); expect(typeof googlechatRuntimeApi.buildChannelConfigSchema).toBe("function"); @@ -338,7 +338,7 @@ describe("plugin-sdk subpath exports", () => { } }); - it("does not advertise trimmed legacy extension helper seams", () => { + it("does not advertise trimmed legacy extension helper surfaces", () => { for (const id of trimmedLegacyExtensionSubpaths) { expect(pluginSdkSubpaths).not.toContain(id); } diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index ad895899dc5..cc1b35a1361 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -122,7 +122,7 @@ describe("buildPluginStatusReport", () => { configSchema: false, }, ], - diagnostics: [{ level: "warn", pluginId: "google", message: "watch this seam" }], + diagnostics: [{ level: "warn", pluginId: "google", message: "watch this surface" }], channels: [], channelSetups: [], providers: [], @@ -175,7 +175,7 @@ describe("buildPluginStatusReport", () => { hasAllowedModelsConfig: true, }); expect(inspect?.diagnostics).toEqual([ - { level: "warn", pluginId: "google", message: "watch this seam" }, + { level: "warn", pluginId: "google", message: "watch this surface" }, ]); }); diff --git a/src/terminal/palette.ts b/src/terminal/palette.ts index 847cda3f49f..e432a2c7f22 100644 --- a/src/terminal/palette.ts +++ b/src/terminal/palette.ts @@ -1,4 +1,4 @@ -// Lobster palette tokens for CLI/UI theming. "lobster seam" == use this palette. +// Lobster palette tokens for CLI/UI theming. Use this palette for all CLI color output. // Keep in sync with docs/cli/index.md (CLI palette section). export const LOBSTER_PALETTE = { accent: "#FF5A2D", From 8193af6d4ebf53c31b8c33fa6214e1eb3a8eb2dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:23:06 -0700 Subject: [PATCH 370/393] Plugins: add LSP server runtime with stdio JSON-RPC client and agent tool bridge --- src/agents/embedded-pi-lsp.ts | 23 ++ src/agents/pi-bundle-lsp-runtime.ts | 374 ++++++++++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/agents/embedded-pi-lsp.ts create mode 100644 src/agents/pi-bundle-lsp-runtime.ts diff --git a/src/agents/embedded-pi-lsp.ts b/src/agents/embedded-pi-lsp.ts new file mode 100644 index 00000000000..b660dd1de15 --- /dev/null +++ b/src/agents/embedded-pi-lsp.ts @@ -0,0 +1,23 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { BundleLspServerConfig } from "../plugins/bundle-lsp.js"; +import { loadEnabledBundleLspConfig } from "../plugins/bundle-lsp.js"; + +export type EmbeddedPiLspConfig = { + lspServers: Record; + diagnostics: Array<{ pluginId: string; message: string }>; +}; + +export function loadEmbeddedPiLspConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; +}): EmbeddedPiLspConfig { + const bundleLsp = loadEnabledBundleLspConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + // User-configured LSP servers could override bundle defaults here in the future. + return { + lspServers: { ...bundleLsp.config.lspServers }, + diagnostics: bundleLsp.diagnostics, + }; +} diff --git a/src/agents/pi-bundle-lsp-runtime.ts b/src/agents/pi-bundle-lsp-runtime.ts new file mode 100644 index 00000000000..c971da811d6 --- /dev/null +++ b/src/agents/pi-bundle-lsp-runtime.ts @@ -0,0 +1,374 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { OpenClawConfig } from "../config/config.js"; +import { logDebug, logWarn } from "../logger.js"; +import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js"; +import { + resolveStdioMcpServerLaunchConfig, + describeStdioMcpServerLaunchConfig, +} from "./mcp-stdio.js"; +import type { AnyAgentTool } from "./tools/common.js"; + +// Minimal LSP JSON-RPC framing over stdio (Content-Length header + JSON body). + +type LspSession = { + serverName: string; + process: ChildProcess; + requestId: number; + pendingRequests: Map void; reject: (e: Error) => void }>; + buffer: string; + initialized: boolean; + capabilities: LspServerCapabilities; +}; + +type LspServerCapabilities = { + hoverProvider?: boolean; + completionProvider?: boolean; + definitionProvider?: boolean; + referencesProvider?: boolean; + diagnosticProvider?: boolean; + [key: string]: unknown; +}; + +export type BundleLspToolRuntime = { + tools: AnyAgentTool[]; + sessions: Array<{ serverName: string; capabilities: LspServerCapabilities }>; + dispose: () => Promise; +}; + +function encodeLspMessage(body: unknown): string { + const json = JSON.stringify(body); + return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`; +} + +function parseLspMessages(buffer: string): { messages: unknown[]; remaining: string } { + const messages: unknown[] = []; + let remaining = buffer; + + while (true) { + const headerEnd = remaining.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + break; + } + + const header = remaining.slice(0, headerEnd); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) { + remaining = remaining.slice(headerEnd + 4); + continue; + } + + const contentLength = parseInt(match[1], 10); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + contentLength; + + if (Buffer.byteLength(remaining.slice(bodyStart), "utf-8") < contentLength) { + break; + } + + try { + const body = remaining.slice(bodyStart, bodyStart + contentLength); + messages.push(JSON.parse(body)); + } catch { + // skip malformed + } + remaining = remaining.slice(bodyEnd); + } + + return { messages, remaining }; +} + +function sendRequest(session: LspSession, method: string, params?: unknown): Promise { + const id = ++session.requestId; + return new Promise((resolve, reject) => { + session.pendingRequests.set(id, { resolve, reject }); + const message = { jsonrpc: "2.0", id, method, params }; + const encoded = encodeLspMessage(message); + session.process.stdin?.write(encoded, "utf-8"); + + // Timeout after 10 seconds + setTimeout(() => { + if (session.pendingRequests.has(id)) { + session.pendingRequests.delete(id); + reject(new Error(`LSP request ${method} timed out`)); + } + }, 10_000); + }); +} + +function handleIncomingData(session: LspSession, chunk: string) { + session.buffer += chunk; + const { messages, remaining } = parseLspMessages(session.buffer); + session.buffer = remaining; + + for (const msg of messages) { + if (typeof msg !== "object" || msg === null) { + continue; + } + const record = msg as Record; + + if ("id" in record && typeof record.id === "number") { + const pending = session.pendingRequests.get(record.id); + if (pending) { + session.pendingRequests.delete(record.id); + if ("error" in record) { + pending.reject(new Error(JSON.stringify(record.error))); + } else { + pending.resolve(record.result); + } + } + } + // Notifications (no id) are logged but not acted on + if ("method" in record && !("id" in record)) { + logDebug(`bundle-lsp:${session.serverName}: notification ${String(record.method)}`); + } + } +} + +async function initializeSession(session: LspSession): Promise { + const result = (await sendRequest(session, "initialize", { + processId: process.pid, + rootUri: null, + capabilities: { + textDocument: { + hover: { contentFormat: ["plaintext", "markdown"] }, + completion: { completionItem: { snippetSupport: false } }, + definition: {}, + references: {}, + }, + }, + })) as { capabilities?: LspServerCapabilities } | undefined; + + // Send initialized notification + session.process.stdin?.write( + encodeLspMessage({ jsonrpc: "2.0", method: "initialized", params: {} }), + "utf-8", + ); + + session.initialized = true; + return result?.capabilities ?? {}; +} + +async function disposeSession(session: LspSession) { + if (session.initialized) { + try { + await sendRequest(session, "shutdown").catch(() => {}); + session.process.stdin?.write( + encodeLspMessage({ jsonrpc: "2.0", method: "exit", params: null }), + "utf-8", + ); + } catch { + // best-effort + } + } + for (const [, pending] of session.pendingRequests) { + pending.reject(new Error("LSP session disposed")); + } + session.pendingRequests.clear(); + session.process.kill(); +} + +function buildLspTools(session: LspSession): AnyAgentTool[] { + const tools: AnyAgentTool[] = []; + const caps = session.capabilities; + const serverLabel = session.serverName; + + if (caps.hoverProvider) { + tools.push({ + name: `lsp_hover_${serverLabel}`, + label: `LSP Hover (${serverLabel})`, + description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.`, + parameters: { + type: "object", + properties: { + uri: { type: "string", description: "File URI (file:///path/to/file)" }, + line: { type: "number", description: "Zero-based line number" }, + character: { type: "number", description: "Zero-based character offset" }, + }, + required: ["uri", "line", "character"], + }, + execute: async (_toolCallId, input) => { + const params = input as { uri: string; line: number; character: number }; + const result = await sendRequest(session, "textDocument/hover", { + textDocument: { uri: params.uri }, + position: { line: params.line, character: params.character }, + }); + return formatLspResult(serverLabel, "hover", result); + }, + }); + } + + if (caps.definitionProvider) { + tools.push({ + name: `lsp_definition_${serverLabel}`, + label: `LSP Go to Definition (${serverLabel})`, + description: `Find the definition of a symbol at a position in a file via the ${serverLabel} language server.`, + parameters: { + type: "object", + properties: { + uri: { type: "string", description: "File URI (file:///path/to/file)" }, + line: { type: "number", description: "Zero-based line number" }, + character: { type: "number", description: "Zero-based character offset" }, + }, + required: ["uri", "line", "character"], + }, + execute: async (_toolCallId, input) => { + const params = input as { uri: string; line: number; character: number }; + const result = await sendRequest(session, "textDocument/definition", { + textDocument: { uri: params.uri }, + position: { line: params.line, character: params.character }, + }); + return formatLspResult(serverLabel, "definition", result); + }, + }); + } + + if (caps.referencesProvider) { + tools.push({ + name: `lsp_references_${serverLabel}`, + label: `LSP Find References (${serverLabel})`, + description: `Find all references to a symbol at a position in a file via the ${serverLabel} language server.`, + parameters: { + type: "object", + properties: { + uri: { type: "string", description: "File URI (file:///path/to/file)" }, + line: { type: "number", description: "Zero-based line number" }, + character: { type: "number", description: "Zero-based character offset" }, + includeDeclaration: { + type: "boolean", + description: "Include the declaration in results", + }, + }, + required: ["uri", "line", "character"], + }, + execute: async (_toolCallId, input) => { + const params = input as { + uri: string; + line: number; + character: number; + includeDeclaration?: boolean; + }; + const result = await sendRequest(session, "textDocument/references", { + textDocument: { uri: params.uri }, + position: { line: params.line, character: params.character }, + context: { includeDeclaration: params.includeDeclaration ?? true }, + }); + return formatLspResult(serverLabel, "references", result); + }, + }); + } + + return tools; +} + +function formatLspResult( + serverName: string, + method: string, + result: unknown, +): AgentToolResult { + const text = + result !== null && result !== undefined + ? JSON.stringify(result, null, 2) + : `No ${method} result from ${serverName}`; + return { + content: [{ type: "text", text }], + details: { lspServer: serverName, lspMethod: method }, + }; +} + +export async function createBundleLspToolRuntime(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + reservedToolNames?: Iterable; +}): Promise { + const loaded = loadEmbeddedPiLspConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + for (const diagnostic of loaded.diagnostics) { + logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`); + } + + const reservedNames = new Set( + Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean), + ); + const sessions: LspSession[] = []; + const tools: AnyAgentTool[] = []; + + try { + for (const [serverName, rawServer] of Object.entries(loaded.lspServers)) { + const launch = resolveStdioMcpServerLaunchConfig(rawServer); + if (!launch.ok) { + logWarn(`bundle-lsp: skipped server "${serverName}" because ${launch.reason}.`); + continue; + } + const launchConfig = launch.config; + + try { + const child = spawn(launchConfig.command, launchConfig.args ?? [], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, ...launchConfig.env }, + cwd: launchConfig.cwd, + }); + + const session: LspSession = { + serverName, + process: child, + requestId: 0, + pendingRequests: new Map(), + buffer: "", + initialized: false, + capabilities: {}, + }; + + child.stdout?.setEncoding("utf-8"); + child.stdout?.on("data", (chunk: string) => handleIncomingData(session, chunk)); + child.stderr?.setEncoding("utf-8"); + child.stderr?.on("data", (chunk: string) => { + for (const line of chunk.split(/\r?\n/).filter(Boolean)) { + logDebug(`bundle-lsp:${serverName}: ${line.trim()}`); + } + }); + + const capabilities = await initializeSession(session); + session.capabilities = capabilities; + sessions.push(session); + + const serverTools = buildLspTools(session); + for (const tool of serverTools) { + const normalizedName = tool.name.trim().toLowerCase(); + if (reservedNames.has(normalizedName)) { + logWarn( + `bundle-lsp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`, + ); + continue; + } + reservedNames.add(normalizedName); + tools.push(tool); + } + + logDebug( + `bundle-lsp: started "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}) with ${serverTools.length} tools`, + ); + } catch (error) { + logWarn( + `bundle-lsp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`, + ); + } + } + + return { + tools, + sessions: sessions.map((s) => ({ + serverName: s.serverName, + capabilities: s.capabilities, + })), + dispose: async () => { + await Promise.allSettled(sessions.map((session) => disposeSession(session))); + }, + }; + } catch (error) { + await Promise.allSettled(sessions.map((session) => disposeSession(session))); + throw error; + } +} From 80e681a60cc99894607e298bdabdd2c8eb79391a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:23:13 -0700 Subject: [PATCH 371/393] Plugins: integrate LSP tool runtime into Pi embedded runner --- src/agents/pi-embedded-runner/compact.ts | 21 +++++++++++++++---- src/agents/pi-embedded-runner/run/attempt.ts | 22 ++++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 7dba07dd2cb..587a0e9214d 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -53,6 +53,7 @@ import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { createConfiguredOllamaStreamFn } from "../ollama-stream.js"; import { resolveOwnerDisplaySetting } from "../owner-display.js"; +import { createBundleLspToolRuntime } from "../pi-bundle-lsp-runtime.js"; import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js"; import { ensureSessionHeader, @@ -603,10 +604,21 @@ export async function compactEmbeddedPiSessionDirect( reservedToolNames: tools.map((tool) => tool.name), }) : undefined; - const effectiveTools = - bundleMcpRuntime && bundleMcpRuntime.tools.length > 0 - ? [...tools, ...bundleMcpRuntime.tools] - : tools; + const bundleLspRuntime = toolsEnabled + ? await createBundleLspToolRuntime({ + workspaceDir: effectiveWorkspace, + cfg: params.config, + reservedToolNames: [ + ...tools.map((tool) => tool.name), + ...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []), + ], + }) + : undefined; + const effectiveTools = [ + ...tools, + ...(bundleMcpRuntime?.tools ?? []), + ...(bundleLspRuntime?.tools ?? []), + ]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools }); logToolSchemasForGoogle({ tools: effectiveTools, provider }); const machineName = await getMachineDisplayName(); @@ -1092,6 +1104,7 @@ export async function compactEmbeddedPiSessionDirect( }); session.dispose(); await bundleMcpRuntime?.dispose(); + await bundleLspRuntime?.dispose(); } } finally { await sessionLock.release(); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 69d8212adfa..3c77d877e28 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -61,6 +61,7 @@ import { supportsModelTools } from "../../model-tool-support.js"; import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js"; import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; +import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js"; import { createBundleMcpToolRuntime } from "../../pi-bundle-mcp-tools.js"; import { downgradeOpenAIFunctionCallReasoningPairs, @@ -1570,10 +1571,22 @@ export async function runEmbeddedAttempt( ], }) : undefined; - const effectiveTools = - bundleMcpRuntime && bundleMcpRuntime.tools.length > 0 - ? [...tools, ...bundleMcpRuntime.tools] - : tools; + const bundleLspRuntime = toolsEnabled + ? await createBundleLspToolRuntime({ + workspaceDir: effectiveWorkspace, + cfg: params.config, + reservedToolNames: [ + ...tools.map((tool) => tool.name), + ...(clientTools?.map((tool) => tool.function.name) ?? []), + ...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []), + ], + }) + : undefined; + const effectiveTools = [ + ...tools, + ...(bundleMcpRuntime?.tools ?? []), + ...(bundleLspRuntime?.tools ?? []), + ]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools, clientTools, @@ -2913,6 +2926,7 @@ export async function runEmbeddedAttempt( session?.dispose(); releaseWsSession(params.sessionId); await bundleMcpRuntime?.dispose(); + await bundleLspRuntime?.dispose(); await sessionLock.release(); } } finally { From e6c6aaa11b1fff3dcdf9830ad18b4afce301202c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:25:53 -0700 Subject: [PATCH 372/393] Perf: skip MCP/LSP runtime spawning when no servers are configured --- src/agents/pi-bundle-lsp-runtime.ts | 4 ++++ src/agents/pi-bundle-mcp-tools.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/agents/pi-bundle-lsp-runtime.ts b/src/agents/pi-bundle-lsp-runtime.ts index c971da811d6..cecc95bb475 100644 --- a/src/agents/pi-bundle-lsp-runtime.ts +++ b/src/agents/pi-bundle-lsp-runtime.ts @@ -288,6 +288,10 @@ export async function createBundleLspToolRuntime(params: { for (const diagnostic of loaded.diagnostics) { logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`); } + // Skip spawning when no LSP servers are configured. + if (Object.keys(loaded.lspServers).length === 0) { + return { tools: [], sessions: [], dispose: async () => {} }; + } const reservedNames = new Set( Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean), diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index 159cd8bfe12..bbe3aa200ae 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -131,6 +131,10 @@ export async function createBundleMcpToolRuntime(params: { for (const diagnostic of loaded.diagnostics) { logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`); } + // Skip spawning when no MCP servers are configured. + if (Object.keys(loaded.mcpServers).length === 0) { + return { tools: [], dispose: async () => {} }; + } const reservedNames = new Set( Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean), From fbd88e2c8f0018070f97954ff085012e4037833b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:30:01 -0700 Subject: [PATCH 373/393] Main recovery: restore formatter and contract checks (#49570) * Extensions: fix oxfmt drift on main * Plugins: restore runtime barrel exports on main * Config: restore web search compatibility types * Telegram: align test harness with reply runtime * Plugin SDK: fix channel config accessor generics * CLI: remove redundant search provider casts * Tests: restore main typecheck coverage * Lobster: fix test import formatting * Extensions: route bundled seams through plugin-sdk * Tests: use extension env helper for xai * Image generation: fix main oxfmt drift * Config: restore latest main compatibility checks * Plugin SDK: align guardrail tests with lint * Telegram: type native command skill mock --- .../acpx/src/runtime-internals/events.ts | 2 +- extensions/amazon-bedrock/index.test.ts | 2 +- .../brave/src/brave-web-search-provider.ts | 4 +- extensions/copilot-proxy/runtime-api.ts | 2 +- extensions/device-pair/api.ts | 2 +- extensions/diagnostics-otel/api.ts | 2 +- extensions/diffs/api.ts | 2 +- extensions/discord/src/directory-config.ts | 13 +-- extensions/discord/src/runtime-api.ts | 1 + extensions/google/runtime-api.ts | 2 +- extensions/imessage/runtime-api.ts | 22 ++--- extensions/irc/src/runtime-api.ts | 54 +---------- extensions/line/src/channel.ts | 1 + extensions/llm-task/api.ts | 2 +- extensions/lobster/runtime-api.ts | 2 +- extensions/lobster/src/lobster-tool.test.ts | 2 +- extensions/memory-lancedb/api.ts | 2 +- extensions/open-prose/runtime-api.ts | 2 +- extensions/phone-control/runtime-api.ts | 2 +- extensions/qwen-portal-auth/index.ts | 4 +- extensions/qwen-portal-auth/runtime-api.ts | 2 +- extensions/signal/src/channel.setup.ts | 2 +- extensions/signal/src/channel.ts | 20 ++-- extensions/signal/src/shared.ts | 12 +-- extensions/slack/src/channel.ts | 2 + extensions/slack/src/directory-config.ts | 13 +-- extensions/slack/src/runtime-api.ts | 33 +++---- extensions/talk-voice/api.ts | 2 +- extensions/telegram/runtime-api.ts | 68 +++++++++---- .../bot-native-commands.menu-test-support.ts | 13 ++- .../telegram/src/bot-native-commands.test.ts | 8 +- .../bot.create-telegram-bot.test-harness.ts | 97 ++++++++++--------- .../src/bot.create-telegram-bot.test.ts | 4 +- .../telegram/src/bot.fetch-abort.test.ts | 4 +- .../telegram/src/bot.media.e2e-harness.ts | 19 +++- .../telegram/src/bot.media.test-utils.ts | 6 +- extensions/telegram/src/bot.test.ts | 4 +- extensions/telegram/src/directory-config.ts | 13 +-- extensions/thread-ownership/api.ts | 2 +- extensions/twitch/api.ts | 1 - extensions/voice-call/api.ts | 2 +- extensions/whatsapp/src/runtime-api.ts | 20 ++-- extensions/xai/web-search.test.ts | 2 +- extensions/zalo/src/actions.ts | 2 +- extensions/zalo/src/channel.runtime.ts | 15 +-- extensions/zalo/src/channel.ts | 16 +-- extensions/zalo/src/config-schema.ts | 2 +- extensions/zalo/src/monitor.ts | 38 ++++---- extensions/zalo/src/monitor.webhook.ts | 6 +- extensions/zalo/src/send.ts | 2 +- extensions/zalo/src/token.ts | 2 +- .../openclaw-tools.image-generation.test.ts | 12 ++- .../extra-params.google.test.ts | 2 +- ...e-aliases-schemas-without-dropping.test.ts | 2 +- .../pi-tools.model-provider-collision.test.ts | 5 +- src/agents/tools/image-generate-tool.test.ts | 9 +- src/agents/tools/image-generate-tool.ts | 19 +++- src/agents/xai.live.test.ts | 2 +- src/channels/plugins/setup-wizard-helpers.ts | 9 +- src/commands/config-validation.test.ts | 3 +- src/commands/configure.wizard.ts | 10 +- .../doctor-legacy-config.migrations.test.ts | 2 + src/commands/doctor-legacy-config.ts | 19 +++- src/config/types.tools.ts | 22 +++++ src/config/zod-schema.agent-runtime.ts | 51 ++++++++++ src/image-generation/providers/fal.ts | 17 +++- src/infra/outbound/outbound-session.test.ts | 2 +- src/infra/outbound/outbound.test.ts | 2 +- src/plugin-sdk/acp-runtime.ts | 14 ++- src/plugin-sdk/channel-config-helpers.ts | 19 ++-- src/plugin-sdk/core.ts | 2 + .../package-contract-guardrails.test.ts | 8 +- src/plugin-sdk/telegram.ts | 5 +- src/plugins/contracts/shape.contract.test.ts | 1 + src/secrets/runtime-web-tools.test.ts | 5 +- src/web-search/runtime.test.ts | 1 + src/web-search/runtime.ts | 1 + ui/src/ui/views/config.browser.test.ts | 2 + 78 files changed, 476 insertions(+), 327 deletions(-) diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts index 3bbfed68495..ac5f91acd5a 100644 --- a/extensions/acpx/src/runtime-internals/events.ts +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -1,4 +1,4 @@ -import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../runtime-api.js"; +import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../runtime-api.js"; import { asOptionalBoolean, asOptionalString, diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 87ce6f6dcd2..049ebc45810 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -25,7 +25,7 @@ describe("amazon-bedrock provider plugin", () => { const wrapped = provider.wrapStreamFn?.({ provider: "amazon-bedrock", modelId: "amazon.nova-micro-v1:0", - streamFn: (_model, _context, options) => options, + streamFn: (_model: unknown, _context: unknown, options: Record) => options, } as never); expect( diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 370fe77e854..3e1a6f1533a 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -132,8 +132,8 @@ function resolveBraveConfig( : ({ apiKey: (searchConfig as Record | undefined)?.apiKey } as BraveConfig); } -function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { - return brave.mode === "llm-context" ? "llm-context" : "web"; +function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" { + return brave?.mode === "llm-context" ? "llm-context" : "web"; } function resolveBraveApiKey( diff --git a/extensions/copilot-proxy/runtime-api.ts b/extensions/copilot-proxy/runtime-api.ts index 9f59e519281..849136c6efb 100644 --- a/extensions/copilot-proxy/runtime-api.ts +++ b/extensions/copilot-proxy/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/copilot-proxy.js"; +export * from "openclaw/plugin-sdk/copilot-proxy"; diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 137cd4b89ba..299ad90f05d 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/device-pair.js"; +export * from "openclaw/plugin-sdk/device-pair"; diff --git a/extensions/diagnostics-otel/api.ts b/extensions/diagnostics-otel/api.ts index 077ad45965f..01d7aed8989 100644 --- a/extensions/diagnostics-otel/api.ts +++ b/extensions/diagnostics-otel/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/diagnostics-otel.js"; +export * from "openclaw/plugin-sdk/diagnostics-otel"; diff --git a/extensions/diffs/api.ts b/extensions/diffs/api.ts index a200daea1fd..e6fbaf9022a 100644 --- a/extensions/diffs/api.ts +++ b/extensions/diffs/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/diffs.js"; +export * from "openclaw/plugin-sdk/diffs"; diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index af921c25165..eef67a25200 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,18 +1,16 @@ import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, - inspectReadOnlyChannelAccount, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedDiscordAccount } from "../api.js"; +import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js"; export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedDiscordAccount | null; + }) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } @@ -34,11 +32,10 @@ export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfi } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedDiscordAccount | null; + }) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 2aadbf90b9a..32fbf43e5e5 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -40,6 +40,7 @@ export type { ChannelMessageActionAdapter, ChannelMessageActionName, } from "openclaw/plugin-sdk/channel-runtime"; +export type { DiscordConfig } from "openclaw/plugin-sdk/discord"; export { assertMediaNotDataUrl, parseAvailableTags, diff --git a/extensions/google/runtime-api.ts b/extensions/google/runtime-api.ts index 3eaab2b0faf..7deb5b38f92 100644 --- a/extensions/google/runtime-api.ts +++ b/extensions/google/runtime-api.ts @@ -1 +1 @@ -export { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/google"; +export * from "openclaw/plugin-sdk/google"; diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts index 6cd9966f193..aa6d55c75e5 100644 --- a/extensions/imessage/runtime-api.ts +++ b/extensions/imessage/runtime-api.ts @@ -1,23 +1,19 @@ -export type { IMessageAccountConfig } from "../../src/config/types.imessage.js"; -export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js"; export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, - getChatChannelMeta, -} from "../../src/plugin-sdk/channel-plugin-common.js"; -export { + collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, -} from "../../src/plugin-sdk/channel-config-helpers.js"; -export { collectStatusIssuesFromLastError } from "../../src/plugin-sdk/status-helpers.js"; -export { resolveChannelMediaMaxBytes } from "../../src/channels/plugins/media-limits.js"; -export { + getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, -} from "../../src/channels/plugins/normalize/imessage.js"; -export { IMessageConfigSchema } from "../../src/config/zod-schema.providers-core.js"; + resolveChannelMediaMaxBytes, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + IMessageConfigSchema, + type ChannelPlugin, + type IMessageAccountConfig, +} from "openclaw/plugin-sdk/imessage"; export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index eebfe798ede..93214aeda45 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -1,53 +1 @@ -export { - addWildcardAllowFrom, - buildBaseAccountStatusSnapshot, - buildBaseChannelStatusSummary, - buildChannelConfigSchema, - createAccountListHelpers, - createAccountStatusSink, - createLoggerBackedRuntime, - createNormalizedOutboundDeliverer, - createReplyPrefixOptions, - createScopedPairingAccess, - dispatchInboundReplyWithBase, - emptyPluginConfigSchema, - formatDocsLink, - formatPairingApproveHint, - formatTextWithAttachmentLinks, - getChatChannelMeta, - GROUP_POLICY_BLOCKED_LABEL, - isDangerousNameMatchingEnabled, - issuePairingChallenge, - logInboundDrop, - normalizeResolvedSecretInputString, - parseOptionalDelimitedEntries, - PAIRING_APPROVED_MESSAGE, - patchScopedAccountConfig, - readStoreAllowFromForDmPolicy, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveControlCommandGate, - resolveDefaultGroupPolicy, - resolveEffectiveAllowFromLists, - resolveOutboundMediaUrls, - runPassiveAccountLifecycle, - setAccountEnabledInConfigSection, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - ToolPolicySchema, - warnMissingProviderGroupPolicyFallbackOnce, - type BaseProbeResult, - type BlockStreamingCoalesceConfig, - type ChannelPlugin, - type DmConfig, - type DmPolicy, - type GroupPolicy, - type GroupToolPolicyBySenderConfig, - type GroupToolPolicyConfig, - type MarkdownConfig, - type OpenClawConfig, - type OpenClawPluginApi, - type OutboundReplyPayload, - type PluginRuntime, - type RuntimeEnv, - type WizardPrompter, -} from "openclaw/plugin-sdk/irc"; +export * from "openclaw/plugin-sdk/irc"; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index cd3fab965cc..33f2b7aa247 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -12,6 +12,7 @@ import { type ChannelStatusIssue, type LineConfig, type LineChannelData, + type OpenClawConfig, type ResolvedLineAccount, } from "../api.js"; import { lineConfigAdapter } from "./config-adapter.js"; diff --git a/extensions/llm-task/api.ts b/extensions/llm-task/api.ts index 25e5e13d5ca..8eebdd06e0b 100644 --- a/extensions/llm-task/api.ts +++ b/extensions/llm-task/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/llm-task.js"; +export * from "openclaw/plugin-sdk/llm-task"; diff --git a/extensions/lobster/runtime-api.ts b/extensions/lobster/runtime-api.ts index 24898e04cf5..7ab2351b77d 100644 --- a/extensions/lobster/runtime-api.ts +++ b/extensions/lobster/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/lobster.js"; +export * from "openclaw/plugin-sdk/lobster"; diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 8c010e20f11..778cb695d88 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -3,8 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js"; import { createWindowsCmdShimFixture, restorePlatformPathEnv, diff --git a/extensions/memory-lancedb/api.ts b/extensions/memory-lancedb/api.ts index ce6e02cf02f..c1bd12dd4b7 100644 --- a/extensions/memory-lancedb/api.ts +++ b/extensions/memory-lancedb/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/memory-lancedb.js"; +export * from "openclaw/plugin-sdk/memory-lancedb"; diff --git a/extensions/open-prose/runtime-api.ts b/extensions/open-prose/runtime-api.ts index 1a7ce98ffef..1601f81be1f 100644 --- a/extensions/open-prose/runtime-api.ts +++ b/extensions/open-prose/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/open-prose.js"; +export * from "openclaw/plugin-sdk/open-prose"; diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts index c113b9802be..2e9e0adeba2 100644 --- a/extensions/phone-control/runtime-api.ts +++ b/extensions/phone-control/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/phone-control.js"; +export * from "openclaw/plugin-sdk/phone-control"; diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 384f58f4845..c5789e6cc08 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -1,5 +1,7 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; +import { loginQwenPortalOAuth } from "./oauth.js"; +import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; import { buildOauthProviderAuthResult, definePluginEntry, @@ -7,8 +9,6 @@ import { type ProviderAuthContext, type ProviderCatalogContext, } from "./runtime-api.js"; -import { loginQwenPortalOAuth } from "./oauth.js"; -import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; const PROVIDER_ID = "qwen-portal"; const PROVIDER_LABEL = "Qwen"; diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts index ccd9abae569..232a2886110 100644 --- a/extensions/qwen-portal-auth/runtime-api.ts +++ b/extensions/qwen-portal-auth/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/qwen-portal-auth.js"; +export * from "openclaw/plugin-sdk/qwen-portal-auth"; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index df5337a4761..d51edcaca10 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,6 +1,6 @@ import { type ResolvedSignalAccount } from "./accounts.js"; -import { signalSetupAdapter } from "./setup-core.js"; import { type ChannelPlugin } from "./runtime-api.js"; +import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 85aaadbd2c1..1879c85a7b0 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -4,6 +4,16 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; +import { markdownToSignalTextChunks } from "./format.js"; +import { + looksLikeUuid, + resolveSignalPeerId, + resolveSignalRecipient, + resolveSignalSender, +} from "./identity.js"; +import { signalMessageActions } from "./message-actions.js"; +import type { SignalProbe } from "./probe.js"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, @@ -17,16 +27,6 @@ import { resolveChannelMediaMaxBytes, type ChannelPlugin, } from "./runtime-api.js"; -import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; -import { markdownToSignalTextChunks } from "./format.js"; -import { - looksLikeUuid, - resolveSignalPeerId, - resolveSignalRecipient, - resolveSignalSender, -} from "./identity.js"; -import { signalMessageActions } from "./message-actions.js"; -import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; import { diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 1a0579e0236..1622dc207e4 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -4,6 +4,12 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, + type ResolvedSignalAccount, +} from "./accounts.js"; import { buildChannelConfigSchema, getChatChannelMeta, @@ -11,12 +17,6 @@ import { SignalConfigSchema, type ChannelPlugin, } from "./runtime-api.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, - type ResolvedSignalAccount, -} from "./accounts.js"; import { createSignalSetupWizardProxy } from "./setup-core.js"; export const SIGNAL_CHANNEL = "signal" as const; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 417f3b9a3b4..cbb86a1dff1 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -29,6 +29,8 @@ import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index a74b2e4079d..8d7d4604ea1 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,20 +1,18 @@ import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, - inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedSlackAccount } from "../api.js"; +import { inspectSlackAccount, type InspectedSlackAccount } from "../api.js"; import { parseSlackTarget } from "./targets.js"; export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", + const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedSlackAccount | null; + }) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } @@ -40,11 +38,10 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", + const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedSlackAccount | null; + }) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index 4988fa5d4f4..5dac68be756 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -1,34 +1,29 @@ -export type { OpenClawConfig } from "../../../src/config/config.js"; -export type { SlackAccountConfig } from "../../../src/config/types.slack.js"; -export type { ChannelPlugin } from "../../../src/channels/plugins/types.js"; - export { + buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, - buildChannelConfigSchema, - getChatChannelMeta, + looksLikeSlackTargetId, + normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, -} from "../../../src/plugin-sdk/channel-plugin-common.js"; -export { buildComputedAccountStatusSnapshot } from "../../../src/plugin-sdk/status-helpers.js"; + projectCredentialSnapshotFields, + resolveConfiguredFromRequiredCredentialStatuses, + type ChannelPlugin, + type OpenClawConfig, + type SlackAccountConfig, +} from "openclaw/plugin-sdk/slack"; export { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, } from "./directory-config.js"; export { - looksLikeSlackTargetId, - normalizeSlackMessagingTarget, -} from "../../../src/channels/plugins/normalize/slack.js"; -export { - projectCredentialSnapshotFields, - resolveConfiguredFromRequiredCredentialStatuses, -} from "../../../src/channels/account-snapshot-fields.js"; -export { SlackConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -export { + buildChannelConfigSchema, + getChatChannelMeta, createActionGate, imageResultFromFile, jsonResult, readNumberParam, readReactionParams, readStringParam, -} from "../../../src/agents/tools/common.js"; -export { withNormalizedTimestamp } from "../../../src/agents/date-time.js"; + SlackConfigSchema, + withNormalizedTimestamp, +} from "openclaw/plugin-sdk/slack-core"; export { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; diff --git a/extensions/talk-voice/api.ts b/extensions/talk-voice/api.ts index 5f50f1a5247..a5ae821e944 100644 --- a/extensions/talk-voice/api.ts +++ b/extensions/talk-voice/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/talk-voice.js"; +export * from "openclaw/plugin-sdk/talk-voice"; diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index b645e653834..c069a35e40e 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -1,16 +1,18 @@ export type { + ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, - TelegramActionConfig, -} from "../../src/plugin-sdk/telegram-core.js"; -export type { ChannelMessageActionAdapter } from "../../src/channels/plugins/types.js"; -export type { TelegramAccountConfig, TelegramNetworkConfig } from "../../src/config/types.js"; -export type { OpenClawPluginApi, + PluginRuntime, + TelegramAccountConfig, + TelegramActionConfig, + TelegramNetworkConfig, +} from "openclaw/plugin-sdk/telegram"; +export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger, -} from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/core"; export type { AcpRuntime, AcpRuntimeCapabilities, @@ -20,12 +22,22 @@ export type { AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, + AcpRuntimeErrorCode, AcpSessionUpdateTag, -} from "../../src/acp/runtime/types.js"; -export type { AcpRuntimeErrorCode } from "../../src/acp/runtime/errors.js"; -export { AcpRuntimeError } from "../../src/acp/runtime/errors.js"; +} from "openclaw/plugin-sdk/acp-runtime"; +export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../src/routing/session-key.js"; +export { + buildTokenChannelStatusSummary, + clearAccountEntryFields, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + parseTelegramTopicConversation, + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, + resolveTelegramPollVisibility, +} from "openclaw/plugin-sdk/telegram"; export { buildChannelConfigSchema, getChatChannelMeta, @@ -37,13 +49,31 @@ export { readStringParam, resolvePollMaxSelections, TelegramConfigSchema, -} from "../../src/plugin-sdk/telegram-core.js"; -export { parseTelegramTopicConversation } from "../../src/acp/conversation-id.js"; -export { clearAccountEntryFields } from "../../src/channels/plugins/config-helpers.js"; -export { buildTokenChannelStatusSummary } from "../../src/plugin-sdk/status-helpers.js"; +} from "openclaw/plugin-sdk/telegram-core"; +export type { TelegramProbe } from "./src/probe.js"; +export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js"; +export { telegramMessageActions } from "./src/channel-actions.js"; +export { monitorTelegramProvider } from "./src/monitor.js"; +export { probeTelegram } from "./src/probe.js"; export { - projectCredentialSnapshotFields, - resolveConfiguredFromCredentialStatuses, -} from "../../src/channels/account-snapshot-fields.js"; -export { resolveTelegramPollVisibility } from "../../src/poll-params.js"; -export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js"; + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageReplyMarkupTelegram, + editMessageTelegram, + pinMessageTelegram, + reactMessageTelegram, + renameForumTopicTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, + sendTypingTelegram, + unpinMessageTelegram, +} from "./src/send.js"; +export { + createTelegramThreadBindingManager, + getTelegramThreadBindingManager, + setTelegramThreadBindingIdleTimeoutBySessionKey, + setTelegramThreadBindingMaxAgeBySessionKey, +} from "./src/thread-bindings.js"; +export { resolveTelegramToken } from "./src/token.js"; diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index 5d0f90257e5..8b68368d84f 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -1,5 +1,6 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { expect, vi } from "vitest"; +import type { SkillCommandSpec } from "../../../src/agents/skills.js"; import type { OpenClawConfig } from "../runtime-api.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import { @@ -8,6 +9,12 @@ import { type NativeCommandTestParams as RegisterTelegramNativeCommandsParams, } from "./bot-native-commands.fixture-test-support.js"; +const EMPTY_REPLY_COUNTS = { + block: 0, + final: 0, + tool: 0, +} as const; + type RegisteredCommand = { command: string; description: string; @@ -21,7 +28,9 @@ type CreateCommandBotResult = { }; const skillCommandMocks = vi.hoisted(() => ({ - listSkillCommandsForAgents: vi.fn(() => []), + listSkillCommandsForAgents: vi.fn< + (params: { cfg: OpenClawConfig; agentIds?: string[] }) => SkillCommandSpec[] + >(() => []), })); const deliveryMocks = vi.hoisted(() => ({ @@ -86,7 +95,7 @@ export function createNativeCommandTestParams( enqueueSystemEvent: vi.fn(), dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => ({ queuedFinal: false, - counts: {}, + counts: EMPTY_REPLY_COUNTS, })), listSkillCommandsForAgents, wasSentByBot: vi.fn(() => false), diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index f2737d98f89..043baf9b2b6 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -37,6 +37,12 @@ import { waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; +const EMPTY_REPLY_COUNTS = { + block: 0, + final: 0, + tool: 0, +} as const; + function createNativeCommandTestParams( cfg: OpenClawConfig, params: Partial[0]> = {}, @@ -48,7 +54,7 @@ function createNativeCommandTestParams( enqueueSystemEvent: vi.fn(), dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => ({ queuedFinal: false, - counts: {}, + counts: EMPTY_REPLY_COUNTS, })), listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents, wasSentByBot: vi.fn(() => false), diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 648638bd23b..f2f8f89ce63 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -4,23 +4,21 @@ import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; +import type { TelegramBotDeps } from "./bot-deps.js"; -type AnyMock = MockFn<(...args: unknown[]) => unknown>; -type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; +type AnyMock = ReturnType; +type AnyAsyncMock = ReturnType; type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< ReturnType >; -type DispatchReplyHarnessParams = { - ctx: MsgContext; - replyOptions?: GetReplyOptions; - dispatcherOptions?: { - typingCallbacks?: { - start?: () => void | Promise; - }; - deliver?: (payload: ReplyPayload, info: { kind: "final" }) => void | Promise; - }; +type DispatchReplyHarnessParams = Parameters[0]; + +const EMPTY_REPLY_COUNTS: DispatchReplyWithBufferedBlockDispatcherResult["counts"] = { + block: 0, + final: 0, + tool: 0, }; const { sessionStorePath } = vi.hoisted(() => ({ @@ -39,12 +37,14 @@ vi.doMock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); -const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({ - loadConfig: vi.fn(() => ({})), -})); -const { resolveStorePathMock } = vi.hoisted((): { resolveStorePathMock: AnyMock } => ({ - resolveStorePathMock: vi.fn((storePath?: string) => storePath ?? sessionStorePath), +const { loadConfig } = vi.hoisted((): { loadConfig: MockFn<() => OpenClawConfig> } => ({ + loadConfig: vi.fn(() => ({}) as OpenClawConfig), })); +const { resolveStorePathMock } = vi.hoisted( + (): { resolveStorePathMock: MockFn } => ({ + resolveStorePathMock: vi.fn((storePath?: string) => storePath ?? sessionStorePath), + }), +); export function getLoadConfigMock(): AnyMock { return loadConfig; @@ -67,7 +67,7 @@ vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted( (): { - readChannelAllowFromStore: AnyAsyncMock; + readChannelAllowFromStore: MockFn; upsertChannelPairingRequest: AnyAsyncMock; } => ({ readChannelAllowFromStore: vi.fn(async () => [] as string[]), @@ -111,9 +111,9 @@ const skillCommandsHoisted = vi.hoisted(() => ({ async (params: DispatchReplyHarnessParams) => { const result: DispatchReplyWithBufferedBlockDispatcherResult = { queuedFinal: false, - counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + counts: EMPTY_REPLY_COUNTS, }; - await params.dispatcherOptions?.typingCallbacks?.start?.(); + await params.dispatcherOptions?.typingCallbacks?.onReplyStart?.(); const reply = await skillCommandsHoisted.replySpy(params.ctx, params.replyOptions); const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; for (const payload of payloads) { @@ -141,9 +141,10 @@ vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { }); const systemEventsHoisted = vi.hoisted(() => ({ - enqueueSystemEventSpy: vi.fn(), + enqueueSystemEventSpy: vi.fn(() => false), })); -export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; +export const enqueueSystemEventSpy: MockFn = + systemEventsHoisted.enqueueSystemEventSpy; vi.doMock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -173,7 +174,7 @@ const grammySpies = vi.hoisted(() => ({ onSpy: vi.fn() as AnyMock, stopSpy: vi.fn() as AnyMock, commandSpy: vi.fn() as AnyMock, - botCtorSpy: vi.fn() as AnyMock, + botCtorSpy: vi.fn((_: string, __?: { client?: { fetch?: typeof fetch } }) => undefined), answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock, sendChatActionSpy: vi.fn() as AnyMock, editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, @@ -191,26 +192,26 @@ const grammySpies = vi.hoisted(() => ({ getFileSpy: vi.fn(async () => ({ file_path: "media/file.jpg" })) as AnyAsyncMock, })); -export const { - useSpy, - middlewareUseSpy, - onSpy, - stopSpy, - commandSpy, - botCtorSpy, - answerCallbackQuerySpy, - sendChatActionSpy, - editMessageTextSpy, - editMessageReplyMarkupSpy, - sendMessageDraftSpy, - setMessageReactionSpy, - setMyCommandsSpy, - getMeSpy, - sendMessageSpy, - sendAnimationSpy, - sendPhotoSpy, - getFileSpy, -} = grammySpies; +export const useSpy: MockFn<(arg: unknown) => void> = grammySpies.useSpy; +export const middlewareUseSpy: AnyMock = grammySpies.middlewareUseSpy; +export const onSpy: AnyMock = grammySpies.onSpy; +export const stopSpy: AnyMock = grammySpies.stopSpy; +export const commandSpy: AnyMock = grammySpies.commandSpy; +export const botCtorSpy: MockFn< + (token: string, options?: { client?: { fetch?: typeof fetch } }) => void +> = grammySpies.botCtorSpy; +export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQuerySpy; +export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy; +export const editMessageTextSpy: AnyAsyncMock = grammySpies.editMessageTextSpy; +export const editMessageReplyMarkupSpy: AnyAsyncMock = grammySpies.editMessageReplyMarkupSpy; +export const sendMessageDraftSpy: AnyAsyncMock = grammySpies.sendMessageDraftSpy; +export const setMessageReactionSpy: AnyAsyncMock = grammySpies.setMessageReactionSpy; +export const setMyCommandsSpy: AnyAsyncMock = grammySpies.setMyCommandsSpy; +export const getMeSpy: AnyAsyncMock = grammySpies.getMeSpy; +export const sendMessageSpy: AnyAsyncMock = grammySpies.sendMessageSpy; +export const sendAnimationSpy: AnyAsyncMock = grammySpies.sendAnimationSpy; +export const sendPhotoSpy: AnyAsyncMock = grammySpies.sendPhotoSpy; +export const getFileSpy: AnyAsyncMock = grammySpies.getFileSpy; const runnerHoisted = vi.hoisted(() => ({ sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise) => { @@ -224,7 +225,11 @@ const runnerHoisted = vi.hoisted(() => ({ export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy; export let sequentializeKey: ((ctx: unknown) => string) | undefined; export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy; -export const telegramBotRuntimeForTest = { +export const telegramBotRuntimeForTest: { + Bot: new (token: string, options?: { client?: { fetch?: typeof fetch } }) => unknown; + sequentialize: (keyFn: (ctx: unknown) => string) => unknown; + apiThrottler: () => unknown; +} = { Bot: class { api = { config: { use: grammySpies.useSpy }, @@ -259,7 +264,7 @@ export const telegramBotRuntimeForTest = { }, apiThrottler: () => runnerHoisted.throttlerSpy(), }; -export const telegramBotDepsForTest = { +export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig, resolveStorePath: resolveStorePathMock, readChannelAllowFromStore, @@ -365,9 +370,9 @@ beforeEach(() => { async (params: DispatchReplyHarnessParams) => { const result: DispatchReplyWithBufferedBlockDispatcherResult = { queuedFinal: false, - counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + counts: EMPTY_REPLY_COUNTS, }; - await params.dispatcherOptions?.typingCallbacks?.start?.(); + await params.dispatcherOptions?.typingCallbacks?.onReplyStart?.(); const reply = await replySpy(params.ctx, params.replyOptions); const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; for (const payload of payloads) { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index b9098fc7b37..5c05d54a2c7 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -39,7 +39,9 @@ const { getTelegramSequentialKey, setTelegramBotRuntimeForTest, } = await import("./bot.js"); -setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setTelegramBotRuntimeForTest( + telegramBotRuntimeForTest as unknown as Parameters[0], +); const createTelegramBot = (opts: Parameters[0]) => createTelegramBotBase({ ...opts, diff --git a/extensions/telegram/src/bot.fetch-abort.test.ts b/extensions/telegram/src/bot.fetch-abort.test.ts index 7a58aa86e87..63e53a1ba5c 100644 --- a/extensions/telegram/src/bot.fetch-abort.test.ts +++ b/extensions/telegram/src/bot.fetch-abort.test.ts @@ -6,7 +6,9 @@ const { botCtorSpy, telegramBotDepsForTest } = const { telegramBotRuntimeForTest } = await import("./bot.create-telegram-bot.test-harness.js"); const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } = await import("./bot.js"); -setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setTelegramBotRuntimeForTest( + telegramBotRuntimeForTest as unknown as Parameters[0], +); const createTelegramBot = (opts: Parameters[0]) => createTelegramBotBase({ ...opts, diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index e21308c7403..7054b69d06a 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,5 +1,12 @@ import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import { beforeEach, vi, type Mock } from "vitest"; +import type { TelegramBotDeps } from "./bot-deps.js"; + +const EMPTY_REPLY_COUNTS = { + block: 0, + final: 0, + tool: 0, +} as const; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); @@ -56,7 +63,11 @@ const apiStub: ApiStub = { setMyCommands: vi.fn(async () => undefined), }; -export const telegramBotRuntimeForTest = { +export const telegramBotRuntimeForTest: { + Bot: new (token: string) => unknown; + sequentialize: () => unknown; + apiThrottler: () => unknown; +} = { Bot: class { api = apiStub; use = middlewareUseSpy; @@ -84,12 +95,12 @@ const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => for (const payload of payloads) { await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); } - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: EMPTY_REPLY_COUNTS }; }), ); -export const telegramBotDepsForTest = { +export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig: () => ({ - channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, + channels: { telegram: { dmPolicy: "open" as const, allowFrom: ["*"] } }, }), resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/telegram-media-sessions.json"), readChannelAllowFromStore: vi.fn(async () => [] as string[]), diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index a98afa96b69..7c391642d67 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -107,7 +107,11 @@ beforeAll(async () => { onSpyRef = harness.onSpy; sendChatActionSpyRef = harness.sendChatActionSpy; const botModule = await import("./bot.js"); - botModule.setTelegramBotRuntimeForTest(harness.telegramBotRuntimeForTest); + botModule.setTelegramBotRuntimeForTest( + harness.telegramBotRuntimeForTest as unknown as Parameters< + typeof botModule.setTelegramBotRuntimeForTest + >[0], + ); createTelegramBotRef = (opts) => botModule.createTelegramBot({ ...opts, diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 7df6fa8816b..2de1e06fc6d 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -35,7 +35,9 @@ const { normalizeTelegramCommandName } = await import("../../../src/config/telegram-custom-commands.js"); const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } = await import("./bot.js"); -setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setTelegramBotRuntimeForTest( + telegramBotRuntimeForTest as unknown as Parameters[0], +); const createTelegramBot = (opts: Parameters[0]) => createTelegramBotBase({ ...opts, diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index 08b9c3597e2..5aeb9785779 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -2,19 +2,17 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers" import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, - inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedTelegramAccount } from "../api.js"; +import { inspectTelegramAccount, type InspectedTelegramAccount } from "../api.js"; export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedTelegramAccount | null; + }) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } @@ -36,11 +34,10 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedTelegramAccount | null; + }) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/thread-ownership/api.ts b/extensions/thread-ownership/api.ts index 16e4afef70a..d94a5fd68e1 100644 --- a/extensions/thread-ownership/api.ts +++ b/extensions/thread-ownership/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/thread-ownership.js"; +export * from "openclaw/plugin-sdk/thread-ownership"; diff --git a/extensions/twitch/api.ts b/extensions/twitch/api.ts index 4743a12fb3b..68033283423 100644 --- a/extensions/twitch/api.ts +++ b/extensions/twitch/api.ts @@ -1,2 +1 @@ export * from "openclaw/plugin-sdk/twitch"; -export * from "./src/setup-surface.js"; diff --git a/extensions/voice-call/api.ts b/extensions/voice-call/api.ts index d0f69774b5e..ef9f7d7a3c0 100644 --- a/extensions/voice-call/api.ts +++ b/extensions/voice-call/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/voice-call.js"; +export * from "openclaw/plugin-sdk/voice-call"; diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index ce89a02eb76..a0f07404a91 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -1,22 +1,30 @@ export { + buildChannelConfigSchema, createActionGate, - createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, formatWhatsAppConfigAllowFromEntries, - isWhatsAppGroupJid, + getChatChannelMeta, jsonResult, - normalizeWhatsAppTarget, + normalizeE164, readReactionParams, readStringParam, - resolveWhatsAppHeartbeatRecipients, - resolveWhatsAppMentionStripRegexes, + resolveWhatsAppGroupIntroHint, resolveWhatsAppOutboundTarget, ToolAuthorizationError, + WhatsAppConfigSchema, type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk/whatsapp-core"; + +export { + createWhatsAppOutboundBase, + isWhatsAppGroupJid, + normalizeWhatsAppTarget, + resolveWhatsAppHeartbeatRecipients, + resolveWhatsAppMentionStripRegexes, type ChannelMessageActionName, type DmPolicy, type GroupPolicy, - type OpenClawConfig, type WhatsAppAccountConfig, } from "openclaw/plugin-sdk/whatsapp"; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 0c15d09864c..29433ec7efa 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -3,7 +3,7 @@ import { resolveWebSearchProviderCredential, } from "openclaw/plugin-sdk/provider-web-search"; import { describe, expect, it } from "vitest"; -import { withEnv } from "../../src/test-utils/env.js"; +import { withEnv } from "../../test/helpers/extensions/env.js"; import { __testing } from "./web-search.js"; const { extractXaiWebSearchContent, resolveXaiInlineCitations, resolveXaiWebSearchModel } = diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index b6b5c5b95f3..89b284df789 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -1,11 +1,11 @@ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { listEnabledZaloAccounts } from "./accounts.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, OpenClawConfig, } from "./runtime-api.js"; import { extractToolSend, jsonResult, readStringParam } from "./runtime-api.js"; -import { listEnabledZaloAccounts } from "./accounts.js"; const loadZaloActionsRuntime = createLazyRuntimeNamedExport( () => import("./actions.runtime.js"), diff --git a/extensions/zalo/src/channel.runtime.ts b/extensions/zalo/src/channel.runtime.ts index 39702a439fc..6b76e0e92eb 100644 --- a/extensions/zalo/src/channel.runtime.ts +++ b/extensions/zalo/src/channel.runtime.ts @@ -1,18 +1,15 @@ import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; -import { normalizeSecretInputString } from "./secret-input.js"; -import { sendMessageZalo } from "./send.js"; import { PAIRING_APPROVED_MESSAGE, type ChannelPlugin, type OpenClawConfig, } from "./runtime-api.js"; +import { normalizeSecretInputString } from "./secret-input.js"; +import { sendMessageZalo } from "./send.js"; -export async function notifyZaloPairingApproval(params: { - cfg: OpenClawConfig; - id: string; -}) { +export async function notifyZaloPairingApproval(params: { cfg: OpenClawConfig; id: string }) { const { resolveZaloAccount } = await import("./accounts.js"); const account = resolveZaloAccount({ cfg: params.cfg }); if (!account.token) { @@ -44,11 +41,7 @@ export async function probeZaloAccount(params: { } export async function startZaloGatewayAccount( - ctx: Parameters< - NonNullable< - NonNullable["startAccount"] - > - >[0], + ctx: Parameters["startAccount"]>>[0], ) { const account = ctx.account; const token = account.token.trim(); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index a9cfea6f9ad..5434b3e144e 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -9,6 +9,14 @@ import { collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { + listZaloAccountIds, + resolveDefaultZaloAccountId, + resolveZaloAccount, + type ResolvedZaloAccount, +} from "./accounts.js"; +import { zaloMessageActions } from "./actions.js"; +import { ZaloConfigSchema } from "./config-schema.js"; import { buildBaseAccountStatusSnapshot, buildChannelConfigSchema, @@ -24,14 +32,6 @@ import { type ChannelPlugin, type OpenClawConfig, } from "./runtime-api.js"; -import { - listZaloAccountIds, - resolveDefaultZaloAccountId, - resolveZaloAccount, - type ResolvedZaloAccount, -} from "./accounts.js"; -import { zaloMessageActions } from "./actions.js"; -import { ZaloConfigSchema } from "./config-schema.js"; import { resolveZaloOutboundSessionRoute } from "./session-route.js"; import { zaloSetupAdapter } from "./setup-core.js"; import { zaloSetupWizard } from "./setup-surface.js"; diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 70b863779c1..75d8027cf47 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -5,8 +5,8 @@ import { GroupPolicySchema, } from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "zod"; -import { buildSecretInputSchema } from "./secret-input.js"; import { MarkdownConfigSchema } from "./runtime-api.js"; +import { buildSecretInputSchema } from "./secret-input.js"; const zaloAccountSchema = z.object({ name: z.string().optional(), diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index ee97207cf3b..8452fb661e2 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,25 +1,4 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { - MarkdownTableMode, - OpenClawConfig, - OutboundReplyPayload, -} from "./runtime-api.js"; -import { - createTypingCallbacks, - createScopedPairingAccess, - createReplyPrefixOptions, - issuePairingChallenge, - logTypingFailure, - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, - resolveOutboundMediaUrls, - resolveDefaultGroupPolicy, - resolveInboundRouteEnvelopeBuilderWithRuntime, - sendMediaWithLeadingCaption, - resolveWebhookPath, - waitForAbortSignal, - warnMissingProviderGroupPolicyFallbackOnce, -} from "./runtime-api.js"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, @@ -48,6 +27,23 @@ import { type ZaloWebhookTarget, } from "./monitor.webhook.js"; import { resolveZaloProxyFetch } from "./proxy.js"; +import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "./runtime-api.js"; +import { + createTypingCallbacks, + createScopedPairingAccess, + createReplyPrefixOptions, + issuePairingChallenge, + logTypingFailure, + resolveDirectDmAuthorizationOutcome, + resolveSenderCommandAuthorizationWithRuntime, + resolveOutboundMediaUrls, + resolveDefaultGroupPolicy, + resolveInboundRouteEnvelopeBuilderWithRuntime, + sendMediaWithLeadingCaption, + resolveWebhookPath, + waitForAbortSignal, + warnMissingProviderGroupPolicyFallbackOnce, +} from "./runtime-api.js"; import { getZaloRuntime } from "./runtime.js"; export type ZaloRuntimeEnv = { diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index e058dcc453c..02a82bf0544 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -1,5 +1,8 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ResolvedZaloAccount } from "./accounts.js"; +import type { ZaloFetch, ZaloUpdate } from "./api.js"; +import type { ZaloRuntimeEnv } from "./monitor.js"; import { createDedupeCache, createFixedWindowRateLimiter, @@ -17,9 +20,6 @@ import { resolveClientIp, type OpenClawConfig, } from "./runtime-api.js"; -import type { ResolvedZaloAccount } from "./accounts.js"; -import type { ZaloFetch, ZaloUpdate } from "./api.js"; -import type { ZaloRuntimeEnv } from "./monitor.js"; const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index d83bd16114d..647ca3b9823 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -2,8 +2,8 @@ import { resolveZaloAccount } from "./accounts.js"; import type { ZaloFetch } from "./api.js"; import { sendMessage, sendPhoto } from "./api.js"; import { resolveZaloProxyFetch } from "./proxy.js"; -import { resolveZaloToken } from "./token.js"; import type { OpenClawConfig } from "./runtime-api.js"; +import { resolveZaloToken } from "./token.js"; export type ZaloSendOptions = { token?: string; diff --git a/extensions/zalo/src/token.ts b/extensions/zalo/src/token.ts index c593cb5b824..2ee4ffa4283 100644 --- a/extensions/zalo/src/token.ts +++ b/extensions/zalo/src/token.ts @@ -1,8 +1,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; +import type { BaseTokenResolution } from "./runtime-api.js"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js"; import type { ZaloConfig } from "./types.js"; -import type { BaseTokenResolution } from "./runtime-api.js"; export type ZaloTokenResolution = BaseTokenResolution & { source: "env" | "config" | "configFile" | "none"; diff --git a/src/agents/openclaw-tools.image-generation.test.ts b/src/agents/openclaw-tools.image-generation.test.ts index 9ad49f66371..cb5b9691009 100644 --- a/src/agents/openclaw-tools.image-generation.test.ts +++ b/src/agents/openclaw-tools.image-generation.test.ts @@ -17,7 +17,17 @@ function stubImageGenerationProviders() { id: "openai", defaultModel: "gpt-image-1", models: ["gpt-image-1"], - supportedSizes: ["1024x1024"], + capabilities: { + generate: { + supportsSize: true, + }, + edit: { + enabled: false, + }, + geometry: { + sizes: ["1024x1024"], + }, + }, generateImage: vi.fn(async () => { throw new Error("not used"); }), diff --git a/src/agents/pi-embedded-runner/extra-params.google.test.ts b/src/agents/pi-embedded-runner/extra-params.google.test.ts index 4cf33f5eeef..622e85b475c 100644 --- a/src/agents/pi-embedded-runner/extra-params.google.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.google.test.ts @@ -18,7 +18,7 @@ describe("extra-params: Google thinking payload compatibility", () => { api: "google-generative-ai", provider: "google", id: "gemini-3.1-pro-preview", - } as Model<"openai-completions">, + } as unknown as Model<"openai-completions">, thinkingLevel: "high", payload: { contents: [], diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index ca704b03e51..c704515ac6e 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -457,7 +457,7 @@ describe("createOpenClawCodingTools", () => { it("applies xai model compat for direct Grok tool cleanup", () => { const xaiTools = createOpenClawCodingTools({ modelProvider: "xai", - modelCompat: applyXaiModelCompat({}).compat, + modelCompat: applyXaiModelCompat({ compat: {} }).compat, senderIsOwner: true, }); diff --git a/src/agents/pi-tools.model-provider-collision.test.ts b/src/agents/pi-tools.model-provider-collision.test.ts index 04eaa575601..9d629839199 100644 --- a/src/agents/pi-tools.model-provider-collision.test.ts +++ b/src/agents/pi-tools.model-provider-collision.test.ts @@ -18,10 +18,7 @@ function toolNames(tools: AnyAgentTool[]): string[] { describe("applyModelProviderToolPolicy", () => { it("keeps web_search for non-xAI models", () => { - const filtered = __testing.applyModelProviderToolPolicy(baseTools, { - modelProvider: "openai", - modelId: "gpt-4o-mini", - }); + const filtered = __testing.applyModelProviderToolPolicy(baseTools); expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]); }); diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 50df1718daf..f719d8552b5 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -392,10 +392,11 @@ describe("createImageGenerateTool", () => { throw new Error("expected image_generate tool"); } - await expect(tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" })) - .rejects.toThrow( - "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", - ); + await expect( + tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" }), + ).rejects.toThrow( + "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", + ); }); it("lists registered provider and model options", async () => { diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 3ae12fda187..aeb20a83723 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -230,7 +230,9 @@ function normalizeReferenceImages(args: Record): string[] { return normalized; } -function parseImageGenerationModelRef(raw: string | undefined): { provider: string; model: string } | null { +function parseImageGenerationModelRef( + raw: string | undefined, +): { provider: string; model: string } | null { const trimmed = raw?.trim(); if (!trimmed) { return null; @@ -258,7 +260,8 @@ function resolveSelectedImageGenerationProvider(params: { } return listRuntimeImageGenerationProviders({ config: params.config }).find( (provider) => - provider.id === selectedRef.provider || (provider.aliases ?? []).includes(selectedRef.provider), + provider.id === selectedRef.provider || + (provider.aliases ?? []).includes(selectedRef.provider), ); } @@ -298,7 +301,9 @@ function validateImageGenerationCapabilities(params: { if (params.size) { if (!modeCaps.supportsSize) { - throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`); + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`, + ); } if ((geometry?.sizes?.length ?? 0) > 0 && !geometry?.sizes?.includes(params.size)) { throw new ToolInputError( @@ -309,7 +314,9 @@ function validateImageGenerationCapabilities(params: { if (params.aspectRatio) { if (!modeCaps.supportsAspectRatio) { - throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`); + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`, + ); } if ( (geometry?.aspectRatios?.length ?? 0) > 0 && @@ -323,7 +330,9 @@ function validateImageGenerationCapabilities(params: { if (params.resolution) { if (!modeCaps.supportsResolution) { - throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`); + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`, + ); } if ( (geometry?.resolutions?.length ?? 0) > 0 && diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index a8dcde278db..5d84287c4c3 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -26,7 +26,7 @@ type AssistantLikeMessage = { }; function resolveLiveXaiModel() { - return getModel("xai", "grok-4-1-fast-reasoning") ?? getModel("xai", "grok-4"); + return getModel("xai", "grok-4"); } async function collectDoneMessage( diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index 50a29404b30..23299816f5e 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -722,7 +722,14 @@ export function createAccountScopedGroupAccessSection(params: { }; } -type AccountScopedChannel = "discord" | "slack" | "telegram" | "imessage" | "signal"; +type AccountScopedChannel = + | "bluebubbles" + | "discord" + | "imessage" + | "line" + | "signal" + | "slack" + | "telegram"; type LegacyDmChannel = "discord" | "slack"; export function patchLegacyDmChannelConfig(params: { diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 83876477b43..2c4852ba8b6 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginCompatibilityNotice } from "../plugins/status.js"; const readConfigFileSnapshot = vi.fn(); -const buildPluginCompatibilityNotices = vi.fn(() => []); +const buildPluginCompatibilityNotices = vi.fn((): PluginCompatibilityNotice[] => []); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot, diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 78cd0716376..c74909ae14b 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -184,13 +184,13 @@ async function promptWebToolsConfig( if (!entry) { return false; } - return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry); + return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry); }; const existingProvider: SP = (() => { const stored = existingSearch?.provider; if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { - return stored as SP; + return stored; } return ( SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider @@ -242,8 +242,8 @@ async function promptWebToolsConfig( nextSearch = { ...nextSearch, provider: providerChoice }; const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; - const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); - const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); + const existingKey = resolveExistingKey(nextConfig, providerChoice); + const keyConfigured = hasExistingKey(nextConfig, providerChoice); const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); const envVarNames = entry.envKeys.join(" / "); @@ -263,7 +263,7 @@ async function promptWebToolsConfig( const key = String(keyInput ?? "").trim(); if (key || existingKey) { - const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); + const applied = applySearchKey(nextConfig, providerChoice, (key || existingKey)!); nextSearch = { ...applied.tools?.web?.search }; } else if (keyConfigured || envAvailable) { nextSearch = { ...nextSearch }; diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 738827c31c6..b8ec52ca171 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -359,6 +359,8 @@ describe("normalizeCompatibilityConfigValues", () => { providers: { google: { apiKey: "existing-google-key", + baseUrl: "https://generativelanguage.googleapis.com", + models: [], }, }, }, diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 8072b89854b..c3376bd74e9 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -474,6 +474,11 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { }; const normalizeLegacyNanoBananaSkill = () => { + type ModelProviderEntry = Partial< + NonNullable["providers"]>[string] + >; + type ModelsConfigPatch = Partial>; + const rawSkills = next.skills; if (!isRecord(rawSkills)) { return; @@ -544,14 +549,20 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { ? structuredClone(rawLegacyEntry.apiKey) : undefined); - const rawModels = isRecord(next.models) ? structuredClone(next.models) : {}; - const rawProviders = isRecord(rawModels.providers) ? { ...rawModels.providers } : {}; - const rawGoogle = isRecord(rawProviders.google) ? { ...rawProviders.google } : {}; + const rawModels = ( + isRecord(next.models) ? structuredClone(next.models) : {} + ) as ModelsConfigPatch; + const rawProviders = ( + isRecord(rawModels.providers) ? { ...rawModels.providers } : {} + ) as Record; + const rawGoogle = ( + isRecord(rawProviders.google) ? { ...rawProviders.google } : {} + ) as ModelProviderEntry; const hasGoogleApiKey = rawGoogle.apiKey !== undefined; if (!hasGoogleApiKey && legacyApiKey) { rawGoogle.apiKey = legacyApiKey; rawProviders.google = rawGoogle; - rawModels.providers = rawProviders; + rawModels.providers = rawProviders as NonNullable["providers"]; next = { ...next, models: rawModels as OpenClawConfig["models"], diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 0a1e68a16a7..6939b7b0d96 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -444,6 +444,14 @@ export type MemorySearchConfig = { }; }; +type WebSearchLegacyProviderConfig = { + apiKey?: SecretInput; + baseUrl?: string; + model?: string; + mode?: string; + inlineCitations?: boolean; +}; + export type ToolsConfig = { /** Base tool profile applied before allow/deny lists. */ profile?: ToolProfileId; @@ -465,6 +473,20 @@ export type ToolsConfig = { timeoutSeconds?: number; /** Cache TTL in minutes for search results. */ cacheTtlMinutes?: number; + /** @deprecated Legacy Brave credential path. */ + apiKey?: SecretInput; + /** @deprecated Legacy Brave scoped config. */ + brave?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Firecrawl scoped config. */ + firecrawl?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Gemini scoped config. */ + gemini?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Grok scoped config. */ + grok?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Kimi scoped config. */ + kimi?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Perplexity scoped config. */ + perplexity?: WebSearchLegacyProviderConfig; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 2763697c2d9..10f0f8637e9 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -267,6 +267,57 @@ export const ToolsWebSearchSchema = z maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), + apiKey: SecretInputSchema.optional().register(sensitive), + brave: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + mode: z.string().optional(), + }) + .strict() + .optional(), + firecrawl: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), + gemini: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), + grok: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + inlineCitations: z.boolean().optional(), + }) + .strict() + .optional(), + kimi: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), + perplexity: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), }) .strict() .optional(); diff --git a/src/image-generation/providers/fal.ts b/src/image-generation/providers/fal.ts index 4059859e534..8d0cd8ceaaf 100644 --- a/src/image-generation/providers/fal.ts +++ b/src/image-generation/providers/fal.ts @@ -94,14 +94,22 @@ function aspectRatioToEnum(aspectRatio: string | undefined): string | undefined return undefined; } -function aspectRatioToDimensions(aspectRatio: string, edge: number): { width: number; height: number } { +function aspectRatioToDimensions( + aspectRatio: string, + edge: number, +): { width: number; height: number } { const match = /^(\d+):(\d+)$/u.exec(aspectRatio.trim()); if (!match) { throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); } const widthRatio = Number.parseInt(match[1] ?? "", 10); const heightRatio = Number.parseInt(match[2] ?? "", 10); - if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) { + if ( + !Number.isFinite(widthRatio) || + !Number.isFinite(heightRatio) || + widthRatio <= 0 || + heightRatio <= 0 + ) { throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); } if (widthRatio >= heightRatio) { @@ -140,7 +148,10 @@ function resolveFalImageSize(params: { return { width: edge, height: edge }; } if (normalizedAspectRatio) { - return aspectRatioToEnum(normalizedAspectRatio) ?? aspectRatioToDimensions(normalizedAspectRatio, 1024); + return ( + aspectRatioToEnum(normalizedAspectRatio) ?? + aspectRatioToDimensions(normalizedAspectRatio, 1024) + ); } return undefined; } diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts index c33c3edcf77..7a45f938bf8 100644 --- a/src/infra/outbound/outbound-session.test.ts +++ b/src/infra/outbound/outbound-session.test.ts @@ -41,7 +41,7 @@ describe("resolveOutboundSessionRoute", () => { from?: string; to?: string; threadId?: string | number; - chatType?: "direct" | "group"; + chatType?: "channel" | "direct" | "group"; }; }> = [ { diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 7266f45d969..7dcdab184ed 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -972,7 +972,7 @@ describe("resolveOutboundSessionRoute", () => { from?: string; to?: string; threadId?: string | number; - chatType?: "direct" | "group"; + chatType?: "channel" | "direct" | "group"; }; }> = [ { diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index c50c36419bb..84435bb896a 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -1,6 +1,18 @@ // Public ACP runtime helpers for plugins that integrate with ACP control/session state. export { getAcpSessionManager } from "../acp/control-plane/manager.js"; -export { isAcpRuntimeError } from "../acp/runtime/errors.js"; +export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; +export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; +export type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, + AcpSessionUpdateTag, +} from "../acp/runtime/types.js"; export { readAcpSessionEntry } from "../acp/runtime/session-meta.js"; export type { AcpSessionStoreEntry } from "../acp/runtime/session-meta.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index ee18f8bc9c9..d9a229657dd 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -41,8 +41,11 @@ export function resolveOptionalConfigString( } /** Build the shared allowlist/default target adapter surface for account-scoped channel configs. */ -export function createScopedAccountConfigAccessors(params: { - resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount; +export function createScopedAccountConfigAccessors< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + resolveAccount: (params: { cfg: Config; accountId?: string | null }) => ResolvedAccount; resolveAllowFrom: (account: ResolvedAccount) => Array | null | undefined; formatAllowFrom: (allowFrom: Array) => string[]; resolveDefaultTo?: (account: ResolvedAccount) => string | number | null | undefined; @@ -52,7 +55,9 @@ export function createScopedAccountConfigAccessors(params: { > { const base = { resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - mapAllowFromEntries(params.resolveAllowFrom(params.resolveAccount({ cfg, accountId }))), + mapAllowFromEntries( + params.resolveAllowFrom(params.resolveAccount({ cfg: cfg as Config, accountId })), + ), formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => params.formatAllowFrom(allowFrom), }; @@ -65,7 +70,7 @@ export function createScopedAccountConfigAccessors(params: { ...base, resolveDefaultTo: ({ cfg, accountId }) => resolveOptionalConfigString( - params.resolveDefaultTo?.(params.resolveAccount({ cfg, accountId })), + params.resolveDefaultTo?.(params.resolveAccount({ cfg: cfg as Config, accountId })), ), }; } @@ -160,7 +165,7 @@ export function createScopedChannelConfigAdapter< clearBaseFields: params.clearBaseFields, allowTopLevel: params.allowTopLevel, }), - ...createScopedAccountConfigAccessors({ + ...createScopedAccountConfigAccessors({ resolveAccount: resolveAccessorAccount, resolveAllowFrom: params.resolveAllowFrom, formatAllowFrom: params.formatAllowFrom, @@ -316,7 +321,7 @@ export function createTopLevelChannelConfigAdapter< deleteMode: params.deleteMode, clearBaseFields: params.clearBaseFields, }), - ...createScopedAccountConfigAccessors({ + ...createScopedAccountConfigAccessors({ resolveAccount: resolveAccessorAccount, resolveAllowFrom: params.resolveAllowFrom, formatAllowFrom: params.formatAllowFrom, @@ -438,7 +443,7 @@ export function createHybridChannelConfigAdapter< clearBaseFields: params.clearBaseFields, preserveSectionOnDefaultDelete: params.preserveSectionOnDefaultDelete, }), - ...createScopedAccountConfigAccessors({ + ...createScopedAccountConfigAccessors({ resolveAccount: resolveAccessorAccount, resolveAllowFrom: params.resolveAllowFrom, formatAllowFrom: params.formatAllowFrom, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 124c37d6712..252063d2631 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -44,6 +44,7 @@ export type { ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, OpenClawPluginService, + OpenClawPluginServiceContext, ProviderAuthContext, ProviderAuthDoctorHintContext, ProviderAuthMethodNonInteractiveContext, @@ -51,6 +52,7 @@ export type { ProviderAuthResult, OpenClawPluginCommandDefinition, OpenClawPluginDefinition, + PluginLogger, PluginInteractiveTelegramHandlerContext, } from "../plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugin-sdk/package-contract-guardrails.test.ts b/src/plugin-sdk/package-contract-guardrails.test.ts index 046562708cd..a637927098e 100644 --- a/src/plugin-sdk/package-contract-guardrails.test.ts +++ b/src/plugin-sdk/package-contract-guardrails.test.ts @@ -25,7 +25,7 @@ function collectPluginSdkPackageExports(): string[] { } subpaths.push(key.slice("./plugin-sdk/".length)); } - return subpaths.sort(); + return subpaths.toSorted(); } function collectPluginSdkSourceNames(): string[] { @@ -35,7 +35,7 @@ function collectPluginSdkSourceNames(): string[] { (entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts"), ) .map((entry) => entry.name.slice(0, -".ts".length)) - .sort(); + .toSorted(); } function collectTextFiles(rootRelativeDir: string): string[] { @@ -92,7 +92,7 @@ function collectPluginSdkSubpathReferences() { describe("plugin-sdk package contract guardrails", () => { it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => { - expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].sort()); + expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].toSorted()); }); it("keeps repo openclaw/plugin-sdk/ references on exported built subpaths", () => { @@ -135,7 +135,7 @@ describe("plugin-sdk package contract guardrails", () => { failures.push( `src/plugin-sdk/${sourceName}.ts is referenced as openclaw/plugin-sdk/${sourceName} in ${matchingRefs .map((reference) => reference.file) - .sort() + .toSorted() .join(", ")}, but ${sourceName} is not exported as a public plugin-sdk subpath`, ); } diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 4a180763b38..c4ec4f2cdff 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -26,6 +26,8 @@ export type { StickerMetadata } from "../../extensions/telegram/api.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; +export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; +export { resolveTelegramPollVisibility } from "../poll-params.js"; export { PAIRING_APPROVED_MESSAGE, @@ -38,9 +40,6 @@ export { setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; -export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; -export { resolveTelegramPollVisibility } from "../poll-params.js"; - export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, diff --git a/src/plugins/contracts/shape.contract.test.ts b/src/plugins/contracts/shape.contract.test.ts index ffc7c92360a..c5726c4fd0b 100644 --- a/src/plugins/contracts/shape.contract.test.ts +++ b/src/plugins/contracts/shape.contract.test.ts @@ -99,6 +99,7 @@ describe("plugin shape compatibility matrix", () => { envVars: ["HYBRID_SEARCH_KEY"], placeholder: "hsk_...", signupUrl: "https://example.com/signup", + credentialPath: "tools.web.search.hybrid-search.apiKey", getCredentialValue: () => "hsk-test", setCredentialValue(searchConfigTarget, value) { searchConfigTarget.apiKey = value; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 94f7b9be99f..7b0706a66d4 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -68,7 +68,10 @@ function createProviderSecretRefConfig( } function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown { - return config.plugins?.entries?.[providerPluginId(provider)]?.config?.webSearch?.apiKey; + const pluginConfig = config.plugins?.entries?.[providerPluginId(provider)]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined; + return pluginConfig?.webSearch?.apiKey; } function expectInactiveFirecrawlSecretRef(params: { diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 68446d33a95..428ae25552c 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -21,6 +21,7 @@ describe("web search runtime", () => { placeholder: "custom-...", signupUrl: "https://example.com/signup", autoDetectOrder: 1, + credentialPath: "tools.web.search.custom.apiKey", getCredentialValue: () => "configured", setCredentialValue: () => {}, createTool: () => ({ diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 4861ad12480..2c81f6748b4 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -199,5 +199,6 @@ export async function runWebSearch( export const __testing = { resolveSearchConfig, + resolveSearchProvider: resolveWebSearchProviderId, resolveWebSearchProviderId, }; diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 4b546cfa0b7..6473c09404d 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -42,6 +42,8 @@ describe("config view", () => { themeMode: "system" as ThemeMode, setTheme: vi.fn(), setThemeMode: vi.fn(), + borderRadius: 50, + setBorderRadius: vi.fn(), gatewayUrl: "", assistantName: "OpenClaw", }); From bde4c7995f5b2d2a07f533b1762bbd26c5a5d167 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:45:29 -0700 Subject: [PATCH 374/393] docs: remove docs/refactor/ directory Delete all 7 refactor design docs and the zh-CN translations. Remove the zh-CN nav group from docs.json. These were orphaned from English nav and accessible only by direct URL. Internal design docs do not belong on the public docs site. Co-Authored-By: Claude Opus 4.6 --- docs/docs.json | 10 - docs/refactor/clawnet.md | 417 ----------------- docs/refactor/cluster.md | 299 ------------ docs/refactor/exec-host.md | 316 ------------- docs/refactor/firecrawl-extension.md | 260 ----------- docs/refactor/outbound-session-mirroring.md | 89 ---- docs/refactor/plugin-sdk.md | 264 ----------- docs/refactor/strict-config.md | 93 ---- docs/zh-CN/refactor/clawnet.md | 424 ------------------ docs/zh-CN/refactor/exec-host.md | 323 ------------- .../refactor/outbound-session-mirroring.md | 92 ---- docs/zh-CN/refactor/plugin-sdk.md | 221 --------- docs/zh-CN/refactor/strict-config.md | 100 ----- 13 files changed, 2908 deletions(-) delete mode 100644 docs/refactor/clawnet.md delete mode 100644 docs/refactor/cluster.md delete mode 100644 docs/refactor/exec-host.md delete mode 100644 docs/refactor/firecrawl-extension.md delete mode 100644 docs/refactor/outbound-session-mirroring.md delete mode 100644 docs/refactor/plugin-sdk.md delete mode 100644 docs/refactor/strict-config.md delete mode 100644 docs/zh-CN/refactor/clawnet.md delete mode 100644 docs/zh-CN/refactor/exec-host.md delete mode 100644 docs/zh-CN/refactor/outbound-session-mirroring.md delete mode 100644 docs/zh-CN/refactor/plugin-sdk.md delete mode 100644 docs/zh-CN/refactor/strict-config.md diff --git a/docs/docs.json b/docs/docs.json index 5ee53ed6008..9d04ab81c5c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1949,16 +1949,6 @@ "zh-CN/experiments/research/memory", "zh-CN/experiments/proposals/model-config" ] - }, - { - "group": "重构方案", - "pages": [ - "zh-CN/refactor/clawnet", - "zh-CN/refactor/exec-host", - "zh-CN/refactor/outbound-session-mirroring", - "zh-CN/refactor/plugin-sdk", - "zh-CN/refactor/strict-config" - ] } ] }, diff --git a/docs/refactor/clawnet.md b/docs/refactor/clawnet.md deleted file mode 100644 index f24cfdc2c57..00000000000 --- a/docs/refactor/clawnet.md +++ /dev/null @@ -1,417 +0,0 @@ ---- -summary: "Clawnet refactor: unify network protocol, roles, auth, approvals, identity" -read_when: - - Planning a unified network protocol for nodes + operator clients - - Reworking approvals, pairing, TLS, and presence across devices -title: "Clawnet Refactor" ---- - -# Clawnet refactor (protocol + auth unification) - -## Hi - -Hi Peter — great direction; this unlocks simpler UX + stronger security. - -## Purpose - -Single, rigorous document for: - -- Current state: protocols, flows, trust boundaries. -- Pain points: approvals, multi‑hop routing, UI duplication. -- Proposed new state: one protocol, scoped roles, unified auth/pairing, TLS pinning. -- Identity model: stable IDs + cute slugs. -- Migration plan, risks, open questions. - -## Goals (from discussion) - -- One protocol for all clients (mac app, CLI, iOS, Android, headless node). -- Every network participant authenticated + paired. -- Role clarity: nodes vs operators. -- Central approvals routed to where the user is. -- TLS encryption + optional pinning for all remote traffic. -- Minimal code duplication. -- Single machine should appear once (no UI/node duplicate entry). - -## Non‑goals (explicit) - -- Remove capability separation (still need least‑privilege). -- Expose full gateway control plane without scope checks. -- Make auth depend on human labels (slugs remain non‑security). - ---- - -# Current state (as‑is) - -## Two protocols - -### 1) Gateway WebSocket (control plane) - -- Full API surface: config, channels, models, sessions, agent runs, logs, nodes, etc. -- Default bind: loopback. Remote access via SSH/Tailscale. -- Auth: token/password via `connect`. -- No TLS pinning (relies on loopback/tunnel). -- Code: - - `src/gateway/server/ws-connection/message-handler.ts` - - `src/gateway/client.ts` - - `docs/gateway/protocol.md` - -### 2) Bridge (node transport) - -- Narrow allowlist surface, node identity + pairing. -- JSONL over TCP; optional TLS + cert fingerprint pinning. -- TLS advertises fingerprint in discovery TXT. -- Code: - - `src/infra/bridge/server/connection.ts` - - `src/gateway/server-bridge.ts` - - `src/node-host/bridge-client.ts` - - `docs/gateway/bridge-protocol.md` - -## Control plane clients today - -- CLI → Gateway WS via `callGateway` (`src/gateway/call.ts`). -- macOS app UI → Gateway WS (`GatewayConnection`). -- Web Control UI → Gateway WS. -- ACP → Gateway WS. -- Browser control uses its own HTTP control server. - -## Nodes today - -- macOS app in node mode connects to Gateway bridge (`MacNodeBridgeSession`). -- iOS/Android apps connect to Gateway bridge. -- Pairing + per‑node token stored on gateway. - -## Current approval flow (exec) - -- Agent uses `system.run` via Gateway. -- Gateway invokes node over bridge. -- Node runtime decides approval. -- UI prompt shown by mac app (when node == mac app). -- Node returns `invoke-res` to Gateway. -- Multi‑hop, UI tied to node host. - -## Presence + identity today - -- Gateway presence entries from WS clients. -- Node presence entries from bridge. -- mac app can show two entries for same machine (UI + node). -- Node identity stored in pairing store; UI identity separate. - ---- - -# Problems / pain points - -- Two protocol stacks to maintain (WS + Bridge). -- Approvals on remote nodes: prompt appears on node host, not where user is. -- TLS pinning only exists for bridge; WS depends on SSH/Tailscale. -- Identity duplication: same machine shows as multiple instances. -- Ambiguous roles: UI + node + CLI capabilities not clearly separated. - ---- - -# Proposed new state (Clawnet) - -## One protocol, two roles - -Single WS protocol with role + scope. - -- **Role: node** (capability host) -- **Role: operator** (control plane) -- Optional **scope** for operator: - - `operator.read` (status + viewing) - - `operator.write` (agent run, sends) - - `operator.admin` (config, channels, models) - -### Role behaviors - -**Node** - -- Can register capabilities (`caps`, `commands`, permissions). -- Can receive `invoke` commands (`system.run`, `camera.*`, `canvas.*`, `screen.record`, etc). -- Can send events: `voice.transcript`, `agent.request`, `chat.subscribe`. -- Cannot call config/models/channels/sessions/agent control plane APIs. - -**Operator** - -- Full control plane API, gated by scope. -- Receives all approvals. -- Does not directly execute OS actions; routes to nodes. - -### Key rule - -Role is per‑connection, not per device. A device may open both roles, separately. - ---- - -# Unified authentication + pairing - -## Client identity - -Every client provides: - -- `deviceId` (stable, derived from device key). -- `displayName` (human name). -- `role` + `scope` + `caps` + `commands`. - -## Pairing flow (unified) - -- Client connects unauthenticated. -- Gateway creates a **pairing request** for that `deviceId`. -- Operator receives prompt; approves/denies. -- Gateway issues credentials bound to: - - device public key - - role(s) - - scope(s) - - capabilities/commands -- Client persists token, reconnects authenticated. - -## Device‑bound auth (avoid bearer token replay) - -Preferred: device keypairs. - -- Device generates keypair once. -- `deviceId = fingerprint(publicKey)`. -- Gateway sends nonce; device signs; gateway verifies. -- Tokens are issued to a public key (proof‑of‑possession), not a string. - -Alternatives: - -- mTLS (client certs): strongest, more ops complexity. -- Short‑lived bearer tokens only as a temporary phase (rotate + revoke early). - -## Silent approval (SSH heuristic) - -Define it precisely to avoid a weak link. Prefer one: - -- **Local‑only**: auto‑pair when client connects via loopback/Unix socket. -- **Challenge via SSH**: gateway issues nonce; client proves SSH by fetching it. -- **Physical presence window**: after a local approval on gateway host UI, allow auto‑pair for a short window (e.g. 10 minutes). - -Always log + record auto‑approvals. - ---- - -# TLS everywhere (dev + prod) - -## Reuse existing bridge TLS - -Use current TLS runtime + fingerprint pinning: - -- `src/infra/bridge/server/tls.ts` -- fingerprint verification logic in `src/node-host/bridge-client.ts` - -## Apply to WS - -- WS server supports TLS with same cert/key + fingerprint. -- WS clients can pin fingerprint (optional). -- Discovery advertises TLS + fingerprint for all endpoints. - - Discovery is locator hints only; never a trust anchor. - -## Why - -- Reduce reliance on SSH/Tailscale for confidentiality. -- Make remote mobile connections safe by default. - ---- - -# Approvals redesign (centralized) - -## Current - -Approval happens on node host (mac app node runtime). Prompt appears where node runs. - -## Proposed - -Approval is **gateway‑hosted**, UI delivered to operator clients. - -### New flow - -1. Gateway receives `system.run` intent (agent). -2. Gateway creates approval record: `approval.requested`. -3. Operator UI(s) show prompt. -4. Approval decision sent to gateway: `approval.resolve`. -5. Gateway invokes node command if approved. -6. Node executes, returns `invoke-res`. - -### Approval semantics (hardening) - -- Broadcast to all operators; only the active UI shows a modal (others get a toast). -- First resolution wins; gateway rejects subsequent resolves as already settled. -- Default timeout: deny after N seconds (e.g. 60s), log reason. -- Resolution requires `operator.approvals` scope. - -## Benefits - -- Prompt appears where user is (mac/phone). -- Consistent approvals for remote nodes. -- Node runtime stays headless; no UI dependency. - ---- - -# Role clarity examples - -## iPhone app - -- **Node role** for: mic, camera, voice chat, location, push‑to‑talk. -- Optional **operator.read** for status and chat view. -- Optional **operator.write/admin** only when explicitly enabled. - -## macOS app - -- Operator role by default (control UI). -- Node role when “Mac node” enabled (system.run, screen, camera). -- Same deviceId for both connections → merged UI entry. - -## CLI - -- Operator role always. -- Scope derived by subcommand: - - `status`, `logs` → read - - `agent`, `message` → write - - `config`, `channels` → admin - - approvals + pairing → `operator.approvals` / `operator.pairing` - ---- - -# Identity + slugs - -## Stable ID - -Required for auth; never changes. -Preferred: - -- Keypair fingerprint (public key hash). - -## Cute slug (lobster‑themed) - -Human label only. - -- Example: `scarlet-claw`, `saltwave`, `mantis-pinch`. -- Stored in gateway registry, editable. -- Collision handling: `-2`, `-3`. - -## UI grouping - -Same `deviceId` across roles → single “Instance” row: - -- Badge: `operator`, `node`. -- Shows capabilities + last seen. - ---- - -# Migration strategy - -## Phase 0: Document + align - -- Publish this doc. -- Inventory all protocol calls + approval flows. - -## Phase 1: Add roles/scopes to WS - -- Extend `connect` params with `role`, `scope`, `deviceId`. -- Add allowlist gating for node role. - -## Phase 2: Bridge compatibility - -- Keep bridge running. -- Add WS node support in parallel. -- Gate features behind config flag. - -## Phase 3: Central approvals - -- Add approval request + resolve events in WS. -- Update mac app UI to prompt + respond. -- Node runtime stops prompting UI. - -## Phase 4: TLS unification - -- Add TLS config for WS using bridge TLS runtime. -- Add pinning to clients. - -## Phase 5: Deprecate bridge - -- Migrate iOS/Android/mac node to WS. -- Keep bridge as fallback; remove once stable. - -## Phase 6: Device‑bound auth - -- Require key‑based identity for all non‑local connections. -- Add revocation + rotation UI. - ---- - -# Security notes - -- Role/allowlist enforced at gateway boundary. -- No client gets “full” API without operator scope. -- Pairing required for _all_ connections. -- TLS + pinning reduces MITM risk for mobile. -- SSH silent approval is a convenience; still recorded + revocable. -- Discovery is never a trust anchor. -- Capability claims are verified against server allowlists by platform/type. - -# Streaming + large payloads (node media) - -WS control plane is fine for small messages, but nodes also do: - -- camera clips -- screen recordings -- audio streams - -Options: - -1. WS binary frames + chunking + backpressure rules. -2. Separate streaming endpoint (still TLS + auth). -3. Keep bridge longer for media‑heavy commands, migrate last. - -Pick one before implementation to avoid drift. - -# Capability + command policy - -- Node‑reported caps/commands are treated as **claims**. -- Gateway enforces per‑platform allowlists. -- Any new command requires operator approval or explicit allowlist change. -- Audit changes with timestamps. - -# Audit + rate limiting - -- Log: pairing requests, approvals/denials, token issuance/rotation/revocation. -- Rate‑limit pairing spam and approval prompts. - -# Protocol hygiene - -- Explicit protocol version + error codes. -- Reconnect rules + heartbeat policy. -- Presence TTL and last‑seen semantics. - ---- - -# Open questions - -1. Single device running both roles: token model - - Recommend separate tokens per role (node vs operator). - - Same deviceId; different scopes; clearer revocation. - -2. Operator scope granularity - - read/write/admin + approvals + pairing (minimum viable). - - Consider per‑feature scopes later. - -3. Token rotation + revocation UX - - Auto‑rotate on role change. - - UI to revoke by deviceId + role. - -4. Discovery - - Extend current Bonjour TXT to include WS TLS fingerprint + role hints. - - Treat as locator hints only. - -5. Cross‑network approval - - Broadcast to all operator clients; active UI shows modal. - - First response wins; gateway enforces atomicity. - ---- - -# Summary (TL;DR) - -- Today: WS control plane + Bridge node transport. -- Pain: approvals + duplication + two stacks. -- Proposal: one WS protocol with explicit roles + scopes, unified pairing + TLS pinning, gateway‑hosted approvals, stable device IDs + cute slugs. -- Outcome: simpler UX, stronger security, less duplication, better mobile routing. diff --git a/docs/refactor/cluster.md b/docs/refactor/cluster.md deleted file mode 100644 index db2d9b1276f..00000000000 --- a/docs/refactor/cluster.md +++ /dev/null @@ -1,299 +0,0 @@ ---- -summary: "Refactor clusters with highest LOC reduction potential" -read_when: - - You want to reduce total LOC without changing behavior - - You are choosing the next dedupe or extraction pass -title: "Refactor Cluster Backlog" ---- - -# Refactor Cluster Backlog - -Ranked by likely LOC reduction, safety, and breadth. - -## 1. Channel plugin config and security scaffolding - -Highest-value cluster. - -Repeated shapes across many channel plugins: - -- `config.listAccountIds` -- `config.resolveAccount` -- `config.defaultAccountId` -- `config.setAccountEnabled` -- `config.deleteAccount` -- `config.describeAccount` -- `security.resolveDmPolicy` - -Strong examples: - -- `extensions/telegram/src/channel.ts` -- `extensions/googlechat/src/channel.ts` -- `extensions/slack/src/channel.ts` -- `extensions/discord/src/channel.ts` -- `extensions/matrix/src/channel.ts` -- `extensions/irc/src/channel.ts` -- `extensions/signal/src/channel.ts` -- `extensions/mattermost/src/channel.ts` - -Likely extraction shape: - -- `buildChannelConfigAdapter(...)` -- `buildMultiAccountConfigAdapter(...)` -- `buildDmSecurityAdapter(...)` - -Expected savings: - -- ~250-450 LOC - -Risk: - -- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization. - -## 2. Extension runtime singleton boilerplate - -Very safe. - -Nearly every extension has the same runtime holder: - -- `let runtime: PluginRuntime | null = null` -- `setXRuntime` -- `getXRuntime` - -Strong examples: - -- `extensions/telegram/src/runtime.ts` -- `extensions/matrix/src/runtime.ts` -- `extensions/slack/src/runtime.ts` -- `extensions/discord/src/runtime.ts` -- `extensions/whatsapp/src/runtime.ts` -- `extensions/imessage/src/runtime.ts` -- `extensions/twitch/src/runtime.ts` - -Special-case variants: - -- `extensions/bluebubbles/src/runtime.ts` -- `extensions/line/src/runtime.ts` -- `extensions/synology-chat/src/runtime.ts` - -Likely extraction shape: - -- `createPluginRuntimeStore(errorMessage)` - -Expected savings: - -- ~180-260 LOC - -Risk: - -- Low - -## 3. Setup prompt and config-patch steps - -Large surface area. - -Many setup files repeat: - -- resolve account id -- prompt allowlist entries -- merge allowFrom -- set DM policy -- prompt secrets -- patch top-level vs account-scoped config - -Strong examples: - -- `extensions/bluebubbles/src/setup-surface.ts` -- `extensions/googlechat/src/setup-surface.ts` -- `extensions/msteams/src/setup-surface.ts` -- `extensions/zalo/src/setup-surface.ts` -- `extensions/zalouser/src/setup-surface.ts` -- `extensions/nextcloud-talk/src/setup-surface.ts` -- `extensions/matrix/src/setup-surface.ts` -- `extensions/irc/src/setup-surface.ts` - -Existing helper surface: - -- `src/channels/plugins/setup-wizard-helpers.ts` - -Likely extraction shape: - -- `promptAllowFromList(...)` -- `buildDmPolicyAdapter(...)` -- `applyScopedAccountPatch(...)` -- `promptSecretFields(...)` - -Expected savings: - -- ~300-600 LOC - -Risk: - -- Medium. Easy to over-generalize; keep helpers narrow and composable. - -## 4. Multi-account config-schema fragments - -Repeated schema fragments across extensions. - -Common patterns: - -- `const allowFromEntry = z.union([z.string(), z.number()])` -- account schema plus: - - `accounts: z.object({}).catchall(accountSchema).optional()` - - `defaultAccount: z.string().optional()` -- repeated DM/group fields -- repeated markdown/tool policy fields - -Strong examples: - -- `extensions/bluebubbles/src/config-schema.ts` -- `extensions/zalo/src/config-schema.ts` -- `extensions/zalouser/src/config-schema.ts` -- `extensions/matrix/src/config-schema.ts` -- `extensions/nostr/src/config-schema.ts` - -Likely extraction shape: - -- `AllowFromEntrySchema` -- `buildMultiAccountChannelSchema(accountSchema)` -- `buildCommonDmGroupFields(...)` - -Expected savings: - -- ~120-220 LOC - -Risk: - -- Low to medium. Some schemas are simple, some are special. - -## 5. Webhook and monitor lifecycle startup - -Good medium-value cluster. - -Repeated `startAccount` / monitor setup patterns: - -- resolve account -- compute webhook path -- log startup -- start monitor -- wait for abort -- cleanup -- status sink updates - -Strong examples: - -- `extensions/googlechat/src/channel.ts` -- `extensions/bluebubbles/src/channel.ts` -- `extensions/zalo/src/channel.ts` -- `extensions/telegram/src/channel.ts` -- `extensions/nextcloud-talk/src/channel.ts` - -Existing helper surface: - -- `src/plugin-sdk/channel-lifecycle.ts` - -Likely extraction shape: - -- helper for account monitor lifecycle -- helper for webhook-backed account startup - -Expected savings: - -- ~150-300 LOC - -Risk: - -- Medium to high. Transport details diverge quickly. - -## 6. Small exact-clone cleanup - -Low-risk cleanup bucket. - -Examples: - -- duplicated gateway argv detection: - - `src/infra/gateway-lock.ts` - - `src/cli/daemon-cli/lifecycle.ts` -- duplicated port diagnostics rendering: - - `src/cli/daemon-cli/restart-health.ts` -- duplicated session-key construction: - - `src/web/auto-reply/monitor/broadcast.ts` - -Expected savings: - -- ~30-60 LOC - -Risk: - -- Low - -## Test clusters - -### LINE webhook event fixtures - -Strong examples: - -- `src/line/bot-handlers.test.ts` - -Likely extraction: - -- `makeLineEvent(...)` -- `runLineEvent(...)` -- `makeLineAccount(...)` - -Expected savings: - -- ~120-180 LOC - -### Telegram native command auth matrix - -Strong examples: - -- `src/telegram/bot-native-commands.group-auth.test.ts` -- `src/telegram/bot-native-commands.plugin-auth.test.ts` - -Likely extraction: - -- forum context builder -- denied-message assertion helper -- table-driven auth cases - -Expected savings: - -- ~80-140 LOC - -### Zalo lifecycle setup - -Strong examples: - -- `extensions/zalo/src/monitor.lifecycle.test.ts` - -Likely extraction: - -- shared monitor setup harness - -Expected savings: - -- ~50-90 LOC - -### Brave llm-context unsupported-option tests - -Strong examples: - -- `src/agents/tools/web-tools.enabled-defaults.test.ts` - -Likely extraction: - -- `it.each(...)` matrix - -Expected savings: - -- ~30-50 LOC - -## Suggested order - -1. Runtime singleton boilerplate -2. Small exact-clone cleanup -3. Config and security builder extraction -4. Test-helper extraction -5. Onboarding step extraction -6. Monitor lifecycle helper extraction diff --git a/docs/refactor/exec-host.md b/docs/refactor/exec-host.md deleted file mode 100644 index a70cf7c9dbd..00000000000 --- a/docs/refactor/exec-host.md +++ /dev/null @@ -1,316 +0,0 @@ ---- -summary: "Refactor plan: exec host routing, node approvals, and headless runner" -read_when: - - Designing exec host routing or exec approvals - - Implementing node runner + UI IPC - - Adding exec host security modes and slash commands -title: "Exec Host Refactor" ---- - -# Exec host refactor plan - -## Goals - -- Add `exec.host` + `exec.security` to route execution across **sandbox**, **gateway**, and **node**. -- Keep defaults **safe**: no cross-host execution unless explicitly enabled. -- Split execution into a **headless runner service** with optional UI (macOS app) via local IPC. -- Provide **per-agent** policy, allowlist, ask mode, and node binding. -- Support **ask modes** that work _with_ or _without_ allowlists. -- Cross-platform: Unix socket + token auth (macOS/Linux/Windows parity). - -## Non-goals - -- No legacy allowlist migration or legacy schema support. -- No PTY/streaming for node exec (aggregated output only). -- No new network layer beyond the existing Bridge + Gateway. - -## Decisions (locked) - -- **Config keys:** `exec.host` + `exec.security` (per-agent override allowed). -- **Elevation:** keep `/elevated` as an alias for gateway full access. -- **Ask default:** `on-miss`. -- **Approvals store:** `~/.openclaw/exec-approvals.json` (JSON, no legacy migration). -- **Runner:** headless system service; UI app hosts a Unix socket for approvals. -- **Node identity:** use existing `nodeId`. -- **Socket auth:** Unix socket + token (cross-platform); split later if needed. -- **Node host state:** `~/.openclaw/node.json` (node id + pairing token). -- **macOS exec host:** run `system.run` inside the macOS app; node host service forwards requests over local IPC. -- **No XPC helper:** stick to Unix socket + token + peer checks. - -## Key concepts - -### Host - -- `sandbox`: Docker exec (current behavior). -- `gateway`: exec on gateway host. -- `node`: exec on node runner via Bridge (`system.run`). - -### Security mode - -- `deny`: always block. -- `allowlist`: allow only matches. -- `full`: allow everything (equivalent to elevated). - -### Ask mode - -- `off`: never ask. -- `on-miss`: ask only when allowlist does not match. -- `always`: ask every time. - -Ask is **independent** of allowlist; allowlist can be used with `always` or `on-miss`. - -### Policy resolution (per exec) - -1. Resolve `exec.host` (tool param → agent override → global default). -2. Resolve `exec.security` and `exec.ask` (same precedence). -3. If host is `sandbox`, proceed with local sandbox exec. -4. If host is `gateway` or `node`, apply security + ask policy on that host. - -## Default safety - -- Default `exec.host = sandbox`. -- Default `exec.security = deny` for `gateway` and `node`. -- Default `exec.ask = on-miss` (only relevant if security allows). -- If no node binding is set, **agent may target any node**, but only if policy allows it. - -## Config surface - -### Tool parameters - -- `exec.host` (optional): `sandbox | gateway | node`. -- `exec.security` (optional): `deny | allowlist | full`. -- `exec.ask` (optional): `off | on-miss | always`. -- `exec.node` (optional): node id/name to use when `host=node`. - -### Config keys (global) - -- `tools.exec.host` -- `tools.exec.security` -- `tools.exec.ask` -- `tools.exec.node` (default node binding) - -### Config keys (per agent) - -- `agents.list[].tools.exec.host` -- `agents.list[].tools.exec.security` -- `agents.list[].tools.exec.ask` -- `agents.list[].tools.exec.node` - -### Alias - -- `/elevated on` = set `tools.exec.host=gateway`, `tools.exec.security=full` for the agent session. -- `/elevated off` = restore previous exec settings for the agent session. - -## Approvals store (JSON) - -Path: `~/.openclaw/exec-approvals.json` - -Purpose: - -- Local policy + allowlists for the **execution host** (gateway or node runner). -- Ask fallback when no UI is available. -- IPC credentials for UI clients. - -Proposed schema (v1): - -```json -{ - "version": 1, - "socket": { - "path": "~/.openclaw/exec-approvals.sock", - "token": "base64-opaque-token" - }, - "defaults": { - "security": "deny", - "ask": "on-miss", - "askFallback": "deny" - }, - "agents": { - "agent-id-1": { - "security": "allowlist", - "ask": "on-miss", - "allowlist": [ - { - "pattern": "~/Projects/**/bin/rg", - "lastUsedAt": 0, - "lastUsedCommand": "rg -n TODO", - "lastResolvedPath": "/Users/user/Projects/.../bin/rg" - } - ] - } - } -} -``` - -Notes: - -- No legacy allowlist formats. -- `askFallback` applies only when `ask` is required and no UI is reachable. -- File permissions: `0600`. - -## Runner service (headless) - -### Role - -- Enforce `exec.security` + `exec.ask` locally. -- Execute system commands and return output. -- Emit Bridge events for exec lifecycle (optional but recommended). - -### Service lifecycle - -- Launchd/daemon on macOS; system service on Linux/Windows. -- Approvals JSON is local to the execution host. -- UI hosts a local Unix socket; runners connect on demand. - -## UI integration (macOS app) - -### IPC - -- Unix socket at `~/.openclaw/exec-approvals.sock` (0600). -- Token stored in `exec-approvals.json` (0600). -- Peer checks: same-UID only. -- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay. -- Short TTL (e.g., 10s) + max payload + rate limit. - -### Ask flow (macOS app exec host) - -1. Node service receives `system.run` from gateway. -2. Node service connects to the local socket and sends the prompt/exec request. -3. App validates peer + token + HMAC + TTL, then shows dialog if needed. -4. App executes the command in UI context and returns output. -5. Node service returns output to gateway. - -If UI missing: - -- Apply `askFallback` (`deny|allowlist|full`). - -### Diagram (SCI) - -``` -Agent -> Gateway -> Bridge -> Node Service (TS) - | IPC (UDS + token + HMAC + TTL) - v - Mac App (UI + TCC + system.run) -``` - -## Node identity + binding - -- Use existing `nodeId` from Bridge pairing. -- Binding model: - - `tools.exec.node` restricts the agent to a specific node. - - If unset, agent can pick any node (policy still enforces defaults). -- Node selection resolution: - - `nodeId` exact match - - `displayName` (normalized) - - `remoteIp` - - `nodeId` prefix (>= 6 chars) - -## Eventing - -### Who sees events - -- System events are **per session** and shown to the agent on the next prompt. -- Stored in the gateway in-memory queue (`enqueueSystemEvent`). - -### Event text - -- `Exec started (node=, id=)` -- `Exec finished (node=, id=, code=)` + optional output tail -- `Exec denied (node=, id=, )` - -### Transport - -Option A (recommended): - -- Runner sends Bridge `event` frames `exec.started` / `exec.finished`. -- Gateway `handleBridgeEvent` maps these into `enqueueSystemEvent`. - -Option B: - -- Gateway `exec` tool handles lifecycle directly (synchronous only). - -## Exec flows - -### Sandbox host - -- Existing `exec` behavior (Docker or host when unsandboxed). -- PTY supported in non-sandbox mode only. - -### Gateway host - -- Gateway process executes on its own machine. -- Enforces local `exec-approvals.json` (security/ask/allowlist). - -### Node host - -- Gateway calls `node.invoke` with `system.run`. -- Runner enforces local approvals. -- Runner returns aggregated stdout/stderr. -- Optional Bridge events for start/finish/deny. - -## Output caps - -- Cap combined stdout+stderr at **200k**; keep **tail 20k** for events. -- Truncate with a clear suffix (e.g., `"… (truncated)"`). - -## Slash commands - -- `/exec host= security= ask= node=` -- Per-agent, per-session overrides; non-persistent unless saved via config. -- `/elevated on|off|ask|full` remains a shortcut for `host=gateway security=full` (with `full` skipping approvals). - -## Cross-platform story - -- The runner service is the portable execution target. -- UI is optional; if missing, `askFallback` applies. -- Windows/Linux support the same approvals JSON + socket protocol. - -## Implementation phases - -### Phase 1: config + exec routing - -- Add config schema for `exec.host`, `exec.security`, `exec.ask`, `exec.node`. -- Update tool plumbing to respect `exec.host`. -- Add `/exec` slash command and keep `/elevated` alias. - -### Phase 2: approvals store + gateway enforcement - -- Implement `exec-approvals.json` reader/writer. -- Enforce allowlist + ask modes for `gateway` host. -- Add output caps. - -### Phase 3: node runner enforcement - -- Update node runner to enforce allowlist + ask. -- Add Unix socket prompt bridge to macOS app UI. -- Wire `askFallback`. - -### Phase 4: events - -- Add node → gateway Bridge events for exec lifecycle. -- Map to `enqueueSystemEvent` for agent prompts. - -### Phase 5: UI polish - -- Mac app: allowlist editor, per-agent switcher, ask policy UI. -- Node binding controls (optional). - -## Testing plan - -- Unit tests: allowlist matching (glob + case-insensitive). -- Unit tests: policy resolution precedence (tool param → agent override → global). -- Integration tests: node runner deny/allow/ask flows. -- Bridge event tests: node event → system event routing. - -## Open risks - -- UI unavailability: ensure `askFallback` is respected. -- Long-running commands: rely on timeout + output caps. -- Multi-node ambiguity: error unless node binding or explicit node param. - -## Related docs - -- [Exec tool](/tools/exec) -- [Exec approvals](/tools/exec-approvals) -- [Nodes](/nodes) -- [Elevated mode](/tools/elevated) diff --git a/docs/refactor/firecrawl-extension.md b/docs/refactor/firecrawl-extension.md deleted file mode 100644 index 273f9667916..00000000000 --- a/docs/refactor/firecrawl-extension.md +++ /dev/null @@ -1,260 +0,0 @@ ---- -summary: "Design for an opt-in Firecrawl extension that adds search/scrape value without hardwiring Firecrawl into core defaults" -read_when: - - Designing Firecrawl integration work - - Evaluating web_search/web_fetch plugin extension surfaces - - Deciding whether Firecrawl belongs in core or as an extension -title: "Firecrawl Extension Design" ---- - -# Firecrawl Extension Design - -## Goal - -Ship Firecrawl as an **opt-in extension** that adds: - -- explicit Firecrawl tools for agents, -- optional Firecrawl-backed `web_search` integration, -- self-hosted support, -- stronger security defaults than the current core fallback path, - -without pushing Firecrawl into the default setup/onboarding path. - -## Why this shape - -Recent Firecrawl issues/PRs cluster into three buckets: - -1. **Release/schema drift** - - Several releases rejected `tools.web.fetch.firecrawl` even though docs and runtime code supported it. -2. **Security hardening** - - Current `fetchFirecrawlContent()` still posts to the Firecrawl endpoint with raw `fetch()`, while the main web-fetch path uses the SSRF guard. -3. **Product pressure** - - Users want Firecrawl-native search/scrape flows, especially for self-hosted/private setups. - - Maintainers explicitly rejected wiring Firecrawl deeply into core defaults, setup flow, and browser behavior. - -That combination argues for an extension, not more Firecrawl-specific logic in the default core path. - -## Design principles - -- **Opt-in, vendor-scoped**: no auto-enable, no setup hijack, no default tool-profile widening. -- **Extension owns Firecrawl-specific config**: prefer plugin config over growing `tools.web.*` again. -- **Useful on day one**: works even if core `web_search` / `web_fetch` extension surfaces stay unchanged. -- **Security-first**: endpoint fetches use the same guarded networking posture as other web tools. -- **Self-hosted-friendly**: config + env fallback, explicit base URL, no hosted-only assumptions. - -## Proposed extension - -Plugin id: `firecrawl` - -### MVP capabilities - -Register explicit tools: - -- `firecrawl_search` -- `firecrawl_scrape` - -Optional later: - -- `firecrawl_crawl` -- `firecrawl_map` - -Do **not** add Firecrawl browser automation in the first version. That was the part of PR #32543 that pulled Firecrawl too far into core behavior and raised the most maintainership concern. - -## Config shape - -Use plugin-scoped config: - -```json5 -{ - plugins: { - entries: { - firecrawl: { - enabled: true, - config: { - apiKey: "FIRECRAWL_API_KEY", - baseUrl: "https://api.firecrawl.dev", - timeoutSeconds: 60, - maxAgeMs: 172800000, - proxy: "auto", - storeInCache: true, - onlyMainContent: true, - search: { - enabled: true, - defaultLimit: 5, - sources: ["web"], - categories: [], - scrapeResults: false, - }, - scrape: { - formats: ["markdown"], - fallbackForWebFetchLikeUse: false, - }, - }, - }, - }, - }, -} -``` - -### Credential resolution - -Precedence: - -1. `plugins.entries.firecrawl.config.apiKey` -2. `FIRECRAWL_API_KEY` - -Base URL precedence: - -1. `plugins.entries.firecrawl.config.baseUrl` -2. `FIRECRAWL_BASE_URL` -3. `https://api.firecrawl.dev` - -### Compatibility bridge - -For the first release, the extension may also **read** existing core config at `tools.web.fetch.firecrawl.*` as a fallback source so existing users do not need to migrate immediately. - -Write path stays plugin-local. Do not keep expanding core Firecrawl config surfaces. - -## Tool design - -### `firecrawl_search` - -Inputs: - -- `query` -- `limit` -- `sources` -- `categories` -- `scrapeResults` -- `timeoutSeconds` - -Behavior: - -- Calls Firecrawl `v2/search` -- Returns normalized OpenClaw-friendly result objects: - - `title` - - `url` - - `snippet` - - `source` - - optional `content` -- Wraps result content as untrusted external content -- Cache key includes query + relevant provider params - -Why explicit tool first: - -- Works today without changing `tools.web.search.provider` -- Avoids current schema/loader constraints -- Gives users Firecrawl value immediately - -### `firecrawl_scrape` - -Inputs: - -- `url` -- `formats` -- `onlyMainContent` -- `maxAgeMs` -- `proxy` -- `storeInCache` -- `timeoutSeconds` - -Behavior: - -- Calls Firecrawl `v2/scrape` -- Returns markdown/text plus metadata: - - `title` - - `finalUrl` - - `status` - - `warning` -- Wraps extracted content the same way `web_fetch` does -- Shares cache semantics with web tool expectations where practical - -Why explicit scrape tool: - -- Sidesteps the unresolved `Readability -> Firecrawl -> basic HTML cleanup` ordering bug in core `web_fetch` -- Gives users a deterministic “always use Firecrawl” path for JS-heavy/bot-protected sites - -## What the extension should not do - -- No auto-adding `browser`, `web_search`, or `web_fetch` to `tools.alsoAllow` -- No default onboarding step in `openclaw setup` -- No Firecrawl-specific browser session lifecycle in core -- No change to built-in `web_fetch` fallback semantics in the extension MVP - -## Phase plan - -### Phase 1: extension-only, no core schema changes - -Implement: - -- `extensions/firecrawl/` -- plugin config schema -- `firecrawl_search` -- `firecrawl_scrape` -- tests for config resolution, endpoint selection, caching, error handling, and SSRF guard usage - -This phase is enough to ship real user value. - -### Phase 2: optional `web_search` provider integration - -Support `tools.web.search.provider = "firecrawl"` only after fixing two core constraints: - -1. `src/plugins/web-search-providers.ts` must load configured/installed web-search-provider plugins instead of a hardcoded bundled list. -2. `src/config/types.tools.ts` and `src/config/zod-schema.agent-runtime.ts` must stop hardcoding the provider enum in a way that blocks plugin-registered ids. - -Recommended shape: - -- keep built-in providers documented, -- allow any registered plugin provider id at runtime, -- validate provider-specific config via the provider plugin or a generic provider bag. - -### Phase 3: optional `web_fetch` provider capability - -Do this only if maintainers want vendor-specific fetch backends to participate in `web_fetch`. - -Needed core addition: - -- `registerWebFetchProvider` or equivalent fetch-backend extension surface - -Without that capability, the extension should keep `firecrawl_scrape` as an explicit tool rather than trying to patch built-in `web_fetch`. - -## Security requirements - -The extension must treat Firecrawl as a **trusted operator-configured endpoint**, but still harden transport: - -- Use SSRF-guarded fetch for the Firecrawl endpoint call, not raw `fetch()` -- Preserve self-hosted/private-network compatibility using the same trusted-web-tools endpoint policy used elsewhere -- Never log the API key -- Keep endpoint/base URL resolution explicit and predictable -- Treat Firecrawl-returned content as untrusted external content - -This mirrors the intent behind the SSRF hardening PRs without assuming Firecrawl is a hostile multi-tenant surface. - -## Why not a skill - -The repo already closed a Firecrawl skill PR in favor of ClawHub distribution. That is fine for optional user-installed prompt workflows, but it does not solve: - -- deterministic tool availability, -- provider-grade config/credential handling, -- self-hosted endpoint support, -- caching, -- stable typed outputs, -- security review on network behavior. - -This belongs as an extension, not a prompt-only skill. - -## Success criteria - -- Users can install/enable one extension and get reliable Firecrawl search/scrape without touching core defaults. -- Self-hosted Firecrawl works with config/env fallback. -- Extension endpoint fetches use guarded networking. -- No new Firecrawl-specific core onboarding/default behavior. -- Core can later adopt plugin-native `web_search` / `web_fetch` extension surfaces without redesigning the extension. - -## Recommended implementation order - -1. Build `firecrawl_scrape` -2. Build `firecrawl_search` -3. Add docs and examples -4. If desired, generalize `web_search` provider loading so the extension can back `web_search` -5. Only then consider a true `web_fetch` provider capability diff --git a/docs/refactor/outbound-session-mirroring.md b/docs/refactor/outbound-session-mirroring.md deleted file mode 100644 index 4f712541658..00000000000 --- a/docs/refactor/outbound-session-mirroring.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Outbound Session Mirroring Refactor (Issue #1520) -description: Track outbound session mirroring refactor notes, decisions, tests, and open items. -summary: "Refactor notes for mirroring outbound sends into target channel sessions" -read_when: - - Working on outbound transcript/session mirroring behavior - - Debugging sessionKey derivation for send/message tool paths ---- - -# Outbound Session Mirroring Refactor (Issue #1520) - -## Status - -- In progress. -- Core + plugin channel routing updated for outbound mirroring. -- Gateway send now derives target session when sessionKey is omitted. - -## Context - -Outbound sends were mirrored into the _current_ agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries. - -## Goals - -- Mirror outbound messages into the target channel session key. -- Create session entries on outbound when missing. -- Keep thread/topic scoping aligned with inbound session keys. -- Cover core channels plus bundled extensions. - -## Implementation Summary - -- New outbound session routing helper: - - `src/infra/outbound/outbound-session.ts` - - `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks). - - `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`. -- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring. -- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key. -- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey. -- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry. - -## Thread/Topic Handling - -- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix). -- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session). -- Telegram: topic IDs map to `chatId:topic:` via `buildTelegramGroupPeerId`. - -## Extensions Covered - -- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon. -- Notes: - - Mattermost targets now strip `@` for DM session key routing. - - Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present). - - BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys. - - Slack auto-thread mirroring matches channel ids case-insensitively. - - Gateway send lowercases provided session keys before mirroring. - -## Decisions - -- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there. -- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats. -- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available. -- **Session key casing**: canonicalize session keys to lowercase on write and during migrations. - -## Tests Added/Updated - -- `src/infra/outbound/outbound.test.ts` - - Slack thread session key. - - Telegram topic session key. - - dmScope identityLinks with Discord. -- `src/agents/tools/message-tool.test.ts` - - Derives agentId from session key (no sessionKey passed through). -- `src/gateway/server-methods/send.test.ts` - - Derives session key when omitted and creates session entry. - -## Open Items / Follow-ups - -- Voice-call plugin uses custom `voice:` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping. -- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set. - -## Files Touched - -- `src/infra/outbound/outbound-session.ts` -- `src/infra/outbound/outbound-send-service.ts` -- `src/infra/outbound/message-action-runner.ts` -- `src/agents/tools/message-tool.ts` -- `src/gateway/server-methods/send.ts` -- Tests in: - - `src/infra/outbound/outbound.test.ts` - - `src/agents/tools/message-tool.test.ts` - - `src/gateway/server-methods/send.test.ts` diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md deleted file mode 100644 index edf79de266d..00000000000 --- a/docs/refactor/plugin-sdk.md +++ /dev/null @@ -1,264 +0,0 @@ ---- -summary: "Plan: one clean plugin SDK + runtime for all messaging connectors" -read_when: - - Defining or refactoring the plugin architecture - - Migrating channel connectors to the plugin SDK/runtime -title: "Plugin SDK Refactor" ---- - -# Plugin SDK + Runtime Refactor Plan - -Goal: every messaging connector is a plugin (bundled or external) using one stable API. -No plugin imports from `src/**` directly. All dependencies go through the SDK or runtime. - -## Why now - -- Current connectors mix patterns: direct core imports, dist-only bridges, and custom helpers. -- This makes upgrades brittle and blocks a clean external plugin surface. - -## Target architecture (two layers) - -### 1) Plugin SDK (compile-time, stable, publishable) - -Scope: types, helpers, and config utilities. No runtime state, no side effects. - -Contents (examples): - -- Types: `ChannelPlugin`, adapters, `ChannelMeta`, `ChannelCapabilities`, `ChannelDirectoryEntry`. -- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`, - `applyAccountNameToChannelSection`. -- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`. -- Setup entry points: host-owned `setup` + `setupWizard`; avoid broad public onboarding helpers. -- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`. -- Docs link helper: `formatDocsLink`. - -Delivery: - -- Publish as `openclaw/plugin-sdk` (or export from core under `openclaw/plugin-sdk`). -- Semver with explicit stability guarantees. - -### 2) Plugin Runtime (execution surface, injected) - -Scope: everything that touches core runtime behavior. -Accessed via `OpenClawPluginApi.runtime` so plugins never import `src/**`. - -Proposed surface (minimal but complete): - -```ts -export type PluginRuntime = { - channel: { - text: { - chunkMarkdownText(text: string, limit: number): string[]; - resolveTextChunkLimit(cfg: OpenClawConfig, channel: string, accountId?: string): number; - hasControlCommand(text: string, cfg: OpenClawConfig): boolean; - }; - reply: { - dispatchReplyWithBufferedBlockDispatcher(params: { - ctx: unknown; - cfg: unknown; - dispatcherOptions: { - deliver: (payload: { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - }) => void | Promise; - onError?: (err: unknown, info: { kind: string }) => void; - }; - }): Promise; - createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows - }; - routing: { - resolveAgentRoute(params: { - cfg: unknown; - channel: string; - accountId: string; - peer: { kind: RoutePeerKind; id: string }; - }): { sessionKey: string; accountId: string }; - }; - pairing: { - buildPairingReply(params: { channel: string; idLine: string; code: string }): string; - readAllowFromStore(channel: string): Promise; - upsertPairingRequest(params: { - channel: string; - id: string; - meta?: { name?: string }; - }): Promise<{ code: string; created: boolean }>; - }; - media: { - fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>; - saveMediaBuffer( - buffer: Uint8Array, - contentType: string | undefined, - direction: "inbound" | "outbound", - maxBytes: number, - ): Promise<{ path: string; contentType?: string }>; - }; - mentions: { - buildMentionRegexes(cfg: OpenClawConfig, agentId?: string): RegExp[]; - matchesMentionPatterns(text: string, regexes: RegExp[]): boolean; - }; - groups: { - resolveGroupPolicy( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - ): { - allowlistEnabled: boolean; - allowed: boolean; - groupConfig?: unknown; - defaultConfig?: unknown; - }; - resolveRequireMention( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - override?: boolean, - ): boolean; - }; - debounce: { - createInboundDebouncer(opts: { - debounceMs: number; - buildKey: (v: T) => string | null; - shouldDebounce: (v: T) => boolean; - onFlush: (entries: T[]) => Promise; - onError?: (err: unknown) => void; - }): { push: (v: T) => void; flush: () => Promise }; - resolveInboundDebounceMs(cfg: OpenClawConfig, channel: string): number; - }; - commands: { - resolveCommandAuthorizedFromAuthorizers(params: { - useAccessGroups: boolean; - authorizers: Array<{ configured: boolean; allowed: boolean }>; - }): boolean; - }; - }; - logging: { - shouldLogVerbose(): boolean; - getChildLogger(name: string): PluginLogger; - }; - state: { - resolveStateDir(cfg: OpenClawConfig): string; - }; -}; -``` - -Notes: - -- Runtime is the only way to access core behavior. -- SDK is intentionally small and stable. -- Each runtime method maps to an existing core implementation (no duplication). - -## Migration plan (phased, safe) - -### Phase 0: scaffolding - -- Introduce `openclaw/plugin-sdk`. -- Add `api.runtime` to `OpenClawPluginApi` with the surface above. -- Maintain existing imports during a transition window (deprecation warnings). - -### Phase 1: bridge cleanup (low risk) - -- Replace per-extension `core-bridge.ts` with `api.runtime`. -- Migrate BlueBubbles, Zalo, Zalo Personal first (already close). -- Remove duplicated bridge code. - -### Phase 2: light direct-import plugins - -- Migrate Matrix to SDK + runtime. -- Validate onboarding, directory, group mention logic. - -### Phase 3: heavy direct-import plugins - -- Migrate MS Teams (largest set of runtime helpers). -- Ensure reply/typing semantics match current behavior. - -### Phase 4: iMessage pluginization - -- Move iMessage into `extensions/imessage`. -- Replace direct core calls with `api.runtime`. -- Keep config keys, CLI behavior, and docs intact. - -### Phase 5: enforcement - -- Add lint rule / CI check: no `extensions/**` imports from `src/**`. -- Add plugin SDK/version compatibility checks (runtime + SDK semver). - -## Compatibility and versioning - -- SDK: semver, published, documented changes. -- Runtime: versioned per core release. Add `api.runtime.version`. -- Plugins declare a required runtime range (e.g., `openclawRuntime: ">=2026.2.0"`). - -## Testing strategy - -- Adapter-level unit tests (runtime functions exercised with real core implementation). -- Golden tests per plugin: ensure no behavior drift (routing, pairing, allowlist, mention gating). -- A single end-to-end plugin sample used in CI (install + run + smoke). - -## Open questions - -- Where to host SDK types: separate package or core export? -- Runtime type distribution: in SDK (types only) or in core? -- How to expose docs links for bundled vs external plugins? -- Do we allow limited direct core imports for in-repo plugins during transition? - -## Success criteria - -- All channel connectors are plugins using SDK + runtime. -- No `extensions/**` imports from `src/**`. -- New connector templates depend only on SDK + runtime. -- External plugins can be developed and updated without core source access. - -Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration). - -## Capability plan alignment - -The plugin SDK refactor now aligns with the public capability model documented -in [Plugins](/tools/plugin#public-capability-model). - -Key decisions: - -- Capabilities are the public plugin model. Registration is explicit and typed. -- Legacy hook-only plugins remain supported without migration. -- Plugin shapes (plain-capability, hybrid-capability, hook-only, non-capability) - are classified from actual registration behavior. -- `openclaw plugins inspect` provides canonical deep introspection for any - loaded plugin, showing shape, capabilities, hooks, tools, and diagnostics. -- Export boundary: export capabilities, not implementation convenience. Trim - non-contract helper exports. - -Required test matrix for the capability model: - -- hook-only legacy plugin fixture -- plain capability plugin fixture -- hybrid capability plugin fixture -- real-world legacy hook-style plugin fixture -- `before_agent_start` still works -- typed hooks remain additive -- capability usage and plugin shape are inspectable - -## Implemented channel-owned capabilities - -Recent refactor work widened the channel plugin contract so core can stop owning -channel-specific UX and routing behavior: - -- `messaging.buildCrossContextComponents`: channel-owned cross-context UI markers - (for example Discord components v2 containers) -- `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles - (for example Slack interactive replies) -- `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing -- `status.formatCapabilitiesProbe` / `status.buildCapabilitiesDiagnostics`: channel-owned - `/channels capabilities` probe display and extra audits/scopes -- `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading -- `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping -- `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates -- `execApprovals.*`: channel-owned exec approval surface state, forwarding suppression, - pending payload UX, and pre-delivery hooks -- `lifecycle.onAccountConfigChanged` / `lifecycle.onAccountRemoved`: channel-owned cleanup on - config mutation/removal -- `allowlist.supportsScope`: channel-owned allowlist scope advertisement - -These capabilities should be preferred over new `channel === "discord"` / -`telegram` branches in shared core flows. diff --git a/docs/refactor/strict-config.md b/docs/refactor/strict-config.md deleted file mode 100644 index 9605730c2b0..00000000000 --- a/docs/refactor/strict-config.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -summary: "Strict config validation + doctor-only migrations" -read_when: - - Designing or implementing config validation behavior - - Working on config migrations or doctor workflows - - Handling plugin config schemas or plugin load gating -title: "Strict Config Validation" ---- - -# Strict config validation (doctor-only migrations) - -## Goals - -- **Reject unknown config keys everywhere** (root + nested), except root `$schema` metadata. -- **Reject plugin config without a schema**; don’t load that plugin. -- **Remove legacy auto-migration on load**; migrations run via doctor only. -- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands. - -## Non-goals - -- Backward compatibility on load (legacy keys do not auto-migrate). -- Silent drops of unrecognized keys. - -## Strict validation rules - -- Config must match the schema exactly at every level. -- Unknown keys are validation errors (no passthrough at root or nested), except root `$schema` when it is a string. -- `plugins.entries..config` must be validated by the plugin’s schema. - - If a plugin lacks a schema, **reject plugin load** and surface a clear error. -- Unknown `channels.` keys are errors unless a plugin manifest declares the channel id. -- Plugin manifests (`openclaw.plugin.json`) are required for all plugins. - -## Plugin schema enforcement - -- Each plugin provides a strict JSON Schema for its config (inline in the manifest). -- Plugin load flow: - 1. Resolve plugin manifest + schema (`openclaw.plugin.json`). - 2. Validate config against the schema. - 3. If missing schema or invalid config: block plugin load, record error. -- Error message includes: - - Plugin id - - Reason (missing schema / invalid config) - - Path(s) that failed validation -- Disabled plugins keep their config, but Doctor + logs surface a warning. - -## Doctor flow - -- Doctor runs **every time** config is loaded (dry-run by default). -- If config invalid: - - Print a summary + actionable errors. - - Instruct: `openclaw doctor --fix`. -- `openclaw doctor --fix`: - - Applies migrations. - - Removes unknown keys. - - Writes updated config. - -## Command gating (when config is invalid) - -Allowed (diagnostic-only): - -- `openclaw doctor` -- `openclaw logs` -- `openclaw health` -- `openclaw help` -- `openclaw status` -- `openclaw gateway status` - -Everything else must hard-fail with: “Config invalid. Run `openclaw doctor --fix`.” - -## Error UX format - -- Single summary header. -- Grouped sections: - - Unknown keys (full paths) - - Legacy keys / migrations needed - - Plugin load failures (plugin id + reason + path) - -## Implementation touchpoints - -- `src/config/zod-schema.ts`: remove root passthrough; strict objects everywhere. -- `src/config/zod-schema.providers.ts`: ensure strict channel schemas. -- `src/config/validation.ts`: fail on unknown keys; do not apply legacy migrations. -- `src/config/io.ts`: remove legacy auto-migrations; always run doctor dry-run. -- `src/config/legacy*.ts`: move usage to doctor only. -- `src/plugins/*`: add schema registry + gating. -- CLI command gating in `src/cli`. - -## Tests - -- Unknown key rejection (root + nested). -- Plugin missing schema → plugin load blocked with clear error. -- Invalid config → gateway startup blocked except diagnostic commands. -- Doctor dry-run auto; `doctor --fix` writes corrected config. diff --git a/docs/zh-CN/refactor/clawnet.md b/docs/zh-CN/refactor/clawnet.md deleted file mode 100644 index bfbf81304ab..00000000000 --- a/docs/zh-CN/refactor/clawnet.md +++ /dev/null @@ -1,424 +0,0 @@ ---- -read_when: - - 规划节点 + 操作者客户端的统一网络协议 - - 重新设计跨设备的审批、配对、TLS 和在线状态 -summary: Clawnet 重构:统一网络协议、角色、认证、审批、身份 -title: Clawnet 重构 -x-i18n: - generated_at: "2026-02-03T07:55:03Z" - model: claude-opus-4-5 - provider: pi - source_hash: 719b219c3b326479658fe6101c80d5273fc56eb3baf50be8535e0d1d2bb7987f - source_path: refactor/clawnet.md - workflow: 15 ---- - -# Clawnet 重构(协议 + 认证统一) - -## 嗨 - -嗨 Peter — 方向很好;这将解锁更简单的用户体验 + 更强的安全性。 - -## 目的 - -单一、严谨的文档用于: - -- 当前状态:协议、流程、信任边界。 -- 痛点:审批、多跳路由、UI 重复。 -- 提议的新状态:一个协议、作用域角色、统一的认证/配对、TLS 固定。 -- 身份模型:稳定 ID + 可爱的别名。 -- 迁移计划、风险、开放问题。 - -## 目标(来自讨论) - -- 所有客户端使用一个协议(mac 应用、CLI、iOS、Android、无头节点)。 -- 每个网络参与者都经过认证 + 配对。 -- 角色清晰:节点 vs 操作者。 -- 中央审批路由到用户所在位置。 -- 所有远程流量使用 TLS 加密 + 可选固定。 -- 最小化代码重复。 -- 单台机器应该只显示一次(无 UI/节点重复条目)。 - -## 非目标(明确) - -- 移除能力分离(仍需要最小权限)。 -- 不经作用域检查就暴露完整的 Gateway 网关控制平面。 -- 使认证依赖于人类标签(别名仍然是非安全性的)。 - ---- - -# 当前状态(现状) - -## 两个协议 - -### 1) Gateway 网关 WebSocket(控制平面) - -- 完整 API 表面:配置、渠道、模型、会话、智能体运行、日志、节点等。 -- 默认绑定:loopback。通过 SSH/Tailscale 远程访问。 -- 认证:通过 `connect` 的令牌/密码。 -- 无 TLS 固定(依赖 loopback/隧道)。 -- 代码: - - `src/gateway/server/ws-connection/message-handler.ts` - - `src/gateway/client.ts` - - `docs/gateway/protocol.md` - -### 2) Bridge(节点传输) - -- 窄允许列表表面,节点身份 + 配对。 -- TCP 上的 JSONL;可选 TLS + 证书指纹固定。 -- TLS 在设备发现 TXT 中公布指纹。 -- 代码: - - `src/infra/bridge/server/connection.ts` - - `src/gateway/server-bridge.ts` - - `src/node-host/bridge-client.ts` - - `docs/gateway/bridge-protocol.md` - -## 当前的控制平面客户端 - -- CLI → 通过 `callGateway`(`src/gateway/call.ts`)连接 Gateway 网关 WS。 -- macOS 应用 UI → Gateway 网关 WS(`GatewayConnection`)。 -- Web 控制 UI → Gateway 网关 WS。 -- ACP → Gateway 网关 WS。 -- 浏览器控制使用自己的 HTTP 控制服务器。 - -## 当前的节点 - -- macOS 应用在节点模式下连接到 Gateway 网关 bridge(`MacNodeBridgeSession`)。 -- iOS/Android 应用连接到 Gateway 网关 bridge。 -- 配对 + 每节点令牌存储在 Gateway 网关上。 - -## 当前审批流程(exec) - -- 智能体通过 Gateway 网关使用 `system.run`。 -- Gateway 网关通过 bridge 调用节点。 -- 节点运行时决定审批。 -- UI 提示由 mac 应用显示(当节点 == mac 应用时)。 -- 节点向 Gateway 网关返回 `invoke-res`。 -- 多跳,UI 绑定到节点主机。 - -## 当前的在线状态 + 身份 - -- 来自 WS 客户端的 Gateway 网关在线状态条目。 -- 来自 bridge 的节点在线状态条目。 -- mac 应用可能为同一台机器显示两个条目(UI + 节点)。 -- 节点身份存储在配对存储中;UI 身份是分开的。 - ---- - -# 问题/痛点 - -- 需要维护两个协议栈(WS + Bridge)。 -- 远程节点上的审批:提示出现在节点主机上,而不是用户所在位置。 -- TLS 固定仅存在于 bridge;WS 依赖 SSH/Tailscale。 -- 身份重复:同一台机器显示为多个实例。 -- 角色模糊:UI + 节点 + CLI 能力没有明确分离。 - ---- - -# 提议的新状态(Clawnet) - -## 一个协议,两个角色 - -带有角色 + 作用域的单一 WS 协议。 - -- **角色:node**(能力宿主) -- **角色:operator**(控制平面) -- 操作者的可选**作用域**: - - `operator.read`(状态 + 查看) - - `operator.write`(智能体运行、发送) - - `operator.admin`(配置、渠道、模型) - -### 角色行为 - -**Node** - -- 可以注册能力(`caps`、`commands`、permissions)。 -- 可以接收 `invoke` 命令(`system.run`、`camera.*`、`canvas.*`、`screen.record` 等)。 -- 可以发送事件:`voice.transcript`、`agent.request`、`chat.subscribe`。 -- 不能调用配置/模型/渠道/会话/智能体控制平面 API。 - -**Operator** - -- 完整控制平面 API,受作用域限制。 -- 接收所有审批。 -- 不直接执行 OS 操作;路由到节点。 - -### 关键规则 - -角色是按连接的,不是按设备。一个设备可以分别打开两个角色。 - ---- - -# 统一认证 + 配对 - -## 客户端身份 - -每个客户端提供: - -- `deviceId`(稳定的,从设备密钥派生)。 -- `displayName`(人类名称)。 -- `role` + `scope` + `caps` + `commands`。 - -## 配对流程(统一) - -- 客户端未认证连接。 -- Gateway 网关为该 `deviceId` 创建**配对请求**。 -- 操作者收到提示;批准/拒绝。 -- Gateway 网关颁发绑定到以下内容的凭证: - - 设备公钥 - - 角色 - - 作用域 - - 能力/命令 -- 客户端持久化令牌,重新认证连接。 - -## 设备绑定认证(避免 bearer 令牌重放) - -首选:设备密钥对。 - -- 设备一次性生成密钥对。 -- `deviceId = fingerprint(publicKey)`。 -- Gateway 网关发送 nonce;设备签名;Gateway 网关验证。 -- 令牌颁发给公钥(所有权证明),而不是字符串。 - -替代方案: - -- mTLS(客户端证书):最强,运维复杂度更高。 -- 短期 bearer 令牌仅作为临时阶段(早期轮换 + 撤销)。 - -## 静默批准(SSH 启发式) - -精确定义以避免薄弱环节。优选其一: - -- **仅限本地**:当客户端通过 loopback/Unix socket 连接时自动配对。 -- **通过 SSH 质询**:Gateway 网关颁发 nonce;客户端通过获取它来证明 SSH。 -- **物理存在窗口**:在 Gateway 网关主机 UI 上本地批准后,允许在短窗口内(例如 10 分钟)自动配对。 - -始终记录 + 记录自动批准。 - ---- - -# TLS 无处不在(开发 + 生产) - -## 复用现有 bridge TLS - -使用当前 TLS 运行时 + 指纹固定: - -- `src/infra/bridge/server/tls.ts` -- `src/node-host/bridge-client.ts` 中的指纹验证逻辑 - -## 应用于 WS - -- WS 服务器使用相同的证书/密钥 + 指纹支持 TLS。 -- WS 客户端可以固定指纹(可选)。 -- 设备发现为所有端点公布 TLS + 指纹。 - - 设备发现仅是定位器提示;永远不是信任锚。 - -## 为什么 - -- 减少对 SSH/Tailscale 的机密性依赖。 -- 默认情况下使远程移动连接安全。 - ---- - -# 审批重新设计(集中化) - -## 当前 - -审批发生在节点主机上(mac 应用节点运行时)。提示出现在节点运行的地方。 - -## 提议 - -审批是 **Gateway 网关托管的**,UI 传递给操作者客户端。 - -### 新流程 - -1. Gateway 网关接收 `system.run` 意图(智能体)。 -2. Gateway 网关创建审批记录:`approval.requested`。 -3. 操作者 UI 显示提示。 -4. 审批决定发送到 Gateway 网关:`approval.resolve`。 -5. 如果批准,Gateway 网关调用节点命令。 -6. 节点执行,返回 `invoke-res`。 - -### 审批语义(加固) - -- 广播到所有操作者;只有活跃的 UI 显示模态框(其他显示 toast)。 -- 先解决者获胜;Gateway 网关拒绝后续解决为已结算。 -- 默认超时:N 秒后拒绝(例如 60 秒),记录原因。 -- 解决需要 `operator.approvals` 作用域。 - -## 好处 - -- 提示出现在用户所在位置(mac/手机)。 -- 远程节点的一致审批。 -- 节点运行时保持无头;无 UI 依赖。 - ---- - -# 角色清晰示例 - -## iPhone 应用 - -- **Node 角色**用于:麦克风、相机、语音聊天、位置、一键通话。 -- 可选的 **operator.read** 用于状态和聊天视图。 -- 可选的 **operator.write/admin** 仅在明确启用时。 - -## macOS 应用 - -- 默认是 Operator 角色(控制 UI)。 -- 启用"Mac 节点"时是 Node 角色(system.run、屏幕、相机)。 -- 两个连接使用相同的 deviceId → 合并的 UI 条目。 - -## CLI - -- 始终是 Operator 角色。 -- 作用域按子命令派生: - - `status`、`logs` → read - - `agent`、`message` → write - - `config`、`channels` → admin - - 审批 + 配对 → `operator.approvals` / `operator.pairing` - ---- - -# 身份 + 别名 - -## 稳定 ID - -认证必需;永不改变。 -首选: - -- 密钥对指纹(公钥哈希)。 - -## 可爱别名(龙虾主题) - -仅人类标签。 - -- 示例:`scarlet-claw`、`saltwave`、`mantis-pinch`。 -- 存储在 Gateway 网关注册表中,可编辑。 -- 冲突处理:`-2`、`-3`。 - -## UI 分组 - -跨角色的相同 `deviceId` → 单个"实例"行: - -- 徽章:`operator`、`node`。 -- 显示能力 + 最后在线。 - ---- - -# 迁移策略 - -## 阶段 0:记录 + 对齐 - -- 发布此文档。 -- 盘点所有协议调用 + 审批流程。 - -## 阶段 1:向 WS 添加角色/作用域 - -- 用 `role`、`scope`、`deviceId` 扩展 `connect` 参数。 -- 为 node 角色添加允许列表限制。 - -## 阶段 2:Bridge 兼容性 - -- 保持 bridge 运行。 -- 并行添加 WS node 支持。 -- 通过配置标志限制功能。 - -## 阶段 3:中央审批 - -- 在 WS 中添加审批请求 + 解决事件。 -- 更新 mac 应用 UI 以提示 + 响应。 -- 节点运行时停止提示 UI。 - -## 阶段 4:TLS 统一 - -- 使用 bridge TLS 运行时为 WS 添加 TLS 配置。 -- 向客户端添加固定。 - -## 阶段 5:弃用 bridge - -- 将 iOS/Android/mac 节点迁移到 WS。 -- 保持 bridge 作为后备;稳定后移除。 - -## 阶段 6:设备绑定认证 - -- 所有非本地连接都需要基于密钥的身份。 -- 添加撤销 + 轮换 UI。 - ---- - -# 安全说明 - -- 角色/允许列表在 Gateway 网关边界强制执行。 -- 没有客户端可以在没有 operator 作用域的情况下获得"完整"API。 -- *所有*连接都需要配对。 -- TLS + 固定减少移动设备的 MITM 风险。 -- SSH 静默批准是便利措施;仍然记录 + 可撤销。 -- 设备发现永远不是信任锚。 -- 能力声明通过按平台/类型的服务器允许列表验证。 - -# 流式传输 + 大型负载(节点媒体) - -WS 控制平面对于小消息没问题,但节点还做: - -- 相机剪辑 -- 屏幕录制 -- 音频流 - -选项: - -1. WS 二进制帧 + 分块 + 背压规则。 -2. 单独的流式端点(仍然是 TLS + 认证)。 -3. 对于媒体密集型命令保持 bridge 更长时间,最后迁移。 - -在实现前选择一个以避免漂移。 - -# 能力 + 命令策略 - -- 节点报告的 caps/commands 被视为**声明**。 -- Gateway 网关强制执行每平台允许列表。 -- 任何新命令都需要操作者批准或显式允许列表更改。 -- 用时间戳审计更改。 - -# 审计 + 速率限制 - -- 记录:配对请求、批准/拒绝、令牌颁发/轮换/撤销。 -- 速率限制配对垃圾和审批提示。 - -# 协议卫生 - -- 显式协议版本 + 错误代码。 -- 重连规则 + 心跳策略。 -- 在线状态 TTL 和最后在线语义。 - ---- - -# 开放问题 - -1. 同时运行两个角色的单个设备:令牌模型 - - 建议每个角色单独的令牌(node vs operator)。 - - 相同的 deviceId;不同的作用域;更清晰的撤销。 - -2. 操作者作用域粒度 - - read/write/admin + approvals + pairing(最小可行)。 - - 以后考虑每功能作用域。 - -3. 令牌轮换 + 撤销 UX - - 角色更改时自动轮换。 - - 按 deviceId + 角色撤销的 UI。 - -4. 设备发现 - - 扩展当前 Bonjour TXT 以包含 WS TLS 指纹 + 角色提示。 - - 仅作为定位器提示处理。 - -5. 跨网络审批 - - 广播到所有操作者客户端;活跃的 UI 显示模态框。 - - 先响应者获胜;Gateway 网关强制原子性。 - ---- - -# 总结(TL;DR) - -- 当前:WS 控制平面 + Bridge 节点传输。 -- 痛点:审批 + 重复 + 两个栈。 -- 提议:一个带有显式角色 + 作用域的 WS 协议,统一配对 + TLS 固定,Gateway 网关托管的审批,稳定设备 ID + 可爱别名。 -- 结果:更简单的 UX,更强的安全性,更少的重复,更好的移动路由。 diff --git a/docs/zh-CN/refactor/exec-host.md b/docs/zh-CN/refactor/exec-host.md deleted file mode 100644 index 3b81f41893f..00000000000 --- a/docs/zh-CN/refactor/exec-host.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -read_when: - - 设计 exec 主机路由或 exec 批准 - - 实现节点运行器 + UI IPC - - 添加 exec 主机安全模式和斜杠命令 -summary: 重构计划:exec 主机路由、节点批准和无头运行器 -title: Exec 主机重构 -x-i18n: - generated_at: "2026-02-03T07:54:43Z" - model: claude-opus-4-5 - provider: pi - source_hash: 53a9059cbeb1f3f1dbb48c2b5345f88ca92372654fef26f8481e651609e45e3a - source_path: refactor/exec-host.md - workflow: 15 ---- - -# Exec 主机重构计划 - -## 目标 - -- 添加 `exec.host` + `exec.security` 以在**沙箱**、**Gateway 网关**和**节点**之间路由执行。 -- 保持默认**安全**:除非明确启用,否则不进行跨主机执行。 -- 将执行拆分为**无头运行器服务**,通过本地 IPC 连接可选的 UI(macOS 应用)。 -- 提供**每智能体**策略、允许列表、询问模式和节点绑定。 -- 支持*与*或*不与*允许列表一起使用的**询问模式**。 -- 跨平台:Unix socket + token 认证(macOS/Linux/Windows 一致性)。 - -## 非目标 - -- 无遗留允许列表迁移或遗留 schema 支持。 -- 节点 exec 无 PTY/流式传输(仅聚合输出)。 -- 除现有 Bridge + Gateway 网关外无新网络层。 - -## 决定(已锁定) - -- **配置键:** `exec.host` + `exec.security`(允许每智能体覆盖)。 -- **提升:** 保留 `/elevated` 作为 Gateway 网关完全访问的别名。 -- **询问默认:** `on-miss`。 -- **批准存储:** `~/.openclaw/exec-approvals.json`(JSON,无遗留迁移)。 -- **运行器:** 无头系统服务;UI 应用托管 Unix socket 用于批准。 -- **节点身份:** 使用现有 `nodeId`。 -- **Socket 认证:** Unix socket + token(跨平台);如需要稍后拆分。 -- **节点主机状态:** `~/.openclaw/node.json`(节点 id + 配对 token)。 -- **macOS exec 主机:** 在 macOS 应用内运行 `system.run`;节点主机服务通过本地 IPC 转发请求。 -- **无 XPC helper:** 坚持使用 Unix socket + token + 对等检查。 - -## 关键概念 - -### 主机 - -- `sandbox`:Docker exec(当前行为)。 -- `gateway`:在 Gateway 网关主机上执行。 -- `node`:通过 Bridge 在节点运行器上执行(`system.run`)。 - -### 安全模式 - -- `deny`:始终阻止。 -- `allowlist`:仅允许匹配项。 -- `full`:允许一切(等同于提升模式)。 - -### 询问模式 - -- `off`:从不询问。 -- `on-miss`:仅在允许列表不匹配时询问。 -- `always`:每次都询问。 - -询问**独立于**允许列表;允许列表可与 `always` 或 `on-miss` 一起使用。 - -### 策略解析(每次执行) - -1. 解析 `exec.host`(工具参数 → 智能体覆盖 → 全局默认)。 -2. 解析 `exec.security` 和 `exec.ask`(相同优先级)。 -3. 如果主机是 `sandbox`,继续本地沙箱执行。 -4. 如果主机是 `gateway` 或 `node`,在该主机上应用安全 + 询问策略。 - -## 默认安全 - -- 默认 `exec.host = sandbox`。 -- `gateway` 和 `node` 默认 `exec.security = deny`。 -- 默认 `exec.ask = on-miss`(仅在安全允许时相关)。 -- 如果未设置节点绑定,**智能体可以定向任何节点**,但仅在策略允许时。 - -## 配置表面 - -### 工具参数 - -- `exec.host`(可选):`sandbox | gateway | node`。 -- `exec.security`(可选):`deny | allowlist | full`。 -- `exec.ask`(可选):`off | on-miss | always`。 -- `exec.node`(可选):当 `host=node` 时使用的节点 id/名称。 - -### 配置键(全局) - -- `tools.exec.host` -- `tools.exec.security` -- `tools.exec.ask` -- `tools.exec.node`(默认节点绑定) - -### 配置键(每智能体) - -- `agents.list[].tools.exec.host` -- `agents.list[].tools.exec.security` -- `agents.list[].tools.exec.ask` -- `agents.list[].tools.exec.node` - -### 别名 - -- `/elevated on` = 为智能体会话设置 `tools.exec.host=gateway`、`tools.exec.security=full`。 -- `/elevated off` = 为智能体会话恢复之前的 exec 设置。 - -## 批准存储(JSON) - -路径:`~/.openclaw/exec-approvals.json` - -用途: - -- **执行主机**(Gateway 网关或节点运行器)的本地策略 + 允许列表。 -- 无 UI 可用时的询问回退。 -- UI 客户端的 IPC 凭证。 - -建议的 schema(v1): - -```json -{ - "version": 1, - "socket": { - "path": "~/.openclaw/exec-approvals.sock", - "token": "base64-opaque-token" - }, - "defaults": { - "security": "deny", - "ask": "on-miss", - "askFallback": "deny" - }, - "agents": { - "agent-id-1": { - "security": "allowlist", - "ask": "on-miss", - "allowlist": [ - { - "pattern": "~/Projects/**/bin/rg", - "lastUsedAt": 0, - "lastUsedCommand": "rg -n TODO", - "lastResolvedPath": "/Users/user/Projects/.../bin/rg" - } - ] - } - } -} -``` - -注意事项: - -- 无遗留允许列表格式。 -- `askFallback` 仅在需要 `ask` 且无法访问 UI 时应用。 -- 文件权限:`0600`。 - -## 运行器服务(无头) - -### 角色 - -- 在本地强制执行 `exec.security` + `exec.ask`。 -- 执行系统命令并返回输出。 -- 为 exec 生命周期发出 Bridge 事件(可选但推荐)。 - -### 服务生命周期 - -- macOS 上的 Launchd/daemon;Linux/Windows 上的系统服务。 -- 批准 JSON 是执行主机本地的。 -- UI 托管本地 Unix socket;运行器按需连接。 - -## UI 集成(macOS 应用) - -### IPC - -- Unix socket 位于 `~/.openclaw/exec-approvals.sock`(0600)。 -- Token 存储在 `exec-approvals.json`(0600)中。 -- 对等检查:仅同 UID。 -- 挑战/响应:nonce + HMAC(token, request-hash) 防止重放。 -- 短 TTL(例如 10s)+ 最大负载 + 速率限制。 - -### 询问流程(macOS 应用 exec 主机) - -1. 节点服务从 Gateway 网关接收 `system.run`。 -2. 节点服务连接到本地 socket 并发送提示/exec 请求。 -3. 应用验证对等 + token + HMAC + TTL,然后在需要时显示对话框。 -4. 应用在 UI 上下文中执行命令并返回输出。 -5. 节点服务将输出返回给 Gateway 网关。 - -如果 UI 缺失: - -- 应用 `askFallback`(`deny|allowlist|full`)。 - -### 图示(SCI) - -``` -Agent -> Gateway -> Bridge -> Node Service (TS) - | IPC (UDS + token + HMAC + TTL) - v - Mac App (UI + TCC + system.run) -``` - -## 节点身份 + 绑定 - -- 使用 Bridge 配对中的现有 `nodeId`。 -- 绑定模型: - - `tools.exec.node` 将智能体限制为特定节点。 - - 如果未设置,智能体可以选择任何节点(策略仍强制执行默认值)。 -- 节点选择解析: - - `nodeId` 精确匹配 - - `displayName`(规范化) - - `remoteIp` - - `nodeId` 前缀(>= 6 字符) - -## 事件 - -### 谁看到事件 - -- 系统事件是**每会话**的,在下一个提示时显示给智能体。 -- 存储在 Gateway 网关内存队列中(`enqueueSystemEvent`)。 - -### 事件文本 - -- `Exec started (node=, id=)` -- `Exec finished (node=, id=, code=)` + 可选输出尾部 -- `Exec denied (node=, id=, )` - -### 传输 - -选项 A(推荐): - -- 运行器发送 Bridge `event` 帧 `exec.started` / `exec.finished`。 -- Gateway 网关 `handleBridgeEvent` 将这些映射到 `enqueueSystemEvent`。 - -选项 B: - -- Gateway 网关 `exec` 工具直接处理生命周期(仅同步)。 - -## Exec 流程 - -### 沙箱主机 - -- 现有 `exec` 行为(Docker 或无沙箱时的主机)。 -- 仅在非沙箱模式下支持 PTY。 - -### Gateway 网关主机 - -- Gateway 网关进程在其自己的机器上执行。 -- 强制执行本地 `exec-approvals.json`(安全/询问/允许列表)。 - -### 节点主机 - -- Gateway 网关调用 `node.invoke` 配合 `system.run`。 -- 运行器强制执行本地批准。 -- 运行器返回聚合的 stdout/stderr。 -- 可选的 Bridge 事件用于开始/完成/拒绝。 - -## 输出上限 - -- 组合 stdout+stderr 上限为 **200k**;为事件保留**尾部 20k**。 -- 使用清晰的后缀截断(例如 `"… (truncated)"`)。 - -## 斜杠命令 - -- `/exec host= security= ask= node=` -- 每智能体、每会话覆盖;除非通过配置保存,否则非持久。 -- `/elevated on|off|ask|full` 仍然是 `host=gateway security=full` 的快捷方式(`full` 跳过批准)。 - -## 跨平台方案 - -- 运行器服务是可移植的执行目标。 -- UI 是可选的;如果缺失,应用 `askFallback`。 -- Windows/Linux 支持相同的批准 JSON + socket 协议。 - -## 实现阶段 - -### 阶段 1:配置 + exec 路由 - -- 为 `exec.host`、`exec.security`、`exec.ask`、`exec.node` 添加配置 schema。 -- 更新工具管道以遵守 `exec.host`。 -- 添加 `/exec` 斜杠命令并保留 `/elevated` 别名。 - -### 阶段 2:批准存储 + Gateway 网关强制执行 - -- 实现 `exec-approvals.json` 读取器/写入器。 -- 为 `gateway` 主机强制执行允许列表 + 询问模式。 -- 添加输出上限。 - -### 阶段 3:节点运行器强制执行 - -- 更新节点运行器以强制执行允许列表 + 询问。 -- 添加 Unix socket 提示桥接到 macOS 应用 UI。 -- 连接 `askFallback`。 - -### 阶段 4:事件 - -- 为 exec 生命周期添加节点 → Gateway 网关 Bridge 事件。 -- 映射到 `enqueueSystemEvent` 用于智能体提示。 - -### 阶段 5:UI 完善 - -- Mac 应用:允许列表编辑器、每智能体切换器、询问策略 UI。 -- 节点绑定控制(可选)。 - -## 测试计划 - -- 单元测试:允许列表匹配(glob + 不区分大小写)。 -- 单元测试:策略解析优先级(工具参数 → 智能体覆盖 → 全局)。 -- 集成测试:节点运行器拒绝/允许/询问流程。 -- Bridge 事件测试:节点事件 → 系统事件路由。 - -## 开放风险 - -- UI 不可用:确保遵守 `askFallback`。 -- 长时间运行的命令:依赖超时 + 输出上限。 -- 多节点歧义:除非有节点绑定或显式节点参数,否则报错。 - -## 相关文档 - -- [Exec 工具](/tools/exec) -- [执行批准](/tools/exec-approvals) -- [节点](/nodes) -- [提升模式](/tools/elevated) diff --git a/docs/zh-CN/refactor/outbound-session-mirroring.md b/docs/zh-CN/refactor/outbound-session-mirroring.md deleted file mode 100644 index 3d733a00f64..00000000000 --- a/docs/zh-CN/refactor/outbound-session-mirroring.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -description: Track outbound session mirroring refactor notes, decisions, tests, and open items. -title: 出站会话镜像重构(Issue -x-i18n: - generated_at: "2026-02-03T07:53:51Z" - model: claude-opus-4-5 - provider: pi - source_hash: b88a72f36f7b6d8a71fde9d014c0a87e9a8b8b0d449b67119cf3b6f414fa2b81 - source_path: refactor/outbound-session-mirroring.md - workflow: 15 ---- - -# 出站会话镜像重构(Issue #1520) - -## 状态 - -- 进行中。 -- 核心 + 插件渠道路由已更新以支持出站镜像。 -- Gateway 网关发送现在在省略 sessionKey 时派生目标会话。 - -## 背景 - -出站发送被镜像到*当前*智能体会话(工具会话键)而不是目标渠道会话。入站路由使用渠道/对等方会话键,因此出站响应落在错误的会话中,首次联系的目标通常缺少会话条目。 - -## 目标 - -- 将出站消息镜像到目标渠道会话键。 -- 在缺失时为出站创建会话条目。 -- 保持线程/话题作用域与入站会话键对齐。 -- 涵盖核心渠道加内置扩展。 - -## 实现摘要 - -- 新的出站会话路由辅助器: - - `src/infra/outbound/outbound-session.ts` - - `resolveOutboundSessionRoute` 使用 `buildAgentSessionKey`(dmScope + identityLinks)构建目标 sessionKey。 - - `ensureOutboundSessionEntry` 通过 `recordSessionMetaFromInbound` 写入最小的 `MsgContext`。 -- `runMessageAction`(发送)派生目标 sessionKey 并将其传递给 `executeSendAction` 进行镜像。 -- `message-tool` 不再直接镜像;它只从当前会话键解析 agentId。 -- 插件发送路径使用派生的 sessionKey 通过 `appendAssistantMessageToSessionTranscript` 进行镜像。 -- Gateway 网关发送在未提供时派生目标会话键(默认智能体),并确保会话条目。 - -## 线程/话题处理 - -- Slack:replyTo/threadId -> `resolveThreadSessionKeys`(后缀)。 -- Discord:threadId/replyTo -> `resolveThreadSessionKeys`,`useSuffix=false` 以匹配入站(线程频道 id 已经作用域会话)。 -- Telegram:话题 ID 通过 `buildTelegramGroupPeerId` 映射到 `chatId:topic:`。 - -## 涵盖的扩展 - -- Matrix、MS Teams、Mattermost、BlueBubbles、Nextcloud Talk、Zalo、Zalo Personal、Nostr、Tlon。 -- 注意: - - Mattermost 目标现在为私信会话键路由去除 `@`。 - - Zalo Personal 对 1:1 目标使用私信对等方类型(仅当存在 `group:` 时才使用群组)。 - - BlueBubbles 群组目标去除 `chat_*` 前缀以匹配入站会话键。 - - Slack 自动线程镜像不区分大小写地匹配频道 id。 - - Gateway 网关发送在镜像前将提供的会话键转换为小写。 - -## 决策 - -- **Gateway 网关发送会话派生**:如果提供了 `sessionKey`,则使用它。如果省略,从目标 + 默认智能体派生 sessionKey 并镜像到那里。 -- **会话条目创建**:始终使用 `recordSessionMetaFromInbound`,`Provider/From/To/ChatType/AccountId/Originating*` 与入站格式对齐。 -- **目标规范化**:出站路由在可用时使用解析后的目标(`resolveChannelTarget` 之后)。 -- **会话键大小写**:在写入和迁移期间将会话键规范化为小写。 - -## 添加/更新的测试 - -- `src/infra/outbound/outbound-session.test.ts` - - Slack 线程会话键。 - - Telegram 话题会话键。 - - dmScope identityLinks 与 Discord。 -- `src/agents/tools/message-tool.test.ts` - - 从会话键派生 agentId(不传递 sessionKey)。 -- `src/gateway/server-methods/send.test.ts` - - 在省略时派生会话键并创建会话条目。 - -## 待处理项目 / 后续跟进 - -- 语音通话插件使用自定义的 `voice:` 会话键。出站映射在这里没有标准化;如果 message-tool 应该支持语音通话发送,请添加显式映射。 -- 确认是否有任何外部插件使用内置集之外的非标准 `From/To` 格式。 - -## 涉及的文件 - -- `src/infra/outbound/outbound-session.ts` -- `src/infra/outbound/outbound-send-service.ts` -- `src/infra/outbound/message-action-runner.ts` -- `src/agents/tools/message-tool.ts` -- `src/gateway/server-methods/send.ts` -- 测试: - - `src/infra/outbound/outbound-session.test.ts` - - `src/agents/tools/message-tool.test.ts` - - `src/gateway/server-methods/send.test.ts` diff --git a/docs/zh-CN/refactor/plugin-sdk.md b/docs/zh-CN/refactor/plugin-sdk.md deleted file mode 100644 index fc2e7420593..00000000000 --- a/docs/zh-CN/refactor/plugin-sdk.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -read_when: - - 定义或重构插件架构 - - 将渠道连接器迁移到插件 SDK/运行时 -summary: 计划:为所有消息连接器提供一套统一的插件 SDK + 运行时 -title: 插件 SDK 重构 -x-i18n: - generated_at: "2026-02-01T21:36:45Z" - model: claude-opus-4-5 - provider: pi - source_hash: d1964e2e47a19ee1d42ddaaa9cf1293c80bb0be463b049dc8468962f35bb6cb0 - source_path: refactor/plugin-sdk.md - workflow: 15 ---- - -# 插件 SDK + 运行时重构计划 - -目标:每个消息连接器都是一个插件(内置或外部),使用统一稳定的 API。 -插件不直接从 `src/**` 导入任何内容。所有依赖项均通过 SDK 或运行时获取。 - -## 为什么现在做 - -- 当前连接器混用多种模式:直接导入核心模块、仅 dist 的桥接方式以及自定义辅助函数。 -- 这使得升级变得脆弱,并阻碍了干净的外部插件接口。 - -## 目标架构(两层) - -### 1)插件 SDK(编译时,稳定,可发布) - -范围:类型、辅助函数和配置工具。无运行时状态,无副作用。 - -内容(示例): - -- 类型:`ChannelPlugin`、适配器、`ChannelMeta`、`ChannelCapabilities`、`ChannelDirectoryEntry`。 -- 配置辅助函数:`buildChannelConfigSchema`、`setAccountEnabledInConfigSection`、`deleteAccountFromConfigSection`、 - `applyAccountNameToChannelSection`。 -- 配对辅助函数:`PAIRING_APPROVED_MESSAGE`、`formatPairingApproveHint`。 -- 新手引导辅助函数:`promptChannelAccessConfig`、`addWildcardAllowFrom`、新手引导类型。 -- 工具参数辅助函数:`createActionGate`、`readStringParam`、`readNumberParam`、`readReactionParams`、`jsonResult`。 -- 文档链接辅助函数:`formatDocsLink`。 - -交付方式: - -- 以 `openclaw/plugin-sdk` 发布(或从核心以 `openclaw/plugin-sdk` 导出)。 -- 使用语义化版本控制,提供明确的稳定性保证。 - -### 2)插件运行时(执行层,注入式) - -范围:所有涉及核心运行时行为的内容。 -通过 `OpenClawPluginApi.runtime` 访问,确保插件永远不会导入 `src/**`。 - -建议的接口(最小但完整): - -```ts -export type PluginRuntime = { - channel: { - text: { - chunkMarkdownText(text: string, limit: number): string[]; - resolveTextChunkLimit(cfg: OpenClawConfig, channel: string, accountId?: string): number; - hasControlCommand(text: string, cfg: OpenClawConfig): boolean; - }; - reply: { - dispatchReplyWithBufferedBlockDispatcher(params: { - ctx: unknown; - cfg: unknown; - dispatcherOptions: { - deliver: (payload: { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - }) => void | Promise; - onError?: (err: unknown, info: { kind: string }) => void; - }; - }): Promise; - createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows - }; - routing: { - resolveAgentRoute(params: { - cfg: unknown; - channel: string; - accountId: string; - peer: { kind: RoutePeerKind; id: string }; - }): { sessionKey: string; accountId: string }; - }; - pairing: { - buildPairingReply(params: { channel: string; idLine: string; code: string }): string; - readAllowFromStore(channel: string): Promise; - upsertPairingRequest(params: { - channel: string; - id: string; - meta?: { name?: string }; - }): Promise<{ code: string; created: boolean }>; - }; - media: { - fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>; - saveMediaBuffer( - buffer: Uint8Array, - contentType: string | undefined, - direction: "inbound" | "outbound", - maxBytes: number, - ): Promise<{ path: string; contentType?: string }>; - }; - mentions: { - buildMentionRegexes(cfg: OpenClawConfig, agentId?: string): RegExp[]; - matchesMentionPatterns(text: string, regexes: RegExp[]): boolean; - }; - groups: { - resolveGroupPolicy( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - ): { - allowlistEnabled: boolean; - allowed: boolean; - groupConfig?: unknown; - defaultConfig?: unknown; - }; - resolveRequireMention( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - override?: boolean, - ): boolean; - }; - debounce: { - createInboundDebouncer(opts: { - debounceMs: number; - buildKey: (v: T) => string | null; - shouldDebounce: (v: T) => boolean; - onFlush: (entries: T[]) => Promise; - onError?: (err: unknown) => void; - }): { push: (v: T) => void; flush: () => Promise }; - resolveInboundDebounceMs(cfg: OpenClawConfig, channel: string): number; - }; - commands: { - resolveCommandAuthorizedFromAuthorizers(params: { - useAccessGroups: boolean; - authorizers: Array<{ configured: boolean; allowed: boolean }>; - }): boolean; - }; - }; - logging: { - shouldLogVerbose(): boolean; - getChildLogger(name: string): PluginLogger; - }; - state: { - resolveStateDir(cfg: OpenClawConfig): string; - }; -}; -``` - -备注: - -- 运行时是访问核心行为的唯一方式。 -- SDK 故意保持小巧和稳定。 -- 每个运行时方法都映射到现有的核心实现(无重复代码)。 - -## 迁移计划(分阶段,安全) - -### 阶段 0:基础搭建 - -- 引入 `openclaw/plugin-sdk`。 -- 在 `OpenClawPluginApi` 中添加带有上述接口的 `api.runtime`。 -- 在过渡期内保留现有导入方式(添加弃用警告)。 - -### 阶段 1:桥接清理(低风险) - -- 用 `api.runtime` 替换每个扩展中的 `core-bridge.ts`。 -- 优先迁移 BlueBubbles、Zalo、Zalo Personal(已经接近完成)。 -- 移除重复的桥接代码。 - -### 阶段 2:轻度直接导入的插件 - -- 将 Matrix 迁移到 SDK + 运行时。 -- 验证新手引导、目录、群组提及逻辑。 - -### 阶段 3:重度直接导入的插件 - -- 迁移 Microsoft Teams(使用运行时辅助函数最多的插件)。 -- 确保回复/正在输入的语义与当前行为一致。 - -### 阶段 4:iMessage 插件化 - -- 将 iMessage 移入 `extensions/imessage`。 -- 用 `api.runtime` 替换直接的核心调用。 -- 保持配置键、CLI 行为和文档不变。 - -### 阶段 5:强制执行 - -- 添加 lint 规则 / CI 检查:禁止 `extensions/**` 从 `src/**` 导入。 -- 添加插件 SDK/版本兼容性检查(运行时 + SDK 语义化版本)。 - -## 兼容性与版本控制 - -- SDK:语义化版本控制,已发布,变更有文档记录。 -- 运行时:按核心版本进行版本控制。添加 `api.runtime.version`。 -- 插件声明所需的运行时版本范围(例如 `openclawRuntime: ">=2026.2.0"`)。 - -## 测试策略 - -- 适配器级单元测试(使用真实核心实现验证运行时函数)。 -- 每个插件的黄金测试:确保行为无偏差(路由、配对、允许列表、提及过滤)。 -- CI 中使用单个端到端插件示例(安装 + 运行 + 冒烟测试)。 - -## 待解决问题 - -- SDK 类型托管在哪里:独立包还是核心导出? -- 运行时类型分发:在 SDK 中(仅类型)还是在核心中? -- 如何为内置插件与外部插件暴露文档链接? -- 过渡期间是否允许仓库内插件有限地直接导入核心模块? - -## 成功标准 - -- 所有渠道连接器都是使用 SDK + 运行时的插件。 -- `extensions/**` 不再从 `src/**` 导入。 -- 新连接器模板仅依赖 SDK + 运行时。 -- 外部插件可以在无需访问核心源码的情况下进行开发和更新。 - -相关文档:[插件](/tools/plugin)、[渠道](/channels/index)、[配置](/gateway/configuration)。 diff --git a/docs/zh-CN/refactor/strict-config.md b/docs/zh-CN/refactor/strict-config.md deleted file mode 100644 index 91b9a50714d..00000000000 --- a/docs/zh-CN/refactor/strict-config.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -read_when: - - 设计或实现配置验证行为 - - 处理配置迁移或 doctor 工作流 - - 处理插件配置 schema 或插件加载门控 -summary: 严格配置验证 + 仅通过 doctor 进行迁移 -title: 严格配置验证 -x-i18n: - generated_at: "2026-02-03T10:08:51Z" - model: claude-opus-4-5 - provider: pi - source_hash: 5bc7174a67d2234e763f21330d8fe3afebc23b2e5c728a04abcc648b453a91cc - source_path: refactor/strict-config.md - workflow: 15 ---- - -# 严格配置验证(仅通过 doctor 进行迁移) - -## 目标 - -- **在所有地方拒绝未知配置键**(根级 + 嵌套)。 -- **拒绝没有 schema 的插件配置**;不加载该插件。 -- **移除加载时的旧版自动迁移**;迁移仅通过 doctor 运行。 -- **启动时自动运行 doctor(dry-run)**;如果无效,阻止非诊断命令。 - -## 非目标 - -- 加载时的向后兼容性(旧版键不会自动迁移)。 -- 静默丢弃无法识别的键。 - -## 严格验证规则 - -- 配置必须在每个层级精确匹配 schema。 -- 未知键是验证错误(根级或嵌套都不允许透传)。 -- `plugins.entries..config` 必须由插件的 schema 验证。 - - 如果插件缺少 schema,**拒绝插件加载**并显示清晰的错误。 -- 未知的 `channels.` 键是错误,除非插件清单声明了该渠道 id。 -- 所有插件都需要插件清单(`openclaw.plugin.json`)。 - -## 插件 schema 强制执行 - -- 每个插件为其配置提供严格的 JSON Schema(内联在清单中)。 -- 插件加载流程: - 1. 解析插件清单 + schema(`openclaw.plugin.json`)。 - 2. 根据 schema 验证配置。 - 3. 如果缺少 schema 或配置无效:阻止插件加载,记录错误。 -- 错误消息包括: - - 插件 id - - 原因(缺少 schema / 配置无效) - - 验证失败的路径 -- 禁用的插件保留其配置,但 Doctor + 日志会显示警告。 - -## Doctor 流程 - -- 每次加载配置时都会运行 Doctor(默认 dry-run)。 -- 如果配置无效: - - 打印摘要 + 可操作的错误。 - - 指示:`openclaw doctor --fix`。 -- `openclaw doctor --fix`: - - 应用迁移。 - - 移除未知键。 - - 写入更新后的配置。 - -## 命令门控(当配置无效时) - -允许的命令(仅诊断): - -- `openclaw doctor` -- `openclaw logs` -- `openclaw health` -- `openclaw help` -- `openclaw status` -- `openclaw gateway status` - -其他所有命令必须硬失败并显示:"Config invalid. Run `openclaw doctor --fix`." - -## 错误用户体验格式 - -- 单个摘要标题。 -- 分组部分: - - 未知键(完整路径) - - 旧版键/需要迁移 - - 插件加载失败(插件 id + 原因 + 路径) - -## 实现接触点 - -- `src/config/zod-schema.ts`:移除根级透传;所有地方使用严格对象。 -- `src/config/zod-schema.providers.ts`:确保严格的渠道 schema。 -- `src/config/validation.ts`:未知键时失败;不应用旧版迁移。 -- `src/config/io.ts`:移除旧版自动迁移;始终运行 doctor dry-run。 -- `src/config/legacy*.ts`:将用法移至仅 doctor。 -- `src/plugins/*`:添加 schema 注册表 + 门控。 -- `src/cli` 中的 CLI 命令门控。 - -## 测试 - -- 未知键拒绝(根级 + 嵌套)。 -- 插件缺少 schema → 插件加载被阻止并显示清晰错误。 -- 无效配置 → Gateway 网关启动被阻止,诊断命令除外。 -- Doctor dry-run 自动运行;`doctor --fix` 写入修正后的配置。 From 0ae3e70a5c6a687d41e1ff056ab86691086929fb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:49:54 -0700 Subject: [PATCH 375/393] Plugin SDK: fix contract seam regressions --- extensions/irc/src/accounts.ts | 8 ++--- extensions/nostr/src/config-schema.ts | 8 +++-- extensions/tlon/src/monitor/media.ts | 2 +- extensions/tlon/src/urbit/fetch.ts | 7 ++-- extensions/tlon/src/urbit/upload.test.ts | 8 ++--- extensions/tlon/src/urbit/upload.ts | 2 +- package.json | 44 ++++++++++++++++++++++++ scripts/lib/plugin-sdk-entrypoints.json | 11 ++++++ src/plugin-sdk/channel-config-schema.ts | 3 +- src/plugin-sdk/core.ts | 5 ++- 10 files changed, 81 insertions(+), 17 deletions(-) diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index 66df8f9d26c..e54256dd7c2 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,10 +1,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; -import { - createAccountListHelpers, - normalizeResolvedSecretInputString, - parseOptionalDelimitedEntries, -} from "./runtime-api.js"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 0a741d3ac6b..1a900d8edac 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,10 @@ -import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { + AllowFromListSchema, + buildChannelConfigSchema, + DmPolicySchema, + MarkdownConfigSchema, +} from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "zod"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "../runtime-api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index 8a17e982fad..de64a427ed2 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { fetchWithSsrFGuard } from "../../api.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime"; import { getDefaultSsrFPolicy } from "../urbit/context.js"; // Default to OpenClaw workspace media directory diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts index 638c70f0840..524bd80d47a 100644 --- a/extensions/tlon/src/urbit/fetch.ts +++ b/extensions/tlon/src/urbit/fetch.ts @@ -1,5 +1,8 @@ -import type { LookupFn, SsrFPolicy } from "../../api.js"; -import { fetchWithSsrFGuard } from "../../api.js"; +import { + fetchWithSsrFGuard, + type LookupFn, + type SsrFPolicy, +} from "openclaw/plugin-sdk/infra-runtime"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts index 34dd6186d20..bb8f505e7c1 100644 --- a/extensions/tlon/src/urbit/upload.test.ts +++ b/extensions/tlon/src/urbit/upload.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; -// Mock fetchWithSsrFGuard from plugin-sdk -vi.mock("openclaw/plugin-sdk/tlon", async (importOriginal) => { - const actual = await importOriginal(); +// Mock fetchWithSsrFGuard from the focused infra seam. +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchWithSsrFGuard: vi.fn(), @@ -16,7 +16,7 @@ vi.mock("@tloncorp/api", () => ({ describe("uploadImageFromUrl", () => { async function loadUploadMocks() { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); + const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/infra-runtime"); const { uploadFile } = await import("@tloncorp/api"); const { uploadImageFromUrl } = await import("./upload.js"); return { diff --git a/extensions/tlon/src/urbit/upload.ts b/extensions/tlon/src/urbit/upload.ts index 6176c132207..f0afe35c29e 100644 --- a/extensions/tlon/src/urbit/upload.ts +++ b/extensions/tlon/src/urbit/upload.ts @@ -2,7 +2,7 @@ * Upload an image from a URL to Tlon storage. */ import { uploadFile } from "@tloncorp/api"; -import { fetchWithSsrFGuard } from "../../api.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime"; import { getDefaultSsrFPolicy } from "./context.js"; /** diff --git a/package.json b/package.json index b9c04e44692..09a8c047869 100644 --- a/package.json +++ b/package.json @@ -230,6 +230,22 @@ "types": "./dist/plugin-sdk/bluebubbles.d.ts", "default": "./dist/plugin-sdk/bluebubbles.js" }, + "./plugin-sdk/copilot-proxy": { + "types": "./dist/plugin-sdk/copilot-proxy.d.ts", + "default": "./dist/plugin-sdk/copilot-proxy.js" + }, + "./plugin-sdk/device-pair": { + "types": "./dist/plugin-sdk/device-pair.d.ts", + "default": "./dist/plugin-sdk/device-pair.js" + }, + "./plugin-sdk/diagnostics-otel": { + "types": "./dist/plugin-sdk/diagnostics-otel.d.ts", + "default": "./dist/plugin-sdk/diagnostics-otel.js" + }, + "./plugin-sdk/diffs": { + "types": "./dist/plugin-sdk/diffs.d.ts", + "default": "./dist/plugin-sdk/diffs.js" + }, "./plugin-sdk/feishu": { "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" @@ -242,6 +258,10 @@ "types": "./dist/plugin-sdk/irc.d.ts", "default": "./dist/plugin-sdk/irc.js" }, + "./plugin-sdk/llm-task": { + "types": "./dist/plugin-sdk/llm-task.d.ts", + "default": "./dist/plugin-sdk/llm-task.js" + }, "./plugin-sdk/lobster": { "types": "./dist/plugin-sdk/lobster.d.ts", "default": "./dist/plugin-sdk/lobster.js" @@ -262,6 +282,10 @@ "types": "./dist/plugin-sdk/memory-core.d.ts", "default": "./dist/plugin-sdk/memory-core.js" }, + "./plugin-sdk/memory-lancedb": { + "types": "./dist/plugin-sdk/memory-lancedb.d.ts", + "default": "./dist/plugin-sdk/memory-lancedb.js" + }, "./plugin-sdk/minimax-portal-auth": { "types": "./dist/plugin-sdk/minimax-portal-auth.d.ts", "default": "./dist/plugin-sdk/minimax-portal-auth.js" @@ -274,6 +298,18 @@ "types": "./dist/plugin-sdk/nostr.d.ts", "default": "./dist/plugin-sdk/nostr.js" }, + "./plugin-sdk/open-prose": { + "types": "./dist/plugin-sdk/open-prose.d.ts", + "default": "./dist/plugin-sdk/open-prose.js" + }, + "./plugin-sdk/phone-control": { + "types": "./dist/plugin-sdk/phone-control.d.ts", + "default": "./dist/plugin-sdk/phone-control.js" + }, + "./plugin-sdk/qwen-portal-auth": { + "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", + "default": "./dist/plugin-sdk/qwen-portal-auth.js" + }, "./plugin-sdk/synology-chat": { "types": "./dist/plugin-sdk/synology-chat.d.ts", "default": "./dist/plugin-sdk/synology-chat.js" @@ -286,6 +322,14 @@ "types": "./dist/plugin-sdk/test-utils.d.ts", "default": "./dist/plugin-sdk/test-utils.js" }, + "./plugin-sdk/talk-voice": { + "types": "./dist/plugin-sdk/talk-voice.d.ts", + "default": "./dist/plugin-sdk/talk-voice.js" + }, + "./plugin-sdk/thread-ownership": { + "types": "./dist/plugin-sdk/thread-ownership.d.ts", + "default": "./dist/plugin-sdk/thread-ownership.js" + }, "./plugin-sdk/tlon": { "types": "./dist/plugin-sdk/tlon.d.ts", "default": "./dist/plugin-sdk/tlon.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 41a6875af2c..288fefb7fd0 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -47,20 +47,31 @@ "msteams", "acpx", "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", "feishu", "googlechat", "irc", + "llm-task", "lobster", "lazy-runtime", "matrix", "mattermost", "memory-core", + "memory-lancedb", "minimax-portal-auth", "nextcloud-talk", "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", "synology-chat", "testing", "test-utils", + "talk-voice", + "thread-ownership", "tlon", "twitch", "voice-call", diff --git a/src/plugin-sdk/channel-config-schema.ts b/src/plugin-sdk/channel-config-schema.ts index bbf6191ae75..994905f9f20 100644 --- a/src/plugin-sdk/channel-config-schema.ts +++ b/src/plugin-sdk/channel-config-schema.ts @@ -1,7 +1,8 @@ /** Shared config-schema primitives for channel plugins with DM/group policy knobs. */ export { AllowFromListSchema, + buildChannelConfigSchema, buildCatchallMultiAccountChannelSchema, buildNestedDmConfigSchema, } from "../channels/plugins/config-schema.js"; -export { DmPolicySchema, GroupPolicySchema } from "../config/zod-schema.core.js"; +export { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema } from "../config/zod-schema.core.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 252063d2631..c80e681350b 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -84,7 +84,10 @@ export { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { + formatPairingApproveHint, + parseOptionalDelimitedEntries, +} from "../channels/plugins/helpers.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { From da2289869d692cb5619668eb825e9d92fca2eecf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:55:47 -0700 Subject: [PATCH 376/393] docs: remove experiments/ and design/ directories Delete all experiment plans, proposals, research docs, and the kilo-gateway-integration design doc. These are internal planning docs that do not belong on the public docs site. - 12 English experiment files - 5 zh-CN experiment translations - 1 design doc (kilo-gateway-integration) - Remove nav groups from docs.json (English + zh-CN) - Remove 3 redirects pointing to deleted experiment pages - Remove dead experiment links from hubs.md Co-Authored-By: Claude Opus 4.6 --- docs/design/kilo-gateway-integration.md | 542 ------------ docs/docs.json | 38 - .../experiments/onboarding-config-protocol.md | 43 - ...ndings-discord-channels-telegram-topics.md | 375 -------- .../plans/acp-thread-bound-agents.md | 800 ------------------ .../plans/acp-unified-streaming-refactor.md | 96 --- .../plans/browser-evaluate-cdp-refactor.md | 232 ----- .../plans/discord-async-inbound-worker.md | 337 -------- .../plans/openresponses-gateway.md | 126 --- .../plans/pty-process-supervision.md | 195 ----- .../plans/session-binding-channel-agnostic.md | 226 ----- .../proposals/acp-bound-command-auth.md | 89 -- docs/experiments/proposals/model-config.md | 36 - docs/experiments/research/memory.md | 228 ----- docs/start/hubs.md | 6 - .../experiments/onboarding-config-protocol.md | 47 - .../experiments/plans/cron-add-hardening.md | 70 -- .../plans/group-policy-hardening.md | 45 - .../plans/openresponses-gateway.md | 121 --- .../experiments/proposals/model-config.md | 42 - docs/zh-CN/experiments/research/memory.md | 235 ----- 21 files changed, 3929 deletions(-) delete mode 100644 docs/design/kilo-gateway-integration.md delete mode 100644 docs/experiments/onboarding-config-protocol.md delete mode 100644 docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md delete mode 100644 docs/experiments/plans/acp-thread-bound-agents.md delete mode 100644 docs/experiments/plans/acp-unified-streaming-refactor.md delete mode 100644 docs/experiments/plans/browser-evaluate-cdp-refactor.md delete mode 100644 docs/experiments/plans/discord-async-inbound-worker.md delete mode 100644 docs/experiments/plans/openresponses-gateway.md delete mode 100644 docs/experiments/plans/pty-process-supervision.md delete mode 100644 docs/experiments/plans/session-binding-channel-agnostic.md delete mode 100644 docs/experiments/proposals/acp-bound-command-auth.md delete mode 100644 docs/experiments/proposals/model-config.md delete mode 100644 docs/experiments/research/memory.md delete mode 100644 docs/zh-CN/experiments/onboarding-config-protocol.md delete mode 100644 docs/zh-CN/experiments/plans/cron-add-hardening.md delete mode 100644 docs/zh-CN/experiments/plans/group-policy-hardening.md delete mode 100644 docs/zh-CN/experiments/plans/openresponses-gateway.md delete mode 100644 docs/zh-CN/experiments/proposals/model-config.md delete mode 100644 docs/zh-CN/experiments/research/memory.md diff --git a/docs/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md deleted file mode 100644 index e498ea36e89..00000000000 --- a/docs/design/kilo-gateway-integration.md +++ /dev/null @@ -1,542 +0,0 @@ ---- -title: "Kilo Gateway Integration Design" -summary: "Design doc for integrating Kilo Gateway as a first-class OpenClaw provider" -read_when: - - Working on the Kilo Gateway provider integration - - Understanding provider integration patterns ---- - -# Kilo Gateway Provider Integration Design - -## Overview - -This document outlines the design for integrating "Kilo Gateway" as a first-class provider in OpenClaw, modeled after the existing OpenRouter implementation. Kilo Gateway uses an OpenAI-compatible completions API with a different base URL. - -## Design Decisions - -### 1. Provider Naming - -**Recommendation: `kilocode`** - -Rationale: - -- Matches the user config example provided (`kilocode` provider key) -- Consistent with existing provider naming patterns (e.g., `openrouter`, `opencode`, `moonshot`) -- Short and memorable -- Avoids confusion with generic "kilo" or "gateway" terms - -Alternative considered: `kilo-gateway` - rejected because hyphenated names are less common in the codebase and `kilocode` is more concise. - -### 2. Default Model Reference - -**Recommendation: `kilocode/anthropic/claude-opus-4.6`** - -Rationale: - -- Based on user config example -- Claude Opus 4.5 is a capable default model -- Explicit model selection avoids reliance on auto-routing - -### 3. Base URL Configuration - -**Recommendation: Hardcoded default with config override** - -- **Default Base URL:** `https://api.kilo.ai/api/gateway/` -- **Configurable:** Yes, via `models.providers.kilocode.baseUrl` - -This matches the pattern used by other providers like Moonshot, Venice, and Synthetic. - -### 4. Model Scanning - -**Recommendation: No dedicated model scanning endpoint initially** - -Rationale: - -- Kilo Gateway proxies to OpenRouter, so models are dynamic -- Users can manually configure models in their config -- If Kilo Gateway exposes a `/models` endpoint in the future, scanning can be added - -### 5. Special Handling - -**Recommendation: Inherit OpenRouter behavior for Anthropic models** - -Since Kilo Gateway proxies to OpenRouter, the same special handling should apply: - -- Cache TTL eligibility for `anthropic/*` models -- Extra params (cacheControlTtl) for `anthropic/*` models -- Transcript policy follows OpenRouter patterns - -## Files to Modify - -### Core Credential Management - -#### 1. `src/commands/onboard-auth.credentials.ts` - -Add: - -```typescript -export const KILOCODE_DEFAULT_MODEL_REF = "kilocode/anthropic/claude-opus-4.6"; - -export async function setKilocodeApiKey(key: string, agentDir?: string) { - upsertAuthProfile({ - profileId: "kilocode:default", - credential: { - type: "api_key", - provider: "kilocode", - key, - }, - agentDir: resolveAuthAgentDir(agentDir), - }); -} -``` - -#### 2. `src/agents/model-auth.ts` - -Add to `envMap` in `resolveEnvApiKey()`: - -```typescript -const envMap: Record = { - // ... existing entries - kilocode: "KILOCODE_API_KEY", -}; -``` - -#### 3. `src/config/io.ts` - -Add to `SHELL_ENV_EXPECTED_KEYS`: - -```typescript -const SHELL_ENV_EXPECTED_KEYS = [ - // ... existing entries - "KILOCODE_API_KEY", -]; -``` - -### Config Application - -#### 4. `src/commands/onboard-auth.config-core.ts` - -Add new functions: - -```typescript -export const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/"; - -export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[KILOCODE_DEFAULT_MODEL_REF] = { - ...models[KILOCODE_DEFAULT_MODEL_REF], - alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.kilocode; - const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; - const normalizedApiKey = resolvedApiKey?.trim(); - - providers.kilocode = { - ...existingProviderRest, - baseUrl: KILOCODE_BASE_URL, - api: "openai-completions", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - models: { - mode: cfg.models?.mode ?? "merge", - providers, - }, - }; -} - -export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyKilocodeProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: KILOCODE_DEFAULT_MODEL_REF, - }, - }, - }, - }; -} -``` - -### Auth Choice System - -#### 5. `src/commands/onboard-types.ts` - -Add to `AuthChoice` type: - -```typescript -export type AuthChoice = - // ... existing choices - "kilocode-api-key"; -// ... -``` - -Add to `OnboardOptions`: - -```typescript -export type OnboardOptions = { - // ... existing options - kilocodeApiKey?: string; - // ... -}; -``` - -#### 6. `src/commands/auth-choice-options.ts` - -Add to `AuthChoiceGroupId`: - -```typescript -export type AuthChoiceGroupId = - // ... existing groups - "kilocode"; -// ... -``` - -Add to `AUTH_CHOICE_GROUP_DEFS`: - -```typescript -{ - value: "kilocode", - label: "Kilo Gateway", - hint: "API key (OpenRouter-compatible)", - choices: ["kilocode-api-key"], -}, -``` - -Add to `buildAuthChoiceOptions()`: - -```typescript -options.push({ - value: "kilocode-api-key", - label: "Kilo Gateway API key", - hint: "OpenRouter-compatible gateway", -}); -``` - -#### 7. `src/commands/auth-choice.preferred-provider.ts` - -Add mapping: - -```typescript -const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { - // ... existing mappings - "kilocode-api-key": "kilocode", -}; -``` - -### Auth Choice Application - -#### 8. `src/commands/auth-choice.apply.api-providers.ts` - -Add import: - -```typescript -import { - // ... existing imports - applyKilocodeConfig, - applyKilocodeProviderConfig, - KILOCODE_DEFAULT_MODEL_REF, - setKilocodeApiKey, -} from "./onboard-auth.js"; -``` - -Add handling for `kilocode-api-key`: - -```typescript -if (authChoice === "kilocode-api-key") { - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const profileOrder = resolveAuthProfileOrder({ - cfg: nextConfig, - store, - provider: "kilocode", - }); - const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); - const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; - let profileId = "kilocode:default"; - let mode: "api_key" | "oauth" | "token" = "api_key"; - let hasCredential = false; - - if (existingProfileId && existingCred?.type) { - profileId = existingProfileId; - mode = - existingCred.type === "oauth" ? "oauth" : existingCred.type === "token" ? "token" : "api_key"; - hasCredential = true; - } - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kilocode") { - await setKilocodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - const envKey = resolveEnvApiKey("kilocode"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing KILOCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setKilocodeApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Kilo Gateway API key", - validate: validateApiKeyInput, - }); - await setKilocodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir); - hasCredential = true; - } - - if (hasCredential) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "kilocode", - mode, - }); - } - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: KILOCODE_DEFAULT_MODEL_REF, - applyDefaultConfig: applyKilocodeConfig, - applyProviderConfig: applyKilocodeProviderConfig, - noteDefault: KILOCODE_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; -} -``` - -Also add tokenProvider mapping at the top of the function: - -```typescript -if (params.opts.tokenProvider === "kilocode") { - authChoice = "kilocode-api-key"; -} -``` - -### CLI Registration - -#### 9. `src/cli/program/register.onboard.ts` - -Add CLI option: - -```typescript -.option("--kilocode-api-key ", "Kilo Gateway API key") -``` - -Add to action handler: - -```typescript -kilocodeApiKey: opts.kilocodeApiKey as string | undefined, -``` - -Update auth-choice help text: - -```typescript -.option( - "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|kilocode-api-key|ai-gateway-api-key|...", -) -``` - -### Non-Interactive Onboarding - -#### 10. `src/commands/onboard-non-interactive/local/auth-choice.ts` - -Add handling for `kilocode-api-key`: - -```typescript -if (authChoice === "kilocode-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "kilocode", - cfg: baseConfig, - flagValue: opts.kilocodeApiKey, - flagName: "--kilocode-api-key", - envVar: "KILOCODE_API_KEY", - }); - await setKilocodeApiKey(resolved.apiKey, agentDir); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "kilocode:default", - provider: "kilocode", - mode: "api_key", - }); - // ... apply default model -} -``` - -### Export Updates - -#### 11. `src/commands/onboard-auth.ts` - -Add exports: - -```typescript -export { - // ... existing exports - applyKilocodeConfig, - applyKilocodeProviderConfig, - KILOCODE_BASE_URL, -} from "./onboard-auth.config-core.js"; - -export { - // ... existing exports - KILOCODE_DEFAULT_MODEL_REF, - setKilocodeApiKey, -} from "./onboard-auth.credentials.js"; -``` - -### Special Handling (Optional) - -#### 12. `src/agents/pi-embedded-runner/cache-ttl.ts` - -Add Kilo Gateway support for Anthropic models: - -```typescript -export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { - const normalizedProvider = provider.toLowerCase(); - const normalizedModelId = modelId.toLowerCase(); - if (normalizedProvider === "anthropic") return true; - if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/")) - return true; - if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) return true; - return false; -} -``` - -#### 13. `src/agents/transcript-policy.ts` - -Add Kilo Gateway handling (similar to OpenRouter): - -```typescript -const isKilocodeGemini = provider === "kilocode" && modelId.toLowerCase().includes("gemini"); - -// Include in needsNonImageSanitize check -const needsNonImageSanitize = - isGoogle || isAnthropic || isMistral || isOpenRouterGemini || isKilocodeGemini; -``` - -## Configuration Structure - -### User Config Example - -```json -{ - "models": { - "mode": "merge", - "providers": { - "kilocode": { - "baseUrl": "https://api.kilo.ai/api/gateway/", - "apiKey": "xxxxx", - "api": "openai-completions", - "models": [ - { - "id": "anthropic/claude-opus-4.6", - "name": "Anthropic: Claude Opus 4.6" - }, - { "id": "minimax/minimax-m2.5:free", "name": "Minimax: Minimax M2.5" } - ] - } - } - } -} -``` - -### Auth Profile Structure - -```json -{ - "profiles": { - "kilocode:default": { - "type": "api_key", - "provider": "kilocode", - "key": "xxxxx" - } - } -} -``` - -## Testing Considerations - -1. **Unit Tests:** - - Test `setKilocodeApiKey()` writes correct profile - - Test `applyKilocodeConfig()` sets correct defaults - - Test `resolveEnvApiKey("kilocode")` returns correct env var - -2. **Integration Tests:** - - Test setup flow with `--auth-choice kilocode-api-key` - - Test non-interactive setup with `--kilocode-api-key` - - Test model selection with `kilocode/` prefix - -3. **E2E Tests:** - - Test actual API calls through Kilo Gateway (live tests) - -## Migration Notes - -- No migration needed for existing users -- New users can immediately use `kilocode-api-key` auth choice -- Existing manual config with `kilocode` provider will continue to work - -## Future Considerations - -1. **Model Catalog:** If Kilo Gateway exposes a `/models` endpoint, add scanning support similar to `scanOpenRouterModels()` - -2. **OAuth Support:** If Kilo Gateway adds OAuth, extend the auth system accordingly - -3. **Rate Limiting:** Consider adding rate limit handling specific to Kilo Gateway if needed - -4. **Documentation:** Add docs at `docs/providers/kilocode.md` explaining setup and usage - -## Summary of Changes - -| File | Change Type | Description | -| ----------------------------------------------------------- | ----------- | ----------------------------------------------------------------------- | -| `src/commands/onboard-auth.credentials.ts` | Add | `KILOCODE_DEFAULT_MODEL_REF`, `setKilocodeApiKey()` | -| `src/agents/model-auth.ts` | Modify | Add `kilocode` to `envMap` | -| `src/config/io.ts` | Modify | Add `KILOCODE_API_KEY` to shell env keys | -| `src/commands/onboard-auth.config-core.ts` | Add | `applyKilocodeProviderConfig()`, `applyKilocodeConfig()` | -| `src/commands/onboard-types.ts` | Modify | Add `kilocode-api-key` to `AuthChoice`, add `kilocodeApiKey` to options | -| `src/commands/auth-choice-options.ts` | Modify | Add `kilocode` group and option | -| `src/commands/auth-choice.preferred-provider.ts` | Modify | Add `kilocode-api-key` mapping | -| `src/commands/auth-choice.apply.api-providers.ts` | Modify | Add `kilocode-api-key` handling | -| `src/cli/program/register.onboard.ts` | Modify | Add `--kilocode-api-key` option | -| `src/commands/onboard-non-interactive/local/auth-choice.ts` | Modify | Add non-interactive handling | -| `src/commands/onboard-auth.ts` | Modify | Export new functions | -| `src/agents/pi-embedded-runner/cache-ttl.ts` | Modify | Add kilocode support | -| `src/agents/transcript-policy.ts` | Modify | Add kilocode Gemini handling | diff --git a/docs/docs.json b/docs/docs.json index 9d04ab81c5c..1d98a93c602 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -535,10 +535,6 @@ "source": "/onboarding", "destination": "/start/onboarding" }, - { - "source": "/onboarding-config-protocol", - "destination": "/experiments/onboarding-config-protocol" - }, { "source": "/pairing", "destination": "/channels/pairing" @@ -559,10 +555,6 @@ "source": "/presence", "destination": "/concepts/presence" }, - { - "source": "/proposals/model-config", - "destination": "/experiments/proposals/model-config" - }, { "source": "/provider-routing", "destination": "/channels/channel-routing" @@ -583,10 +575,6 @@ "source": "/remote-gateway-readme", "destination": "/gateway/remote-gateway-readme" }, - { - "source": "/research/memory", - "destination": "/experiments/research/memory" - }, { "source": "/rpc", "destination": "/reference/rpc" @@ -1358,21 +1346,6 @@ { "group": "Release policy", "pages": ["reference/RELEASING", "reference/test"] - }, - { - "group": "Experiments", - "pages": [ - "design/kilo-gateway-integration", - "experiments/onboarding-config-protocol", - "experiments/plans/acp-thread-bound-agents", - "experiments/plans/acp-unified-streaming-refactor", - "experiments/plans/browser-evaluate-cdp-refactor", - "experiments/plans/openresponses-gateway", - "experiments/plans/pty-process-supervision", - "experiments/plans/session-binding-channel-agnostic", - "experiments/research/memory", - "experiments/proposals/model-config" - ] } ] }, @@ -1938,17 +1911,6 @@ { "group": "发布策略", "pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"] - }, - { - "group": "实验性功能", - "pages": [ - "zh-CN/experiments/onboarding-config-protocol", - "zh-CN/experiments/plans/openresponses-gateway", - "zh-CN/experiments/plans/cron-add-hardening", - "zh-CN/experiments/plans/group-policy-hardening", - "zh-CN/experiments/research/memory", - "zh-CN/experiments/proposals/model-config" - ] } ] }, diff --git a/docs/experiments/onboarding-config-protocol.md b/docs/experiments/onboarding-config-protocol.md deleted file mode 100644 index e3b9d9dff10..00000000000 --- a/docs/experiments/onboarding-config-protocol.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -summary: "RPC protocol notes for setup wizard and config schema" -read_when: "Changing setup wizard steps or config schema endpoints" -title: "Onboarding and Config Protocol" ---- - -# Onboarding + Config Protocol - -Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI. - -## Components - -- Wizard engine (shared session + prompts + onboarding state). -- CLI onboarding uses the same wizard flow as the UI clients. -- Gateway RPC exposes wizard + config schema endpoints. -- macOS onboarding uses the wizard step model. -- Web UI renders config forms from JSON Schema + UI hints. - -## Gateway RPC - -- `wizard.start` params: `{ mode?: "local"|"remote", workspace?: string }` -- `wizard.next` params: `{ sessionId, answer?: { stepId, value? } }` -- `wizard.cancel` params: `{ sessionId }` -- `wizard.status` params: `{ sessionId }` -- `config.schema` params: `{}` -- `config.schema.lookup` params: `{ path }` - - `path` accepts standard config segments plus slash-delimited plugin ids, for example `plugins.entries.pack/one.config`. - -Responses (shape) - -- Wizard: `{ sessionId, done, step?, status?, error? }` -- Config schema: `{ schema, uiHints, version, generatedAt }` -- Config schema lookup: `{ path, schema, hint?, hintPath?, children[] }` - -## UI Hints - -- `uiHints` keyed by path; optional metadata (label/help/group/order/advanced/sensitive/placeholder). -- Sensitive fields render as password inputs; no redaction layer. -- Unsupported schema nodes fall back to the raw JSON editor. - -## Notes - -- This doc is the single place to track protocol refactors for onboarding/config. diff --git a/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md b/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md deleted file mode 100644 index e85ddeaf4a7..00000000000 --- a/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md +++ /dev/null @@ -1,375 +0,0 @@ -# ACP Persistent Bindings for Discord Channels and Telegram Topics - -Status: Draft - -## Summary - -Introduce persistent ACP bindings that map: - -- Discord channels (and existing threads, where needed), and -- Telegram forum topics in groups/supergroups (`chatId:topic:topicId`) - -to long-lived ACP sessions, with binding state stored in top-level `bindings[]` entries using explicit binding types. - -This makes ACP usage in high-traffic messaging channels predictable and durable, so users can create dedicated channels/topics such as `codex`, `claude-1`, or `claude-myrepo`. - -## Why - -Current thread-bound ACP behavior is optimized for ephemeral Discord thread workflows. Telegram does not have the same thread model; it has forum topics in groups/supergroups. Users want stable, always-on ACP “workspaces” in chat surfaces, not only temporary thread sessions. - -## Goals - -- Support durable ACP binding for: - - Discord channels/threads - - Telegram forum topics (groups/supergroups) -- Make binding source-of-truth config-driven. -- Keep `/acp`, `/new`, `/reset`, `/focus`, and delivery behavior consistent across Discord and Telegram. -- Preserve existing temporary binding flows for ad-hoc usage. - -## Non-Goals - -- Full redesign of ACP runtime/session internals. -- Removing existing ephemeral binding flows. -- Expanding to every channel in the first iteration. -- Implementing Telegram channel direct-messages topics (`direct_messages_topic_id`) in this phase. -- Implementing Telegram private-chat topic variants in this phase. - -## UX Direction - -### 1) Two binding types - -- **Persistent binding**: saved in config, reconciled on startup, intended for “named workspace” channels/topics. -- **Temporary binding**: runtime-only, expires by idle/max-age policy. - -### 2) Command behavior - -- `/acp spawn ... --thread here|auto|off` remains available. -- Add explicit bind lifecycle controls: - - `/acp bind [session|agent] [--persist]` - - `/acp unbind [--persist]` - - `/acp status` includes whether binding is `persistent` or `temporary`. -- In bound conversations, `/new` and `/reset` reset the bound ACP session in place and keep the binding attached. - -### 3) Conversation identity - -- Use canonical conversation IDs: - - Discord: channel/thread ID. - - Telegram topic: `chatId:topic:topicId`. -- Never key Telegram bindings by bare topic ID alone. - -## Config Model (Proposed) - -Unify routing and persistent ACP binding configuration in top-level `bindings[]` with explicit `type` discriminator: - -```jsonc -{ - "agents": { - "list": [ - { - "id": "main", - "default": true, - "workspace": "~/.openclaw/workspace-main", - "runtime": { "type": "embedded" }, - }, - { - "id": "codex", - "workspace": "~/.openclaw/workspace-codex", - "runtime": { - "type": "acp", - "acp": { - "agent": "codex", - "backend": "acpx", - "mode": "persistent", - "cwd": "/workspace/repo-a", - }, - }, - }, - { - "id": "claude", - "workspace": "~/.openclaw/workspace-claude", - "runtime": { - "type": "acp", - "acp": { - "agent": "claude", - "backend": "acpx", - "mode": "persistent", - "cwd": "/workspace/repo-b", - }, - }, - }, - ], - }, - "acp": { - "enabled": true, - "backend": "acpx", - "allowedAgents": ["codex", "claude"], - }, - "bindings": [ - // Route bindings (existing behavior) - { - "type": "route", - "agentId": "main", - "match": { "channel": "discord", "accountId": "default" }, - }, - { - "type": "route", - "agentId": "main", - "match": { "channel": "telegram", "accountId": "default" }, - }, - // Persistent ACP conversation bindings - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "222222222222222222" }, - }, - "acp": { - "label": "codex-main", - "mode": "persistent", - "cwd": "/workspace/repo-a", - "backend": "acpx", - }, - }, - { - "type": "acp", - "agentId": "claude", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "333333333333333333" }, - }, - "acp": { - "label": "claude-repo-b", - "mode": "persistent", - "cwd": "/workspace/repo-b", - }, - }, - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "telegram", - "accountId": "default", - "peer": { "kind": "group", "id": "-1001234567890:topic:42" }, - }, - "acp": { - "label": "tg-codex-42", - "mode": "persistent", - }, - }, - ], - "channels": { - "discord": { - "guilds": { - "111111111111111111": { - "channels": { - "222222222222222222": { - "enabled": true, - "requireMention": false, - }, - "333333333333333333": { - "enabled": true, - "requireMention": false, - }, - }, - }, - }, - }, - "telegram": { - "groups": { - "-1001234567890": { - "topics": { - "42": { - "requireMention": false, - }, - }, - }, - }, - }, - }, -} -``` - -### Minimal Example (No Per-Binding ACP Overrides) - -```jsonc -{ - "agents": { - "list": [ - { "id": "main", "default": true, "runtime": { "type": "embedded" } }, - { - "id": "codex", - "runtime": { - "type": "acp", - "acp": { "agent": "codex", "backend": "acpx", "mode": "persistent" }, - }, - }, - { - "id": "claude", - "runtime": { - "type": "acp", - "acp": { "agent": "claude", "backend": "acpx", "mode": "persistent" }, - }, - }, - ], - }, - "acp": { "enabled": true, "backend": "acpx" }, - "bindings": [ - { - "type": "route", - "agentId": "main", - "match": { "channel": "discord", "accountId": "default" }, - }, - { - "type": "route", - "agentId": "main", - "match": { "channel": "telegram", "accountId": "default" }, - }, - - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "222222222222222222" }, - }, - }, - { - "type": "acp", - "agentId": "claude", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "333333333333333333" }, - }, - }, - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "telegram", - "accountId": "default", - "peer": { "kind": "group", "id": "-1009876543210:topic:5" }, - }, - }, - ], -} -``` - -Notes: - -- `bindings[].type` is explicit: - - `route`: normal agent routing. - - `acp`: persistent ACP harness binding for a matched conversation. -- For `type: "acp"`, `match.peer.id` is the canonical conversation key: - - Discord channel/thread: raw channel/thread ID. - - Telegram topic: `chatId:topic:topicId`. -- `bindings[].acp.backend` is optional. Backend fallback order: - 1. `bindings[].acp.backend` - 2. `agents.list[].runtime.acp.backend` - 3. global `acp.backend` -- `mode`, `cwd`, and `label` follow the same override pattern (`binding override -> agent runtime default -> global/default behavior`). -- Keep existing `session.threadBindings.*` and `channels.discord.threadBindings.*` for temporary binding policies. -- Persistent entries declare desired state; runtime reconciles to actual ACP sessions/bindings. -- One active ACP binding per conversation node is the intended model. -- Backward compatibility: missing `type` is interpreted as `route` for legacy entries. - -### Backend Selection - -- ACP session initialization already uses configured backend selection during spawn (`acp.backend` today). -- This proposal extends spawn/reconcile logic to prefer typed ACP binding overrides: - - `bindings[].acp.backend` for conversation-local override. - - `agents.list[].runtime.acp.backend` for per-agent defaults. -- If no override exists, keep current behavior (`acp.backend` default). - -## Architecture Fit in Current System - -### Reuse existing components - -- `SessionBindingService` already supports channel-agnostic conversation references. -- ACP spawn/bind flows already support binding through service APIs. -- Telegram already carries topic/thread context via `MessageThreadId` and `chatId`. - -### New/extended components - -- **Telegram binding adapter** (parallel to Discord adapter): - - register adapter per Telegram account, - - resolve/list/bind/unbind/touch by canonical conversation ID. -- **Typed binding resolver/index**: - - split `bindings[]` into `route` and `acp` views, - - keep `resolveAgentRoute` on `route` bindings only, - - resolve persistent ACP intent from `acp` bindings only. -- **Inbound binding resolution for Telegram**: - - resolve bound session before route finalization (Discord already does this). -- **Persistent binding reconciler**: - - on startup: load configured top-level `type: "acp"` bindings, ensure ACP sessions exist, ensure bindings exist. - - on config change: apply deltas safely. -- **Cutover model**: - - no channel-local ACP binding fallback is read, - - persistent ACP bindings are sourced only from top-level `bindings[].type="acp"` entries. - -## Phased Delivery - -### Phase 1: Typed binding schema foundation - -- Extend config schema to support `bindings[].type` discriminator: - - `route`, - - `acp` with optional `acp` override object (`mode`, `backend`, `cwd`, `label`). -- Extend agent schema with runtime descriptor to mark ACP-native agents (`agents.list[].runtime.type`). -- Add parser/indexer split for route vs ACP bindings. - -### Phase 2: Runtime resolution + Discord/Telegram parity - -- Resolve persistent ACP bindings from top-level `type: "acp"` entries for: - - Discord channels/threads, - - Telegram forum topics (`chatId:topic:topicId` canonical IDs). -- Implement Telegram binding adapter and inbound bound-session override parity with Discord. -- Do not include Telegram direct/private topic variants in this phase. - -### Phase 3: Command parity and resets - -- Align `/acp`, `/new`, `/reset`, and `/focus` behavior in bound Telegram/Discord conversations. -- Ensure binding survives reset flows as configured. - -### Phase 4: Hardening - -- Better diagnostics (`/acp status`, startup reconciliation logs). -- Conflict handling and health checks. - -## Guardrails and Policy - -- Respect ACP enablement and sandbox restrictions exactly as today. -- Keep explicit account scoping (`accountId`) to avoid cross-account bleed. -- Fail closed on ambiguous routing. -- Keep mention/access policy behavior explicit per channel config. - -## Testing Plan - -- Unit: - - conversation ID normalization (especially Telegram topic IDs), - - reconciler create/update/delete paths, - - `/acp bind --persist` and unbind flows. -- Integration: - - inbound Telegram topic -> bound ACP session resolution, - - inbound Discord channel/thread -> persistent binding precedence. -- Regression: - - temporary bindings continue to work, - - unbound channels/topics keep current routing behavior. - -## Open Questions - -- Should `/acp spawn --thread auto` in Telegram topic default to `here`? -- Should persistent bindings always bypass mention-gating in bound conversations, or require explicit `requireMention=false`? -- Should `/focus` gain `--persist` as an alias for `/acp bind --persist`? - -## Rollout - -- Ship as opt-in per conversation (`bindings[].type="acp"` entry present). -- Start with Discord + Telegram only. -- Add docs with examples for: - - “one channel/topic per agent” - - “multiple channels/topics per same agent with different `cwd`” - - “team naming patterns (`codex-1`, `claude-repo-x`)". diff --git a/docs/experiments/plans/acp-thread-bound-agents.md b/docs/experiments/plans/acp-thread-bound-agents.md deleted file mode 100644 index a0637cedee5..00000000000 --- a/docs/experiments/plans/acp-thread-bound-agents.md +++ /dev/null @@ -1,800 +0,0 @@ ---- -summary: "Integrate ACP coding agents via a first-class ACP control plane in core and plugin-backed runtimes (acpx first)" -owner: "onutc" -status: "draft" -last_updated: "2026-02-25" -title: "ACP Thread Bound Agents" ---- - -# ACP Thread Bound Agents - -## Overview - -This plan defines how OpenClaw should support ACP coding agents in thread-capable channels (Discord first) with production-level lifecycle and recovery. - -Related document: - -- [Unified Runtime Streaming Refactor Plan](/experiments/plans/acp-unified-streaming-refactor) - -Target user experience: - -- a user spawns or focuses an ACP session into a thread -- user messages in that thread route to the bound ACP session -- agent output streams back to the same thread persona -- session can be persistent or one shot with explicit cleanup controls - -## Decision summary - -Long term recommendation is a hybrid architecture: - -- OpenClaw core owns ACP control plane concerns - - session identity and metadata - - thread binding and routing decisions - - delivery invariants and duplicate suppression - - lifecycle cleanup and recovery semantics -- ACP runtime backend is pluggable - - first backend is an acpx-backed plugin service - - runtime does ACP transport, queueing, cancel, reconnect - -OpenClaw should not reimplement ACP transport internals in core. -OpenClaw should not rely on a pure plugin-only interception path for routing. - -## North-star architecture (holy grail) - -Treat ACP as a first-class control plane in OpenClaw, with pluggable runtime adapters. - -Non-negotiable invariants: - -- every ACP thread binding references a valid ACP session record -- every ACP session has explicit lifecycle state (`creating`, `idle`, `running`, `cancelling`, `closed`, `error`) -- every ACP run has explicit run state (`queued`, `running`, `completed`, `failed`, `cancelled`) -- spawn, bind, and initial enqueue are atomic -- command retries are idempotent (no duplicate runs or duplicate Discord outputs) -- bound-thread channel output is a projection of ACP run events, never ad-hoc side effects - -Long-term ownership model: - -- `AcpSessionManager` is the single ACP writer and orchestrator -- manager lives in gateway process first; can be moved to a dedicated sidecar later behind the same interface -- per ACP session key, manager owns one in-memory actor (serialized command execution) -- adapters (`acpx`, future backends) are transport/runtime implementations only - -Long-term persistence model: - -- move ACP control-plane state to a dedicated SQLite store (WAL mode) under OpenClaw state dir -- keep `SessionEntry.acp` as compatibility projection during migration, not source-of-truth -- store ACP events append-only to support replay, crash recovery, and deterministic delivery - -### Delivery strategy (bridge to holy-grail) - -- short-term bridge - - keep current thread binding mechanics and existing ACP config surface - - fix metadata-gap bugs and route ACP turns through a single core ACP branch - - add idempotency keys and fail-closed routing checks immediately -- long-term cutover - - move ACP source-of-truth to control-plane DB + actors - - make bound-thread delivery purely event-projection based - - remove legacy fallback behavior that depends on opportunistic session-entry metadata - -## Why not pure plugin only - -Current plugin hooks are not sufficient for end to end ACP session routing without core changes. - -- inbound routing from thread binding resolves to a session key in core dispatch first -- message hooks are fire-and-forget and cannot short-circuit the main reply path -- plugin commands are good for control operations but not for replacing core per-turn dispatch flow - -Result: - -- ACP runtime can be pluginized -- ACP routing branch must exist in core - -## Existing foundation to reuse - -Already implemented and should remain canonical: - -- thread binding target supports `subagent` and `acp` -- inbound thread routing override resolves by binding before normal dispatch -- outbound thread identity via webhook in reply delivery -- `/focus` and `/unfocus` flow with ACP target compatibility -- persistent binding store with restore on startup -- unbind lifecycle on archive, delete, unfocus, reset, and delete - -This plan extends that foundation rather than replacing it. - -## Architecture - -### Boundary model - -Core (must be in OpenClaw core): - -- ACP session-mode dispatch branch in the reply pipeline -- delivery arbitration to avoid parent plus thread duplication -- ACP control-plane persistence (with `SessionEntry.acp` compatibility projection during migration) -- lifecycle unbind and runtime detach semantics tied to session reset/delete - -Plugin backend (acpx implementation): - -- ACP runtime worker supervision -- acpx process invocation and event parsing -- ACP command handlers (`/acp ...`) and operator UX -- backend-specific config defaults and diagnostics - -### Runtime ownership model - -- one gateway process owns ACP orchestration state -- ACP execution runs in supervised child processes via acpx backend -- process strategy is long lived per active ACP session key, not per message - -This avoids startup cost on every prompt and keeps cancel and reconnect semantics reliable. - -### Core runtime contract - -Add a core ACP runtime contract so routing code does not depend on CLI details and can switch backends without changing dispatch logic: - -```ts -export type AcpRuntimePromptMode = "prompt" | "steer"; - -export type AcpRuntimeHandle = { - sessionKey: string; - backend: string; - runtimeSessionName: string; -}; - -export type AcpRuntimeEvent = - | { type: "text_delta"; stream: "output" | "thought"; text: string } - | { type: "tool_call"; name: string; argumentsText: string } - | { type: "done"; usage?: Record } - | { type: "error"; code: string; message: string; retryable?: boolean }; - -export interface AcpRuntime { - ensureSession(input: { - sessionKey: string; - agent: string; - mode: "persistent" | "oneshot"; - cwd?: string; - env?: Record; - idempotencyKey: string; - }): Promise; - - submit(input: { - handle: AcpRuntimeHandle; - text: string; - mode: AcpRuntimePromptMode; - idempotencyKey: string; - }): Promise<{ runtimeRunId: string }>; - - stream(input: { - handle: AcpRuntimeHandle; - runtimeRunId: string; - onEvent: (event: AcpRuntimeEvent) => Promise | void; - signal?: AbortSignal; - }): Promise; - - cancel(input: { - handle: AcpRuntimeHandle; - runtimeRunId?: string; - reason?: string; - idempotencyKey: string; - }): Promise; - - close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise; - - health?(): Promise<{ ok: boolean; details?: string }>; -} -``` - -Implementation detail: - -- first backend: `AcpxRuntime` shipped as a plugin service -- core resolves runtime via registry and fails with explicit operator error when no ACP runtime backend is available - -### Control-plane data model and persistence - -Long-term source-of-truth is a dedicated ACP SQLite database (WAL mode), for transactional updates and crash-safe recovery: - -- `acp_sessions` - - `session_key` (pk), `backend`, `agent`, `mode`, `cwd`, `state`, `created_at`, `updated_at`, `last_error` -- `acp_runs` - - `run_id` (pk), `session_key` (fk), `state`, `requester_message_id`, `idempotency_key`, `started_at`, `ended_at`, `error_code`, `error_message` -- `acp_bindings` - - `binding_key` (pk), `thread_id`, `channel_id`, `account_id`, `session_key` (fk), `expires_at`, `bound_at` -- `acp_events` - - `event_id` (pk), `run_id` (fk), `seq`, `kind`, `payload_json`, `created_at` -- `acp_delivery_checkpoint` - - `run_id` (pk/fk), `last_event_seq`, `last_discord_message_id`, `updated_at` -- `acp_idempotency` - - `scope`, `idempotency_key`, `result_json`, `created_at`, unique `(scope, idempotency_key)` - -```ts -export type AcpSessionMeta = { - backend: string; - agent: string; - runtimeSessionName: string; - mode: "persistent" | "oneshot"; - cwd?: string; - state: "idle" | "running" | "error"; - lastActivityAt: number; - lastError?: string; -}; -``` - -Storage rules: - -- keep `SessionEntry.acp` as a compatibility projection during migration -- process ids and sockets stay in memory only -- durable lifecycle and run status live in ACP DB, not generic session JSON -- if runtime owner dies, gateway rehydrates from ACP DB and resumes from checkpoints - -### Routing and delivery - -Inbound: - -- keep current thread binding lookup as first routing step -- if bound target is ACP session, route to ACP runtime branch instead of `getReplyFromConfig` -- explicit `/acp steer` command uses `mode: "steer"` - -Outbound: - -- ACP event stream is normalized to OpenClaw reply chunks -- delivery target is resolved through existing bound destination path -- when a bound thread is active for that session turn, parent channel completion is suppressed - -Streaming policy: - -- stream partial output with coalescing window -- configurable min interval and max chunk bytes to stay under Discord rate limits -- final message always emitted on completion or failure - -### State machines and transaction boundaries - -Session state machine: - -- `creating -> idle -> running -> idle` -- `running -> cancelling -> idle | error` -- `idle -> closed` -- `error -> idle | closed` - -Run state machine: - -- `queued -> running -> completed` -- `running -> failed | cancelled` -- `queued -> cancelled` - -Required transaction boundaries: - -- spawn transaction - - create ACP session row - - create/update ACP thread binding row - - enqueue initial run row -- close transaction - - mark session closed - - delete/expire binding rows - - write final close event -- cancel transaction - - mark target run cancelling/cancelled with idempotency key - -No partial success is allowed across these boundaries. - -### Per-session actor model - -`AcpSessionManager` runs one actor per ACP session key: - -- actor mailbox serializes `submit`, `cancel`, `close`, and `stream` side effects -- actor owns runtime handle hydration and runtime adapter process lifecycle for that session -- actor writes run events in-order (`seq`) before any Discord delivery -- actor updates delivery checkpoints after successful outbound send - -This removes cross-turn races and prevents duplicate or out-of-order thread output. - -### Idempotency and delivery projection - -All external ACP actions must carry idempotency keys: - -- spawn idempotency key -- prompt/steer idempotency key -- cancel idempotency key -- close idempotency key - -Delivery rules: - -- Discord messages are derived from `acp_events` plus `acp_delivery_checkpoint` -- retries resume from checkpoint without re-sending already delivered chunks -- final reply emission is exactly-once per run from projection logic - -### Recovery and self-healing - -On gateway start: - -- load non-terminal ACP sessions (`creating`, `idle`, `running`, `cancelling`, `error`) -- recreate actors lazily on first inbound event or eagerly under configured cap -- reconcile any `running` runs missing heartbeats and mark `failed` or recover via adapter - -On inbound Discord thread message: - -- if binding exists but ACP session is missing, fail closed with explicit stale-binding message -- optionally auto-unbind stale binding after operator-safe validation -- never silently route stale ACP bindings to normal LLM path - -### Lifecycle and safety - -Supported operations: - -- cancel current run: `/acp cancel` -- unbind thread: `/unfocus` -- close ACP session: `/acp close` -- auto close idle sessions by effective TTL - -TTL policy: - -- effective TTL is minimum of - - global/session TTL - - Discord thread binding TTL - - ACP runtime owner TTL - -Safety controls: - -- allowlist ACP agents by name -- restrict workspace roots for ACP sessions -- env allowlist passthrough -- max concurrent ACP sessions per account and globally -- bounded restart backoff for runtime crashes - -## Config surface - -Core keys: - -- `acp.enabled` -- `acp.dispatch.enabled` (independent ACP routing kill switch) -- `acp.backend` (default `acpx`) -- `acp.defaultAgent` -- `acp.allowedAgents[]` -- `acp.maxConcurrentSessions` -- `acp.stream.coalesceIdleMs` -- `acp.stream.maxChunkChars` -- `acp.runtime.ttlMinutes` -- `acp.controlPlane.store` (`sqlite` default) -- `acp.controlPlane.storePath` -- `acp.controlPlane.recovery.eagerActors` -- `acp.controlPlane.recovery.reconcileRunningAfterMs` -- `acp.controlPlane.checkpoint.flushEveryEvents` -- `acp.controlPlane.checkpoint.flushEveryMs` -- `acp.idempotency.ttlHours` -- `channels.discord.threadBindings.spawnAcpSessions` - -Plugin/backend keys (acpx plugin section): - -- backend command/path overrides -- backend env allowlist -- backend per-agent presets -- backend startup/stop timeouts -- backend max inflight runs per session - -## Implementation specification - -### Control-plane modules (new) - -Add dedicated ACP control-plane modules in core: - -- `src/acp/control-plane/manager.ts` - - owns ACP actors, lifecycle transitions, command serialization -- `src/acp/control-plane/store.ts` - - SQLite schema management, transactions, query helpers -- `src/acp/control-plane/events.ts` - - typed ACP event definitions and serialization -- `src/acp/control-plane/checkpoint.ts` - - durable delivery checkpoints and replay cursors -- `src/acp/control-plane/idempotency.ts` - - idempotency key reservation and response replay -- `src/acp/control-plane/recovery.ts` - - boot-time reconciliation and actor rehydrate plan - -Compatibility bridge modules: - -- `src/acp/runtime/session-meta.ts` - - remains temporarily for projection into `SessionEntry.acp` - - must stop being source-of-truth after migration cutover - -### Required invariants (must enforce in code) - -- ACP session creation and thread bind are atomic (single transaction) -- there is at most one active run per ACP session actor at a time -- event `seq` is strictly increasing per run -- delivery checkpoint never advances past last committed event -- idempotency replay returns previous success payload for duplicate command keys -- stale/missing ACP metadata cannot route into normal non-ACP reply path - -### Core touchpoints - -Core files to change: - -- `src/auto-reply/reply/dispatch-from-config.ts` - - ACP branch calls `AcpSessionManager.submit` and event-projection delivery - - remove direct ACP fallback that bypasses control-plane invariants -- `src/auto-reply/reply/inbound-context.ts` (or nearest normalized context boundary) - - expose normalized routing keys and idempotency seeds for ACP control plane -- `src/config/sessions/types.ts` - - keep `SessionEntry.acp` as projection-only compatibility field -- `src/gateway/server-methods/sessions.ts` - - reset/delete/archive must call ACP manager close/unbind transaction path -- `src/infra/outbound/bound-delivery-router.ts` - - enforce fail-closed destination behavior for ACP bound session turns -- `src/discord/monitor/thread-bindings.ts` - - add ACP stale-binding validation helpers wired to control-plane lookups -- `src/auto-reply/reply/commands-acp.ts` - - route spawn/cancel/close/steer through ACP manager APIs -- `src/agents/acp-spawn.ts` - - stop ad-hoc metadata writes; call ACP manager spawn transaction -- `src/plugin-sdk/**` and plugin runtime bridge - - expose ACP backend registration and health semantics cleanly - -Core files explicitly not replaced: - -- `src/discord/monitor/message-handler.preflight.ts` - - keep thread binding override behavior as the canonical session-key resolver - -### ACP runtime registry API - -Add a core registry module: - -- `src/acp/runtime/registry.ts` - -Required API: - -```ts -export type AcpRuntimeBackend = { - id: string; - runtime: AcpRuntime; - healthy?: () => boolean; -}; - -export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void; -export function unregisterAcpRuntimeBackend(id: string): void; -export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null; -export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend; -``` - -Behavior: - -- `requireAcpRuntimeBackend` throws a typed ACP backend missing error when unavailable -- plugin service registers backend on `start` and unregisters on `stop` -- runtime lookups are read-only and process-local - -### acpx runtime plugin contract (implementation detail) - -For the first production backend (`extensions/acpx`), OpenClaw and acpx are -connected with a strict command contract: - -- backend id: `acpx` -- plugin service id: `acpx-runtime` -- runtime handle encoding: `runtimeSessionName = acpx:v1:` -- encoded payload fields: - - `name` (acpx named session; uses OpenClaw `sessionKey`) - - `agent` (acpx agent command) - - `cwd` (session workspace root) - - `mode` (`persistent | oneshot`) - -Command mapping: - -- ensure session: - - `acpx --format json --json-strict --cwd sessions ensure --name ` -- prompt turn: - - `acpx --format json --json-strict --cwd prompt --session --file -` -- cancel: - - `acpx --format json --json-strict --cwd cancel --session ` -- close: - - `acpx --format json --json-strict --cwd sessions close ` - -Streaming: - -- OpenClaw consumes ndjson events from `acpx --format json --json-strict` -- `text` => `text_delta/output` -- `thought` => `text_delta/thought` -- `tool_call` => `tool_call` -- `done` => `done` -- `error` => `error` - -### Session schema patch - -Patch `SessionEntry` in `src/config/sessions/types.ts`: - -```ts -type SessionAcpMeta = { - backend: string; - agent: string; - runtimeSessionName: string; - mode: "persistent" | "oneshot"; - cwd?: string; - state: "idle" | "running" | "error"; - lastActivityAt: number; - lastError?: string; -}; -``` - -Persisted field: - -- `SessionEntry.acp?: SessionAcpMeta` - -Migration rules: - -- phase A: dual-write (`acp` projection + ACP SQLite source-of-truth) -- phase B: read-primary from ACP SQLite, fallback-read from legacy `SessionEntry.acp` -- phase C: migration command backfills missing ACP rows from valid legacy entries -- phase D: remove fallback-read and keep projection optional for UX only -- legacy fields (`cliSessionIds`, `claudeCliSessionId`) remain untouched - -### Error contract - -Add stable ACP error codes and user-facing messages: - -- `ACP_BACKEND_MISSING` - - message: `ACP runtime backend is not configured. Install and enable the acpx runtime plugin.` -- `ACP_BACKEND_UNAVAILABLE` - - message: `ACP runtime backend is currently unavailable. Try again in a moment.` -- `ACP_SESSION_INIT_FAILED` - - message: `Could not initialize ACP session runtime.` -- `ACP_TURN_FAILED` - - message: `ACP turn failed before completion.` - -Rules: - -- return actionable user-safe message in-thread -- log detailed backend/system error only in runtime logs -- never silently fall back to normal LLM path when ACP routing was explicitly selected - -### Duplicate delivery arbitration - -Single routing rule for ACP bound turns: - -- if an active thread binding exists for the target ACP session and requester context, deliver only to that bound thread -- do not also send to parent channel for the same turn -- if bound destination selection is ambiguous, fail closed with explicit error (no implicit parent fallback) -- if no active binding exists, use normal session destination behavior - -### Observability and operational readiness - -Required metrics: - -- ACP spawn success/failure count by backend and error code -- ACP run latency percentiles (queue wait, runtime turn time, delivery projection time) -- ACP actor restart count and restart reason -- stale-binding detection count -- idempotency replay hit rate -- Discord delivery retry and rate-limit counters - -Required logs: - -- structured logs keyed by `sessionKey`, `runId`, `backend`, `threadId`, `idempotencyKey` -- explicit state transition logs for session and run state machines -- adapter command logs with redaction-safe arguments and exit summary - -Required diagnostics: - -- `/acp sessions` includes state, active run, last error, and binding status -- `/acp doctor` (or equivalent) validates backend registration, store health, and stale bindings - -### Config precedence and effective values - -ACP enablement precedence: - -- account override: `channels.discord.accounts..threadBindings.spawnAcpSessions` -- channel override: `channels.discord.threadBindings.spawnAcpSessions` -- global ACP gate: `acp.enabled` -- dispatch gate: `acp.dispatch.enabled` -- backend availability: registered backend for `acp.backend` - -Auto-enable behavior: - -- when ACP is configured (`acp.enabled=true`, `acp.dispatch.enabled=true`, or - `acp.backend=acpx`), plugin auto-enable marks `plugins.entries.acpx.enabled=true` - unless denylisted or explicitly disabled - -TTL effective value: - -- `min(session ttl, discord thread binding ttl, acp runtime ttl)` - -### Test map - -Unit tests: - -- `src/acp/runtime/registry.test.ts` (new) -- `src/auto-reply/reply/dispatch-from-config.acp.test.ts` (new) -- `src/infra/outbound/bound-delivery-router.test.ts` (extend ACP fail-closed cases) -- `src/config/sessions/types.test.ts` or nearest session-store tests (ACP metadata persistence) - -Integration tests: - -- `src/discord/monitor/reply-delivery.test.ts` (bound ACP delivery target behavior) -- `src/discord/monitor/message-handler.preflight*.test.ts` (bound ACP session-key routing continuity) -- acpx plugin runtime tests in backend package (service register/start/stop + event normalization) - -Gateway e2e tests: - -- `src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts` (extend ACP reset/delete lifecycle coverage) -- ACP thread turn roundtrip e2e for spawn, message, stream, cancel, unfocus, restart recovery - -### Rollout guard - -Add independent ACP dispatch kill switch: - -- `acp.dispatch.enabled` default `false` for first release -- when disabled: - - ACP spawn/focus control commands may still bind sessions - - ACP dispatch path does not activate - - user receives explicit message that ACP dispatch is disabled by policy -- after canary validation, default can be flipped to `true` in a later release - -## Command and UX plan - -### New commands - -- `/acp spawn [--mode persistent|oneshot] [--thread auto|here|off]` -- `/acp cancel [session]` -- `/acp steer ` -- `/acp close [session]` -- `/acp sessions` - -### Existing command compatibility - -- `/focus ` continues to support ACP targets -- `/unfocus` keeps current semantics -- `/session idle` and `/session max-age` replace the old TTL override - -## Phased rollout - -### Phase 0 ADR and schema freeze - -- ship ADR for ACP control-plane ownership and adapter boundaries -- freeze DB schema (`acp_sessions`, `acp_runs`, `acp_bindings`, `acp_events`, `acp_delivery_checkpoint`, `acp_idempotency`) -- define stable ACP error codes, event contract, and state-transition guards - -### Phase 1 Control-plane foundation in core - -- implement `AcpSessionManager` and per-session actor runtime -- implement ACP SQLite store and transaction helpers -- implement idempotency store and replay helpers -- implement event append + delivery checkpoint modules -- wire spawn/cancel/close APIs to manager with transactional guarantees - -### Phase 2 Core routing and lifecycle integration - -- route thread-bound ACP turns from dispatch pipeline into ACP manager -- enforce fail-closed routing when ACP binding/session invariants fail -- integrate reset/delete/archive/unfocus lifecycle with ACP close/unbind transactions -- add stale-binding detection and optional auto-unbind policy - -### Phase 3 acpx backend adapter/plugin - -- implement `acpx` adapter against runtime contract (`ensureSession`, `submit`, `stream`, `cancel`, `close`) -- add backend health checks and startup/teardown registration -- normalize acpx ndjson events into ACP runtime events -- enforce backend timeouts, process supervision, and restart/backoff policy - -### Phase 4 Delivery projection and channel UX (Discord first) - -- implement event-driven channel projection with checkpoint resume (Discord first) -- coalesce streaming chunks with rate-limit aware flush policy -- guarantee exactly-once final completion message per run -- ship `/acp spawn`, `/acp cancel`, `/acp steer`, `/acp close`, `/acp sessions` - -### Phase 5 Migration and cutover - -- introduce dual-write to `SessionEntry.acp` projection plus ACP SQLite source-of-truth -- add migration utility for legacy ACP metadata rows -- flip read path to ACP SQLite primary -- remove legacy fallback routing that depends on missing `SessionEntry.acp` - -### Phase 6 Hardening, SLOs, and scale limits - -- enforce concurrency limits (global/account/session), queue policies, and timeout budgets -- add full telemetry, dashboards, and alert thresholds -- chaos-test crash recovery and duplicate-delivery suppression -- publish runbook for backend outage, DB corruption, and stale-binding remediation - -### Full implementation checklist - -- core control-plane modules and tests -- DB migrations and rollback plan -- ACP manager API integration across dispatch and commands -- adapter registration interface in plugin runtime bridge -- acpx adapter implementation and tests -- thread-capable channel delivery projection logic with checkpoint replay (Discord first) -- lifecycle hooks for reset/delete/archive/unfocus -- stale-binding detector and operator-facing diagnostics -- config validation and precedence tests for all new ACP keys -- operational docs and troubleshooting runbook - -## Test plan - -Unit tests: - -- ACP DB transaction boundaries (spawn/bind/enqueue atomicity, cancel, close) -- ACP state-machine transition guards for sessions and runs -- idempotency reservation/replay semantics across all ACP commands -- per-session actor serialization and queue ordering -- acpx event parser and chunk coalescer -- runtime supervisor restart and backoff policy -- config precedence and effective TTL calculation -- core ACP routing branch selection and fail-closed behavior when backend/session is invalid - -Integration tests: - -- fake ACP adapter process for deterministic streaming and cancel behavior -- ACP manager + dispatch integration with transactional persistence -- thread-bound inbound routing to ACP session key -- thread-bound outbound delivery suppresses parent channel duplication -- checkpoint replay recovers after delivery failure and resumes from last event -- plugin service registration and teardown of ACP runtime backend - -Gateway e2e tests: - -- spawn ACP with thread, exchange multi-turn prompts, unfocus -- gateway restart with persisted ACP DB and bindings, then continue same session -- concurrent ACP sessions in multiple threads have no cross-talk -- duplicate command retries (same idempotency key) do not create duplicate runs or replies -- stale-binding scenario yields explicit error and optional auto-clean behavior - -## Risks and mitigations - -- Duplicate deliveries during transition - - Mitigation: single destination resolver and idempotent event checkpoint -- Runtime process churn under load - - Mitigation: long lived per session owners + concurrency caps + backoff -- Plugin absent or misconfigured - - Mitigation: explicit operator-facing error and fail-closed ACP routing (no implicit fallback to normal session path) -- Config confusion between subagent and ACP gates - - Mitigation: explicit ACP keys and command feedback that includes effective policy source -- Control-plane store corruption or migration bugs - - Mitigation: WAL mode, backup/restore hooks, migration smoke tests, and read-only fallback diagnostics -- Actor deadlocks or mailbox starvation - - Mitigation: watchdog timers, actor health probes, and bounded mailbox depth with rejection telemetry - -## Acceptance checklist - -- ACP session spawn can create or bind a thread in a supported channel adapter (currently Discord) -- all thread messages route to bound ACP session only -- ACP outputs appear in the same thread identity with streaming or batches -- no duplicate output in parent channel for bound turns -- spawn+bind+initial enqueue are atomic in persistent store -- ACP command retries are idempotent and do not duplicate runs or outputs -- cancel, close, unfocus, archive, reset, and delete perform deterministic cleanup -- crash restart preserves mapping and resumes multi turn continuity -- concurrent thread bound ACP sessions work independently -- ACP backend missing state produces clear actionable error -- stale bindings are detected and surfaced explicitly (with optional safe auto-clean) -- control-plane metrics and diagnostics are available for operators -- new unit, integration, and e2e coverage passes - -## Addendum: targeted refactors for current implementation (status) - -These are non-blocking follow-ups to keep the ACP path maintainable after the current feature set lands. - -### 1) Centralize ACP dispatch policy evaluation (completed) - -- implemented via shared ACP policy helpers in `src/acp/policy.ts` -- dispatch, ACP command lifecycle handlers, and ACP spawn path now consume shared policy logic - -### 2) Split ACP command handler by subcommand domain (completed) - -- `src/auto-reply/reply/commands-acp.ts` is now a thin router -- subcommand behavior is split into: - - `src/auto-reply/reply/commands-acp/lifecycle.ts` - - `src/auto-reply/reply/commands-acp/runtime-options.ts` - - `src/auto-reply/reply/commands-acp/diagnostics.ts` - - shared helpers in `src/auto-reply/reply/commands-acp/shared.ts` - -### 3) Split ACP session manager by responsibility (completed) - -- manager is split into: - - `src/acp/control-plane/manager.ts` (public facade + singleton) - - `src/acp/control-plane/manager.core.ts` (manager implementation) - - `src/acp/control-plane/manager.types.ts` (manager types/deps) - - `src/acp/control-plane/manager.utils.ts` (normalization + helper functions) - -### 4) Optional acpx runtime adapter cleanup - -- `extensions/acpx/src/runtime.ts` can be split into: -- process execution/supervision -- ndjson event parsing/normalization -- runtime API surface (`submit`, `cancel`, `close`, etc.) -- improves testability and makes backend behavior easier to audit diff --git a/docs/experiments/plans/acp-unified-streaming-refactor.md b/docs/experiments/plans/acp-unified-streaming-refactor.md deleted file mode 100644 index 3834fb9f8d8..00000000000 --- a/docs/experiments/plans/acp-unified-streaming-refactor.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -summary: "Holy grail refactor plan for one unified runtime streaming pipeline across main, subagent, and ACP" -owner: "onutc" -status: "draft" -last_updated: "2026-02-25" -title: "Unified Runtime Streaming Refactor Plan" ---- - -# Unified Runtime Streaming Refactor Plan - -## Objective - -Deliver one shared streaming pipeline for `main`, `subagent`, and `acp` so all runtimes get identical coalescing, chunking, delivery ordering, and crash recovery behavior. - -## Why this exists - -- Current behavior is split across multiple runtime-specific shaping paths. -- Formatting/coalescing bugs can be fixed in one path but remain in others. -- Delivery consistency, duplicate suppression, and recovery semantics are harder to reason about. - -## Target architecture - -Single pipeline, runtime-specific adapters: - -1. Runtime adapters emit canonical events only. -2. Shared stream assembler coalesces and finalizes text/tool/status events. -3. Shared channel projector applies channel-specific chunking/formatting once. -4. Shared delivery ledger enforces idempotent send/replay semantics. -5. Outbound channel adapter executes sends and records delivery checkpoints. - -Canonical event contract: - -- `turn_started` -- `text_delta` -- `block_final` -- `tool_started` -- `tool_finished` -- `status` -- `turn_completed` -- `turn_failed` -- `turn_cancelled` - -## Workstreams - -### 1) Canonical streaming contract - -- Define strict event schema + validation in core. -- Add adapter contract tests to guarantee each runtime emits compatible events. -- Reject malformed runtime events early and surface structured diagnostics. - -### 2) Shared stream processor - -- Replace runtime-specific coalescer/projector logic with one processor. -- Processor owns text delta buffering, idle flush, max-chunk splitting, and completion flush. -- Move ACP/main/subagent config resolution into one helper to prevent drift. - -### 3) Shared channel projection - -- Keep channel adapters dumb: accept finalized blocks and send. -- Move Discord-specific chunking quirks to channel projector only. -- Keep pipeline channel-agnostic before projection. - -### 4) Delivery ledger + replay - -- Add per-turn/per-chunk delivery IDs. -- Record checkpoints before and after physical send. -- On restart, replay pending chunks idempotently and avoid duplicates. - -### 5) Migration and cutover - -- Phase 1: shadow mode (new pipeline computes output but old path sends; compare). -- Phase 2: runtime-by-runtime cutover (`acp`, then `subagent`, then `main` or reverse by risk). -- Phase 3: delete legacy runtime-specific streaming code. - -## Non-goals - -- No changes to ACP policy/permissions model in this refactor. -- No channel-specific feature expansion outside projection compatibility fixes. -- No transport/backend redesign (acpx plugin contract remains as-is unless needed for event parity). - -## Risks and mitigations - -- Risk: behavioral regressions in existing main/subagent paths. - Mitigation: shadow mode diffing + adapter contract tests + channel e2e tests. -- Risk: duplicate sends during crash recovery. - Mitigation: durable delivery IDs + idempotent replay in delivery adapter. -- Risk: runtime adapters diverge again. - Mitigation: required shared contract test suite for all adapters. - -## Acceptance criteria - -- All runtimes pass shared streaming contract tests. -- Discord ACP/main/subagent produce equivalent spacing/chunking behavior for tiny deltas. -- Crash/restart replay sends no duplicate chunk for the same delivery ID. -- Legacy ACP projector/coalescer path is removed. -- Streaming config resolution is shared and runtime-independent. diff --git a/docs/experiments/plans/browser-evaluate-cdp-refactor.md b/docs/experiments/plans/browser-evaluate-cdp-refactor.md deleted file mode 100644 index 5832c8a65e6..00000000000 --- a/docs/experiments/plans/browser-evaluate-cdp-refactor.md +++ /dev/null @@ -1,232 +0,0 @@ ---- -summary: "Plan: isolate browser act:evaluate from Playwright queue using CDP, with end-to-end deadlines and safer ref resolution" -read_when: - - Working on browser `act:evaluate` timeout, abort, or queue blocking issues - - Planning CDP based isolation for evaluate execution -owner: "openclaw" -status: "draft" -last_updated: "2026-02-10" -title: "Browser Evaluate CDP Refactor" ---- - -# Browser Evaluate CDP Refactor Plan - -## Context - -`act:evaluate` executes user provided JavaScript in the page. Today it runs via Playwright -(`page.evaluate` or `locator.evaluate`). Playwright serializes CDP commands per page, so a -stuck or long running evaluate can block the page command queue and make every later action -on that tab look "stuck". - -PR #13498 adds a pragmatic safety net (bounded evaluate, abort propagation, and best-effort -recovery). This document describes a larger refactor that makes `act:evaluate` inherently -isolated from Playwright so a stuck evaluate cannot wedge normal Playwright operations. - -## Goals - -- `act:evaluate` cannot permanently block later browser actions on the same tab. -- Timeouts are single source of truth end to end so a caller can rely on a budget. -- Abort and timeout are treated the same way across HTTP and in-process dispatch. -- Element targeting for evaluate is supported without switching everything off Playwright. -- Maintain backward compatibility for existing callers and payloads. - -## Non-goals - -- Replace all browser actions (click, type, wait, etc.) with CDP implementations. -- Remove the existing safety net introduced in PR #13498 (it remains a useful fallback). -- Introduce new unsafe capabilities beyond the existing `browser.evaluateEnabled` gate. -- Add process isolation (worker process/thread) for evaluate. If we still see hard to recover - stuck states after this refactor, that is a follow-up idea. - -## Current Architecture (Why It Gets Stuck) - -At a high level: - -- Callers send `act:evaluate` to the browser control service. -- The route handler calls into Playwright to execute the JavaScript. -- Playwright serializes page commands, so an evaluate that never finishes blocks the queue. -- A stuck queue means later click/type/wait operations on the tab can appear to hang. - -## Proposed Architecture - -### 1. Deadline Propagation - -Introduce a single budget concept and derive everything from it: - -- Caller sets `timeoutMs` (or a deadline in the future). -- The outer request timeout, route handler logic, and the execution budget inside the page - all use the same budget, with small headroom where needed for serialization overhead. -- Abort is propagated as an `AbortSignal` everywhere so cancellation is consistent. - -Implementation direction: - -- Add a small helper (for example `createBudget({ timeoutMs, signal })`) that returns: - - `signal`: the linked AbortSignal - - `deadlineAtMs`: absolute deadline - - `remainingMs()`: remaining budget for child operations -- Use this helper in: - - `src/browser/client-fetch.ts` (HTTP and in-process dispatch) - - `src/node-host/runner.ts` (proxy path) - - browser action implementations (Playwright and CDP) - -### 2. Separate Evaluate Engine (CDP Path) - -Add a CDP based evaluate implementation that does not share Playwright's per page command -queue. The key property is that the evaluate transport is a separate WebSocket connection -and a separate CDP session attached to the target. - -Implementation direction: - -- New module, for example `src/browser/cdp-evaluate.ts`, that: - - Connects to the configured CDP endpoint (browser level socket). - - Uses `Target.attachToTarget({ targetId, flatten: true })` to get a `sessionId`. - - Runs either: - - `Runtime.evaluate` for page level evaluate, or - - `DOM.resolveNode` plus `Runtime.callFunctionOn` for element evaluate. - - On timeout or abort: - - Sends `Runtime.terminateExecution` best-effort for the session. - - Closes the WebSocket and returns a clear error. - -Notes: - -- This still executes JavaScript in the page, so termination can have side effects. The win - is that it does not wedge the Playwright queue, and it is cancelable at the transport - layer by killing the CDP session. - -### 3. Ref Story (Element Targeting Without A Full Rewrite) - -The hard part is element targeting. CDP needs a DOM handle or `backendDOMNodeId`, while -today most browser actions use Playwright locators based on refs from snapshots. - -Recommended approach: keep existing refs, but attach an optional CDP resolvable id. - -#### 3.1 Extend Stored Ref Info - -Extend the stored role ref metadata to optionally include a CDP id: - -- Today: `{ role, name, nth }` -- Proposed: `{ role, name, nth, backendDOMNodeId?: number }` - -This keeps all existing Playwright based actions working and allows CDP evaluate to accept -the same `ref` value when the `backendDOMNodeId` is available. - -#### 3.2 Populate backendDOMNodeId At Snapshot Time - -When producing a role snapshot: - -1. Generate the existing role ref map as today (role, name, nth). -2. Fetch the AX tree via CDP (`Accessibility.getFullAXTree`) and compute a parallel map of - `(role, name, nth) -> backendDOMNodeId` using the same duplicate handling rules. -3. Merge the id back into the stored ref info for the current tab. - -If mapping fails for a ref, leave `backendDOMNodeId` undefined. This makes the feature -best-effort and safe to roll out. - -#### 3.3 Evaluate Behavior With Ref - -In `act:evaluate`: - -- If `ref` is present and has `backendDOMNodeId`, run element evaluate via CDP. -- If `ref` is present but has no `backendDOMNodeId`, fall back to the Playwright path (with - the safety net). - -Optional escape hatch: - -- Extend the request shape to accept `backendDOMNodeId` directly for advanced callers (and - for debugging), while keeping `ref` as the primary interface. - -### 4. Keep A Last Resort Recovery Path - -Even with CDP evaluate, there are other ways to wedge a tab or a connection. Keep the -existing recovery mechanisms (terminate execution + disconnect Playwright) as a last resort -for: - -- legacy callers -- environments where CDP attach is blocked -- unexpected Playwright edge cases - -## Implementation Plan (Single Iteration) - -### Deliverables - -- A CDP based evaluate engine that runs outside the Playwright per-page command queue. -- A single end-to-end timeout/abort budget used consistently by callers and handlers. -- Ref metadata that can optionally carry `backendDOMNodeId` for element evaluate. -- `act:evaluate` prefers the CDP engine when possible and falls back to Playwright when not. -- Tests that prove a stuck evaluate does not wedge later actions. -- Logs/metrics that make failures and fallbacks visible. - -### Implementation Checklist - -1. Add a shared "budget" helper to link `timeoutMs` + upstream `AbortSignal` into: - - a single `AbortSignal` - - an absolute deadline - - a `remainingMs()` helper for downstream operations -2. Update all caller paths to use that helper so `timeoutMs` means the same thing everywhere: - - `src/browser/client-fetch.ts` (HTTP and in-process dispatch) - - `src/node-host/runner.ts` (node proxy path) - - CLI wrappers that call `/act` (add `--timeout-ms` to `browser evaluate`) -3. Implement `src/browser/cdp-evaluate.ts`: - - connect to the browser-level CDP socket - - `Target.attachToTarget` to get a `sessionId` - - run `Runtime.evaluate` for page evaluate - - run `DOM.resolveNode` + `Runtime.callFunctionOn` for element evaluate - - on timeout/abort: best-effort `Runtime.terminateExecution` then close the socket -4. Extend stored role ref metadata to optionally include `backendDOMNodeId`: - - keep existing `{ role, name, nth }` behavior for Playwright actions - - add `backendDOMNodeId?: number` for CDP element targeting -5. Populate `backendDOMNodeId` during snapshot creation (best-effort): - - fetch AX tree via CDP (`Accessibility.getFullAXTree`) - - compute `(role, name, nth) -> backendDOMNodeId` and merge into the stored ref map - - if mapping is ambiguous or missing, leave the id undefined -6. Update `act:evaluate` routing: - - if no `ref`: always use CDP evaluate - - if `ref` resolves to a `backendDOMNodeId`: use CDP element evaluate - - otherwise: fall back to Playwright evaluate (still bounded and abortable) -7. Keep the existing "last resort" recovery path as a fallback, not the default path. -8. Add tests: - - stuck evaluate times out within budget and the next click/type succeeds - - abort cancels evaluate (client disconnect or timeout) and unblocks subsequent actions - - mapping failures cleanly fall back to Playwright -9. Add observability: - - evaluate duration and timeout counters - - terminateExecution usage - - fallback rate (CDP -> Playwright) and reasons - -### Acceptance Criteria - -- A deliberately hung `act:evaluate` returns within the caller budget and does not wedge the - tab for later actions. -- `timeoutMs` behaves consistently across CLI, agent tool, node proxy, and in-process calls. -- If `ref` can be mapped to `backendDOMNodeId`, element evaluate uses CDP; otherwise the - fallback path is still bounded and recoverable. - -## Testing Plan - -- Unit tests: - - `(role, name, nth)` matching logic between role refs and AX tree nodes. - - Budget helper behavior (headroom, remaining time math). -- Integration tests: - - CDP evaluate timeout returns within budget and does not block the next action. - - Abort cancels evaluate and triggers termination best-effort. -- Contract tests: - - Ensure `BrowserActRequest` and `BrowserActResponse` remain compatible. - -## Risks And Mitigations - -- Mapping is imperfect: - - Mitigation: best-effort mapping, fallback to Playwright evaluate, and add debug tooling. -- `Runtime.terminateExecution` has side effects: - - Mitigation: only use on timeout/abort and document the behavior in errors. -- Extra overhead: - - Mitigation: only fetch AX tree when snapshots are requested, cache per target, and keep - CDP session short lived. -- Extension relay limitations: - - Mitigation: use browser level attach APIs when per page sockets are not available, and - keep the current Playwright path as fallback. - -## Open Questions - -- Should the new engine be configurable as `playwright`, `cdp`, or `auto`? -- Do we want to expose a new "nodeRef" format for advanced users, or keep `ref` only? -- How should frame snapshots and selector scoped snapshots participate in AX mapping? diff --git a/docs/experiments/plans/discord-async-inbound-worker.md b/docs/experiments/plans/discord-async-inbound-worker.md deleted file mode 100644 index 70397b51338..00000000000 --- a/docs/experiments/plans/discord-async-inbound-worker.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -summary: "Status and next steps for decoupling Discord gateway listeners from long-running agent turns with a Discord-specific inbound worker" -owner: "openclaw" -status: "in_progress" -last_updated: "2026-03-05" -title: "Discord Async Inbound Worker Plan" ---- - -# Discord Async Inbound Worker Plan - -## Objective - -Remove Discord listener timeout as a user-facing failure mode by making inbound Discord turns asynchronous: - -1. Gateway listener accepts and normalizes inbound events quickly. -2. A Discord run queue stores serialized jobs keyed by the same ordering boundary we use today. -3. A worker executes the actual agent turn outside the Carbon listener lifetime. -4. Replies are delivered back to the originating channel or thread after the run completes. - -This is the long-term fix for queued Discord runs timing out at `channels.discord.eventQueue.listenerTimeout` while the agent run itself is still making progress. - -## Current status - -This plan is partially implemented. - -Already done: - -- Discord listener timeout and Discord run timeout are now separate settings. -- Accepted inbound Discord turns are enqueued into `src/discord/monitor/inbound-worker.ts`. -- The worker now owns the long-running turn instead of the Carbon listener. -- Existing per-route ordering is preserved by queue key. -- Timeout regression coverage exists for the Discord worker path. - -What this means in plain language: - -- the production timeout bug is fixed -- the long-running turn no longer dies just because the Discord listener budget expires -- the worker architecture is not finished yet - -What is still missing: - -- `DiscordInboundJob` is still only partially normalized and still carries live runtime references -- command semantics (`stop`, `new`, `reset`, future session controls) are not yet fully worker-native -- worker observability and operator status are still minimal -- there is still no restart durability - -## Why this exists - -Current behavior ties the full agent turn to the listener lifetime: - -- `src/discord/monitor/listeners.ts` applies the timeout and abort boundary. -- `src/discord/monitor/message-handler.ts` keeps the queued run inside that boundary. -- `src/discord/monitor/message-handler.process.ts` performs media loading, routing, dispatch, typing, draft streaming, and final reply delivery inline. - -That architecture has two bad properties: - -- long but healthy turns can be aborted by the listener watchdog -- users can see no reply even when the downstream runtime would have produced one - -Raising the timeout helps but does not change the failure mode. - -## Non-goals - -- Do not redesign non-Discord channels in this pass. -- Do not broaden this into a generic all-channel worker framework in the first implementation. -- Do not extract a shared cross-channel inbound worker abstraction yet; only share low-level primitives when duplication is obvious. -- Do not add durable crash recovery in the first pass unless needed to land safely. -- Do not change route selection, binding semantics, or ACP policy in this plan. - -## Current constraints - -The current Discord processing path still depends on some live runtime objects that should not stay inside the long-term job payload: - -- Carbon `Client` -- raw Discord event shapes -- in-memory guild history map -- thread binding manager callbacks -- live typing and draft stream state - -We already moved execution onto a worker queue, but the normalization boundary is still incomplete. Right now the worker is "run later in the same process with some of the same live objects," not a fully data-only job boundary. - -## Target architecture - -### 1. Listener stage - -`DiscordMessageListener` remains the ingress point, but its job becomes: - -- run preflight and policy checks -- normalize accepted input into a serializable `DiscordInboundJob` -- enqueue the job into a per-session or per-channel async queue -- return immediately to Carbon once the enqueue succeeds - -The listener should no longer own the end-to-end LLM turn lifetime. - -### 2. Normalized job payload - -Introduce a serializable job descriptor that contains only the data needed to run the turn later. - -Minimum shape: - -- route identity - - `agentId` - - `sessionKey` - - `accountId` - - `channel` -- delivery identity - - destination channel id - - reply target message id - - thread id if present -- sender identity - - sender id, label, username, tag -- channel context - - guild id - - channel name or slug - - thread metadata - - resolved system prompt override -- normalized message body - - base text - - effective message text - - attachment descriptors or resolved media references -- gating decisions - - mention requirement outcome - - command authorization outcome - - bound session or agent metadata if applicable - -The job payload must not contain live Carbon objects or mutable closures. - -Current implementation status: - -- partially done -- `src/discord/monitor/inbound-job.ts` exists and defines the worker handoff -- the payload still contains live Discord runtime context and should be reduced further - -### 3. Worker stage - -Add a Discord-specific worker runner responsible for: - -- reconstructing the turn context from `DiscordInboundJob` -- loading media and any additional channel metadata needed for the run -- dispatching the agent turn -- delivering final reply payloads -- updating status and diagnostics - -Recommended location: - -- `src/discord/monitor/inbound-worker.ts` -- `src/discord/monitor/inbound-job.ts` - -### 4. Ordering model - -Ordering must remain equivalent to today for a given route boundary. - -Recommended key: - -- use the same queue key logic as `resolveDiscordRunQueueKey(...)` - -This preserves existing behavior: - -- one bound agent conversation does not interleave with itself -- different Discord channels can still progress independently - -### 5. Timeout model - -After cutover, there are two separate timeout classes: - -- listener timeout - - only covers normalization and enqueue - - should be short -- run timeout - - optional, worker-owned, explicit, and user-visible - - should not be inherited accidentally from Carbon listener settings - -This removes the current accidental coupling between "Discord gateway listener stayed alive" and "agent run is healthy." - -## Recommended implementation phases - -### Phase 1: normalization boundary - -- Status: partially implemented -- Done: - - extracted `buildDiscordInboundJob(...)` - - added worker handoff tests -- Remaining: - - make `DiscordInboundJob` plain data only - - move live runtime dependencies to worker-owned services instead of per-job payload - - stop rebuilding process context by stitching live listener refs back into the job - -### Phase 2: in-memory worker queue - -- Status: implemented -- Done: - - added `DiscordInboundWorkerQueue` keyed by resolved run queue key - - listener enqueues jobs instead of directly awaiting `processDiscordMessage(...)` - - worker executes jobs in-process, in memory only - -This is the first functional cutover. - -### Phase 3: process split - -- Status: not started -- Move delivery, typing, and draft streaming ownership behind worker-facing adapters. -- Replace direct use of live preflight context with worker context reconstruction. -- Keep `processDiscordMessage(...)` temporarily as a facade if needed, then split it. - -### Phase 4: command semantics - -- Status: not started - Make sure native Discord commands still behave correctly when work is queued: - -- `stop` -- `new` -- `reset` -- any future session-control commands - -The worker queue must expose enough run state for commands to target the active or queued turn. - -### Phase 5: observability and operator UX - -- Status: not started -- emit queue depth and active worker counts into monitor status -- record enqueue time, start time, finish time, and timeout or cancellation reason -- surface worker-owned timeout or delivery failures clearly in logs - -### Phase 6: optional durability follow-up - -- Status: not started - Only after the in-memory version is stable: - -- decide whether queued Discord jobs should survive gateway restart -- if yes, persist job descriptors and delivery checkpoints -- if no, document the explicit in-memory boundary - -This should be a separate follow-up unless restart recovery is required to land. - -## File impact - -Current primary files: - -- `src/discord/monitor/listeners.ts` -- `src/discord/monitor/message-handler.ts` -- `src/discord/monitor/message-handler.preflight.ts` -- `src/discord/monitor/message-handler.process.ts` -- `src/discord/monitor/status.ts` - -Current worker files: - -- `src/discord/monitor/inbound-job.ts` -- `src/discord/monitor/inbound-worker.ts` -- `src/discord/monitor/inbound-job.test.ts` -- `src/discord/monitor/message-handler.queue.test.ts` - -Likely next touch points: - -- `src/auto-reply/dispatch.ts` -- `src/discord/monitor/reply-delivery.ts` -- `src/discord/monitor/thread-bindings.ts` -- `src/discord/monitor/native-command.ts` - -## Next step now - -The next step is to make the worker boundary real instead of partial. - -Do this next: - -1. Move live runtime dependencies out of `DiscordInboundJob` -2. Keep those dependencies on the Discord worker instance instead -3. Reduce queued jobs to plain Discord-specific data: - - route identity - - delivery target - - sender info - - normalized message snapshot - - gating and binding decisions -4. Reconstruct worker execution context from that plain data inside the worker - -In practice, that means: - -- `client` -- `threadBindings` -- `guildHistories` -- `discordRestFetch` -- other mutable runtime-only handles - -should stop living on each queued job and instead live on the worker itself or behind worker-owned adapters. - -After that lands, the next follow-up should be command-state cleanup for `stop`, `new`, and `reset`. - -## Testing plan - -Keep the existing timeout repro coverage in: - -- `src/discord/monitor/message-handler.queue.test.ts` - -Add new tests for: - -1. listener returns after enqueue without awaiting full turn -2. per-route ordering is preserved -3. different channels still run concurrently -4. replies are delivered to the original message destination -5. `stop` cancels the active worker-owned run -6. worker failure produces visible diagnostics without blocking later jobs -7. ACP-bound Discord channels still route correctly under worker execution - -## Risks and mitigations - -- Risk: command semantics drift from current synchronous behavior - Mitigation: land command-state plumbing in the same cutover, not later - -- Risk: reply delivery loses thread or reply-to context - Mitigation: make delivery identity first-class in `DiscordInboundJob` - -- Risk: duplicate sends during retries or queue restarts - Mitigation: keep first pass in-memory only, or add explicit delivery idempotency before persistence - -- Risk: `message-handler.process.ts` becomes harder to reason about during migration - Mitigation: split into normalization, execution, and delivery helpers before or during worker cutover - -## Acceptance criteria - -The plan is complete when: - -1. Discord listener timeout no longer aborts healthy long-running turns. -2. Listener lifetime and agent-turn lifetime are separate concepts in code. -3. Existing per-session ordering is preserved. -4. ACP-bound Discord channels work through the same worker path. -5. `stop` targets the worker-owned run instead of the old listener-owned call stack. -6. Timeout and delivery failures become explicit worker outcomes, not silent listener drops. - -## Remaining landing strategy - -Finish this in follow-up PRs: - -1. make `DiscordInboundJob` plain-data only and move live runtime refs onto the worker -2. clean up command-state ownership for `stop`, `new`, and `reset` -3. add worker observability and operator status -4. decide whether durability is needed or explicitly document the in-memory boundary - -This is still a bounded follow-up if kept Discord-only and if we continue to avoid a premature cross-channel worker abstraction. diff --git a/docs/experiments/plans/openresponses-gateway.md b/docs/experiments/plans/openresponses-gateway.md deleted file mode 100644 index 8ca63c34ec9..00000000000 --- a/docs/experiments/plans/openresponses-gateway.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -summary: "Plan: Add OpenResponses /v1/responses endpoint and deprecate chat completions cleanly" -read_when: - - Designing or implementing `/v1/responses` gateway support - - Planning migration from Chat Completions compatibility -owner: "openclaw" -status: "draft" -last_updated: "2026-01-19" -title: "OpenResponses Gateway Plan" ---- - -# OpenResponses Gateway Integration Plan - -## Context - -OpenClaw Gateway currently exposes a minimal OpenAI-compatible Chat Completions endpoint at -`/v1/chat/completions` (see [OpenAI Chat Completions](/gateway/openai-http-api)). - -Open Responses is an open inference standard based on the OpenAI Responses API. It is designed -for agentic workflows and uses item-based inputs plus semantic streaming events. The OpenResponses -spec defines `/v1/responses`, not `/v1/chat/completions`. - -## Goals - -- Add a `/v1/responses` endpoint that adheres to OpenResponses semantics. -- Keep Chat Completions as a compatibility layer that is easy to disable and eventually remove. -- Standardize validation and parsing with isolated, reusable schemas. - -## Non-goals - -- Full OpenResponses feature parity in the first pass (images, files, hosted tools). -- Replacing internal agent execution logic or tool orchestration. -- Changing the existing `/v1/chat/completions` behavior during the first phase. - -## Research Summary - -Sources: OpenResponses OpenAPI, OpenResponses specification site, and the Hugging Face blog post. - -Key points extracted: - -- `POST /v1/responses` accepts `CreateResponseBody` fields like `model`, `input` (string or - `ItemParam[]`), `instructions`, `tools`, `tool_choice`, `stream`, `max_output_tokens`, and - `max_tool_calls`. -- `ItemParam` is a discriminated union of: - - `message` items with roles `system`, `developer`, `user`, `assistant` - - `function_call` and `function_call_output` - - `reasoning` - - `item_reference` -- Successful responses return a `ResponseResource` with `object: "response"`, `status`, and - `output` items. -- Streaming uses semantic events such as: - - `response.created`, `response.in_progress`, `response.completed`, `response.failed` - - `response.output_item.added`, `response.output_item.done` - - `response.content_part.added`, `response.content_part.done` - - `response.output_text.delta`, `response.output_text.done` -- The spec requires: - - `Content-Type: text/event-stream` - - `event:` must match the JSON `type` field - - terminal event must be literal `[DONE]` -- Reasoning items may expose `content`, `encrypted_content`, and `summary`. -- HF examples include `OpenResponses-Version: latest` in requests (optional header). - -## Proposed Architecture - -- Add `src/gateway/open-responses.schema.ts` containing Zod schemas only (no gateway imports). -- Add `src/gateway/openresponses-http.ts` (or `open-responses-http.ts`) for `/v1/responses`. -- Keep `src/gateway/openai-http.ts` intact as a legacy compatibility adapter. -- Add config `gateway.http.endpoints.responses.enabled` (default `false`). -- Keep `gateway.http.endpoints.chatCompletions.enabled` independent; allow both endpoints to be - toggled separately. -- Emit a startup warning when Chat Completions is enabled to signal legacy status. - -## Deprecation Path for Chat Completions - -- Maintain strict module boundaries: no shared schema types between responses and chat completions. -- Make Chat Completions opt-in by config so it can be disabled without code changes. -- Update docs to label Chat Completions as legacy once `/v1/responses` is stable. -- Optional future step: map Chat Completions requests to the Responses handler for a simpler - removal path. - -## Phase 1 Support Subset - -- Accept `input` as string or `ItemParam[]` with message roles and `function_call_output`. -- Extract system and developer messages into `extraSystemPrompt`. -- Use the most recent `user` or `function_call_output` as the current message for agent runs. -- Reject unsupported content parts (image/file) with `invalid_request_error`. -- Return a single assistant message with `output_text` content. -- Return `usage` with zeroed values until token accounting is wired. - -## Validation Strategy (No SDK) - -- Implement Zod schemas for the supported subset of: - - `CreateResponseBody` - - `ItemParam` + message content part unions - - `ResponseResource` - - Streaming event shapes used by the gateway -- Keep schemas in a single, isolated module to avoid drift and allow future codegen. - -## Streaming Implementation (Phase 1) - -- SSE lines with both `event:` and `data:`. -- Required sequence (minimum viable): - - `response.created` - - `response.output_item.added` - - `response.content_part.added` - - `response.output_text.delta` (repeat as needed) - - `response.output_text.done` - - `response.content_part.done` - - `response.completed` - - `[DONE]` - -## Tests and Verification Plan - -- Add e2e coverage for `/v1/responses`: - - Auth required - - Non-stream response shape - - Stream event ordering and `[DONE]` - - Session routing with headers and `user` -- Keep `src/gateway/openai-http.test.ts` unchanged. -- Manual: curl to `/v1/responses` with `stream: true` and verify event ordering and terminal - `[DONE]`. - -## Doc Updates (Follow-up) - -- Add a new docs page for `/v1/responses` usage and examples. -- Update `/gateway/openai-http-api` with a legacy note and pointer to `/v1/responses`. diff --git a/docs/experiments/plans/pty-process-supervision.md b/docs/experiments/plans/pty-process-supervision.md deleted file mode 100644 index 4ec898058cd..00000000000 --- a/docs/experiments/plans/pty-process-supervision.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -summary: "Production plan for reliable interactive process supervision (PTY + non-PTY) with explicit ownership, unified lifecycle, and deterministic cleanup" -read_when: - - Working on exec/process lifecycle ownership and cleanup - - Debugging PTY and non-PTY supervision behavior -owner: "openclaw" -status: "in-progress" -last_updated: "2026-02-15" -title: "PTY and Process Supervision Plan" ---- - -# PTY and Process Supervision Plan - -## 1. Problem and goal - -We need one reliable lifecycle for long-running command execution across: - -- `exec` foreground runs -- `exec` background runs -- `process` follow up actions (`poll`, `log`, `send-keys`, `paste`, `submit`, `kill`, `remove`) -- CLI agent runner subprocesses - -The goal is not just to support PTY. The goal is predictable ownership, cancellation, timeout, and cleanup with no unsafe process matching heuristics. - -## 2. Scope and boundaries - -- Keep implementation internal in `src/process/supervisor`. -- Do not create a new package for this. -- Keep current behavior compatibility where practical. -- Do not broaden scope to terminal replay or tmux style session persistence. - -## 3. Implemented in this branch - -### Supervisor baseline already present - -- Supervisor module is in place under `src/process/supervisor/*`. -- Exec runtime and CLI runner are already routed through supervisor spawn and wait. -- Registry finalization is idempotent. - -### This pass completed - -1. Explicit PTY command contract - -- `SpawnInput` is now a discriminated union in `src/process/supervisor/types.ts`. -- PTY runs require `ptyCommand` instead of reusing generic `argv`. -- Supervisor no longer rebuilds PTY command strings from argv joins in `src/process/supervisor/supervisor.ts`. -- Exec runtime now passes `ptyCommand` directly in `src/agents/bash-tools.exec-runtime.ts`. - -2. Process layer type decoupling - -- Supervisor types no longer import `SessionStdin` from agents. -- Process local stdin contract lives in `src/process/supervisor/types.ts` (`ManagedRunStdin`). -- Adapters now depend only on process level types: - - `src/process/supervisor/adapters/child.ts` - - `src/process/supervisor/adapters/pty.ts` - -3. Process tool lifecycle ownership improvement - -- `src/agents/bash-tools.process.ts` now requests cancellation through supervisor first. -- `process kill/remove` now use process-tree fallback termination when supervisor lookup misses. -- `remove` keeps deterministic remove behavior by dropping running session entries immediately after termination is requested. - -4. Single source watchdog defaults - -- Added shared defaults in `src/agents/cli-watchdog-defaults.ts`. -- `src/agents/cli-backends.ts` consumes the shared defaults. -- `src/agents/cli-runner/reliability.ts` consumes the same shared defaults. - -5. Dead helper cleanup - -- Removed unused `killSession` helper path from `src/agents/bash-tools.shared.ts`. - -6. Direct supervisor path tests added - -- Added `src/agents/bash-tools.process.supervisor.test.ts` to cover kill and remove routing through supervisor cancellation. - -7. Reliability gap fixes completed - -- `src/agents/bash-tools.process.ts` now falls back to real OS-level process termination when supervisor lookup misses. -- `src/process/supervisor/adapters/child.ts` now uses process-tree termination semantics for default cancel/timeout kill paths. -- Added shared process-tree utility in `src/process/kill-tree.ts`. - -8. PTY contract edge-case coverage added - -- Added `src/process/supervisor/supervisor.pty-command.test.ts` for verbatim PTY command forwarding and empty-command rejection. -- Added `src/process/supervisor/adapters/child.test.ts` for process-tree kill behavior in child adapter cancellation. - -## 4. Remaining gaps and decisions - -### Reliability status - -The two required reliability gaps for this pass are now closed: - -- `process kill/remove` now has a real OS termination fallback when supervisor lookup misses. -- child cancel/timeout now uses process-tree kill semantics for default kill path. -- Regression tests were added for both behaviors. - -### Durability and startup reconciliation - -Restart behavior is now explicitly defined as in-memory lifecycle only. - -- `reconcileOrphans()` remains a no-op in `src/process/supervisor/supervisor.ts` by design. -- Active runs are not recovered after process restart. -- This boundary is intentional for this implementation pass to avoid partial persistence risks. - -### Maintainability follow-ups - -1. `runExecProcess` in `src/agents/bash-tools.exec-runtime.ts` still handles multiple responsibilities and can be split into focused helpers in a follow-up. - -## 5. Implementation plan - -The implementation pass for required reliability and contract items is complete. - -Completed: - -- `process kill/remove` fallback real termination -- process-tree cancellation for child adapter default kill path -- regression tests for fallback kill and child adapter kill path -- PTY command edge-case tests under explicit `ptyCommand` -- explicit in-memory restart boundary with `reconcileOrphans()` no-op by design - -Optional follow-up: - -- split `runExecProcess` into focused helpers with no behavior drift - -## 6. File map - -### Process supervisor - -- `src/process/supervisor/types.ts` updated with discriminated spawn input and process local stdin contract. -- `src/process/supervisor/supervisor.ts` updated to use explicit `ptyCommand`. -- `src/process/supervisor/adapters/child.ts` and `src/process/supervisor/adapters/pty.ts` decoupled from agent types. -- `src/process/supervisor/registry.ts` idempotent finalize unchanged and retained. - -### Exec and process integration - -- `src/agents/bash-tools.exec-runtime.ts` updated to pass PTY command explicitly and keep fallback path. -- `src/agents/bash-tools.process.ts` updated to cancel via supervisor with real process-tree fallback termination. -- `src/agents/bash-tools.shared.ts` removed direct kill helper path. - -### CLI reliability - -- `src/agents/cli-watchdog-defaults.ts` added as shared baseline. -- `src/agents/cli-backends.ts` and `src/agents/cli-runner/reliability.ts` now consume same defaults. - -## 7. Validation run in this pass - -Unit tests: - -- `pnpm vitest src/process/supervisor/registry.test.ts` -- `pnpm vitest src/process/supervisor/supervisor.test.ts` -- `pnpm vitest src/process/supervisor/supervisor.pty-command.test.ts` -- `pnpm vitest src/process/supervisor/adapters/child.test.ts` -- `pnpm vitest src/agents/cli-backends.test.ts` -- `pnpm vitest src/agents/bash-tools.exec.pty-cleanup.test.ts` -- `pnpm vitest src/agents/bash-tools.process.poll-timeout.test.ts` -- `pnpm vitest src/agents/bash-tools.process.supervisor.test.ts` -- `pnpm vitest src/process/exec.test.ts` - -E2E targets: - -- `pnpm vitest src/agents/cli-runner.test.ts` -- `pnpm vitest run src/agents/bash-tools.exec.pty-fallback.test.ts src/agents/bash-tools.exec.background-abort.test.ts src/agents/bash-tools.process.send-keys.test.ts` - -Typecheck note: - -- Use `pnpm build` (and `pnpm check` for full lint/docs gate) in this repo. Older notes that mention `pnpm tsgo` are obsolete. - -## 8. Operational guarantees preserved - -- Exec env hardening behavior is unchanged. -- Approval and allowlist flow is unchanged. -- Output sanitization and output caps are unchanged. -- PTY adapter still guarantees wait settlement on forced kill and listener disposal. - -## 9. Definition of done - -1. Supervisor is lifecycle owner for managed runs. -2. PTY spawn uses explicit command contract with no argv reconstruction. -3. Process layer has no type dependency on agent layer for supervisor stdin contracts. -4. Watchdog defaults are single source. -5. Targeted unit and e2e tests remain green. -6. Restart durability boundary is explicitly documented or fully implemented. - -## 10. Summary - -The branch now has a coherent and safer supervision shape: - -- explicit PTY contract -- cleaner process layering -- supervisor driven cancellation path for process operations -- real fallback termination when supervisor lookup misses -- process-tree cancellation for child-run default kill paths -- unified watchdog defaults -- explicit in-memory restart boundary (no orphan reconciliation across restart in this pass) diff --git a/docs/experiments/plans/session-binding-channel-agnostic.md b/docs/experiments/plans/session-binding-channel-agnostic.md deleted file mode 100644 index aa1f926b36b..00000000000 --- a/docs/experiments/plans/session-binding-channel-agnostic.md +++ /dev/null @@ -1,226 +0,0 @@ ---- -summary: "Channel agnostic session binding architecture and iteration 1 delivery scope" -read_when: - - Refactoring channel-agnostic session routing and bindings - - Investigating duplicate, stale, or missing session delivery across channels -owner: "onutc" -status: "in-progress" -last_updated: "2026-02-21" -title: "Session Binding Channel Agnostic Plan" ---- - -# Session Binding Channel Agnostic Plan - -## Overview - -This document defines the long term channel agnostic session binding model and the concrete scope for the next implementation iteration. - -Goal: - -- make subagent bound session routing a core capability -- keep channel specific behavior in adapters -- avoid regressions in normal Discord behavior - -## Why this exists - -Current behavior mixes: - -- completion content policy -- destination routing policy -- Discord specific details - -This caused edge cases such as: - -- duplicate main and thread delivery under concurrent runs -- stale token usage on reused binding managers -- missing activity accounting for webhook sends - -## Iteration 1 scope - -This iteration is intentionally limited. - -### 1. Add channel agnostic core interfaces - -Add core types and service interfaces for bindings and routing. - -Proposed core types: - -```ts -export type BindingTargetKind = "subagent" | "session"; -export type BindingStatus = "active" | "ending" | "ended"; - -export type ConversationRef = { - channel: string; - accountId: string; - conversationId: string; - parentConversationId?: string; -}; - -export type SessionBindingRecord = { - bindingId: string; - targetSessionKey: string; - targetKind: BindingTargetKind; - conversation: ConversationRef; - status: BindingStatus; - boundAt: number; - expiresAt?: number; - metadata?: Record; -}; -``` - -Core service contract: - -```ts -export interface SessionBindingService { - bind(input: { - targetSessionKey: string; - targetKind: BindingTargetKind; - conversation: ConversationRef; - metadata?: Record; - ttlMs?: number; - }): Promise; - - listBySession(targetSessionKey: string): SessionBindingRecord[]; - resolveByConversation(ref: ConversationRef): SessionBindingRecord | null; - touch(bindingId: string, at?: number): void; - unbind(input: { - bindingId?: string; - targetSessionKey?: string; - reason: string; - }): Promise; -} -``` - -### 2. Add one core delivery router for subagent completions - -Add a single destination resolution path for completion events. - -Router contract: - -```ts -export interface BoundDeliveryRouter { - resolveDestination(input: { - eventKind: "task_completion"; - targetSessionKey: string; - requester?: ConversationRef; - failClosed: boolean; - }): { - binding: SessionBindingRecord | null; - mode: "bound" | "fallback"; - reason: string; - }; -} -``` - -For this iteration: - -- only `task_completion` is routed through this new path -- existing paths for other event kinds remain as-is - -### 3. Keep Discord as adapter - -Discord remains the first adapter implementation. - -Adapter responsibilities: - -- create/reuse thread conversations -- send bound messages via webhook or channel send -- validate thread state (archived/deleted) -- map adapter metadata (webhook identity, thread ids) - -### 4. Fix currently known correctness issues - -Required in this iteration: - -- refresh token usage when reusing existing thread binding manager -- record outbound activity for webhook based Discord sends -- stop implicit main channel fallback when a bound thread destination is selected for session mode completion - -### 5. Preserve current runtime safety defaults - -No behavior change for users with thread bound spawn disabled. - -Defaults stay: - -- `channels.discord.threadBindings.spawnSubagentSessions = false` - -Result: - -- normal Discord users stay on current behavior -- new core path affects only bound session completion routing where enabled - -## Not in iteration 1 - -Explicitly deferred: - -- ACP binding targets (`targetKind: "acp"`) -- new channel adapters beyond Discord -- global replacement of all delivery paths (`spawn_ack`, future `subagent_message`) -- protocol level changes -- store migration/versioning redesign for all binding persistence - -Notes on ACP: - -- interface design keeps room for ACP -- ACP implementation is not started in this iteration - -## Routing invariants - -These invariants are mandatory for iteration 1. - -- destination selection and content generation are separate steps -- if session mode completion resolves to an active bound destination, delivery must target that destination -- no hidden reroute from bound destination to main channel -- fallback behavior must be explicit and observable - -## Compatibility and rollout - -Compatibility target: - -- no regression for users with thread bound spawning off -- no change to non-Discord channels in this iteration - -Rollout: - -1. Land interfaces and router behind current feature gates. -2. Route Discord completion mode bound deliveries through router. -3. Keep legacy path for non-bound flows. -4. Verify with targeted tests and canary runtime logs. - -## Tests required in iteration 1 - -Unit and integration coverage required: - -- manager token rotation uses latest token after manager reuse -- webhook sends update channel activity timestamps -- two active bound sessions in same requester channel do not duplicate to main channel -- completion for bound session mode run resolves to thread destination only -- disabled spawn flag keeps legacy behavior unchanged - -## Proposed implementation files - -Core: - -- `src/infra/outbound/session-binding-service.ts` (new) -- `src/infra/outbound/bound-delivery-router.ts` (new) -- `src/agents/subagent-announce.ts` (completion destination resolution integration) - -Discord adapter and runtime: - -- `src/discord/monitor/thread-bindings.manager.ts` -- `src/discord/monitor/reply-delivery.ts` -- `src/discord/send.outbound.ts` - -Tests: - -- `src/discord/monitor/provider*.test.ts` -- `src/discord/monitor/reply-delivery.test.ts` -- `src/agents/subagent-announce.format.test.ts` - -## Done criteria for iteration 1 - -- core interfaces exist and are wired for completion routing -- correctness fixes above are merged with tests -- no main and thread duplicate completion delivery in session mode bound runs -- no behavior change for disabled bound spawn deployments -- ACP remains explicitly deferred diff --git a/docs/experiments/proposals/acp-bound-command-auth.md b/docs/experiments/proposals/acp-bound-command-auth.md deleted file mode 100644 index 1d02e9e8469..00000000000 --- a/docs/experiments/proposals/acp-bound-command-auth.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -summary: "Proposal: long-term command authorization model for ACP-bound conversations" -read_when: - - Designing native command auth behavior in Telegram/Discord ACP-bound channels/topics -title: "ACP Bound Command Authorization (Proposal)" ---- - -# ACP Bound Command Authorization (Proposal) - -Status: Proposed, **not implemented yet**. - -This document describes a long-term authorization model for native commands in -ACP-bound conversations. It is an experiments proposal and does not replace -current production behavior. - -For implemented behavior, read source and tests in: - -- `src/telegram/bot-native-commands.ts` -- `src/discord/monitor/native-command.ts` -- `src/auto-reply/reply/commands-core.ts` - -## Problem - -Today we have command-specific checks (for example `/new` and `/reset`) that -need to work inside ACP-bound channels/topics even when allowlists are empty. -This solves immediate UX pain, but command-name-based exceptions do not scale. - -## Long-term shape - -Move command authorization from ad-hoc handler logic to command metadata plus a -shared policy evaluator. - -### 1) Add auth policy metadata to command definitions - -Each command definition should declare an auth policy. Example shape: - -```ts -type CommandAuthPolicy = - | { mode: "owner_or_allowlist" } // default, current strict behavior - | { mode: "bound_acp_or_owner_or_allowlist" } // allow in explicitly bound ACP conversations - | { mode: "owner_only" }; -``` - -`/new` and `/reset` would use `bound_acp_or_owner_or_allowlist`. -Most other commands would remain `owner_or_allowlist`. - -### 2) Share one evaluator across channels - -Introduce one helper that evaluates command auth using: - -- command policy metadata -- sender authorization state -- resolved conversation binding state - -Both Telegram and Discord native handlers should call the same helper to avoid -behavior drift. - -### 3) Use binding-match as the bypass boundary - -When policy allows bound ACP bypass, authorize only if a configured binding -match was resolved for the current conversation (not just because current -session key looks ACP-like). - -This keeps the boundary explicit and minimizes accidental widening. - -## Why this is better - -- Scales to future commands without adding more command-name conditionals. -- Keeps behavior consistent across channels. -- Preserves current security model by requiring explicit binding match. -- Keeps allowlists optional hardening instead of a universal requirement. - -## Rollout plan (future) - -1. Add command auth policy field to command registry types and command data. -2. Implement shared evaluator and migrate Telegram + Discord native handlers. -3. Move `/new` and `/reset` to metadata-driven policy. -4. Add tests per policy mode and channel surface. - -## Non-goals - -- This proposal does not change ACP session lifecycle behavior. -- This proposal does not require allowlists for all ACP-bound commands. -- This proposal does not change existing route binding semantics. - -## Note - -This proposal is intentionally additive and does not delete or replace existing -experiments documents. diff --git a/docs/experiments/proposals/model-config.md b/docs/experiments/proposals/model-config.md deleted file mode 100644 index 6a0ef6524b0..00000000000 --- a/docs/experiments/proposals/model-config.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -summary: "Exploration: model config, auth profiles, and fallback behavior" -read_when: - - Exploring future model selection + auth profile ideas -title: "Model Config Exploration" ---- - -# Model Config (Exploration) - -This document captures **ideas** for future model configuration. It is not a -shipping spec. For current behavior, see: - -- [Models](/concepts/models) -- [Model failover](/concepts/model-failover) -- [OAuth + profiles](/concepts/oauth) - -## Motivation - -Operators want: - -- Multiple auth profiles per provider (personal vs work). -- Simple `/model` selection with predictable fallbacks. -- Clear separation between text models and image-capable models. - -## Possible direction (high level) - -- Keep model selection simple: `provider/model` with optional aliases. -- Let providers have multiple auth profiles, with an explicit order. -- Use a global fallback list so all sessions fail over consistently. -- Only override image routing when explicitly configured. - -## Open questions - -- Should profile rotation be per-provider or per-model? -- How should the UI surface profile selection for a session? -- What is the safest migration path from legacy config keys? diff --git a/docs/experiments/research/memory.md b/docs/experiments/research/memory.md deleted file mode 100644 index 99135e78be9..00000000000 --- a/docs/experiments/research/memory.md +++ /dev/null @@ -1,228 +0,0 @@ ---- -summary: "Research notes: offline memory system for Clawd workspaces (Markdown source-of-truth + derived index)" -read_when: - - Designing workspace memory (~/.openclaw/workspace) beyond daily Markdown logs - - Deciding: standalone CLI vs deep OpenClaw integration - - Adding offline recall + reflection (retain/recall/reflect) -title: "Workspace Memory Research" ---- - -# Workspace Memory v2 (offline): research notes - -Target: Clawd-style workspace (`agents.defaults.workspace`, default `~/.openclaw/workspace`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`). - -This doc proposes an **offline-first** memory architecture that keeps Markdown as the canonical, reviewable source of truth, but adds **structured recall** (search, entity summaries, confidence updates) via a derived index. - -## Why change? - -The current setup (one file per day) is excellent for: - -- “append-only” journaling -- human editing -- git-backed durability + auditability -- low-friction capture (“just write it down”) - -It’s weak for: - -- high-recall retrieval (“what did we decide about X?”, “last time we tried Y?”) -- entity-centric answers (“tell me about Alice / The Castle / warelay”) without rereading many files -- opinion/preference stability (and evidence when it changes) -- time constraints (“what was true during Nov 2025?”) and conflict resolution - -## Design goals - -- **Offline**: works without network; can run on laptop/Castle; no cloud dependency. -- **Explainable**: retrieved items should be attributable (file + location) and separable from inference. -- **Low ceremony**: daily logging stays Markdown, no heavy schema work. -- **Incremental**: v1 is useful with FTS only; semantic/vector and graphs are optional upgrades. -- **Agent-friendly**: makes “recall within token budgets” easy (return small bundles of facts). - -## North star model (Hindsight × Letta) - -Two pieces to blend: - -1. **Letta/MemGPT-style control loop** - -- keep a small “core” always in context (persona + key user facts) -- everything else is out-of-context and retrieved via tools -- memory writes are explicit tool calls (append/replace/insert), persisted, then re-injected next turn - -2. **Hindsight-style memory substrate** - -- separate what’s observed vs what’s believed vs what’s summarized -- support retain/recall/reflect -- confidence-bearing opinions that can evolve with evidence -- entity-aware retrieval + temporal queries (even without full knowledge graphs) - -## Proposed architecture (Markdown source-of-truth + derived index) - -### Canonical store (git-friendly) - -Keep `~/.openclaw/workspace` as canonical human-readable memory. - -Suggested workspace layout: - -``` -~/.openclaw/workspace/ - memory.md # small: durable facts + preferences (core-ish) - memory/ - YYYY-MM-DD.md # daily log (append; narrative) - bank/ # “typed” memory pages (stable, reviewable) - world.md # objective facts about the world - experience.md # what the agent did (first-person) - opinions.md # subjective prefs/judgments + confidence + evidence pointers - entities/ - Peter.md - The-Castle.md - warelay.md - ... -``` - -Notes: - -- **Daily log stays daily log**. No need to turn it into JSON. -- The `bank/` files are **curated**, produced by reflection jobs, and can still be edited by hand. -- `memory.md` remains “small + core-ish”: the things you want Clawd to see every session. - -### Derived store (machine recall) - -Add a derived index under the workspace (not necessarily git tracked): - -``` -~/.openclaw/workspace/.memory/index.sqlite -``` - -Back it with: - -- SQLite schema for facts + entity links + opinion metadata -- SQLite **FTS5** for lexical recall (fast, tiny, offline) -- optional embeddings table for semantic recall (still offline) - -The index is always **rebuildable from Markdown**. - -## Retain / Recall / Reflect (operational loop) - -### Retain: normalize daily logs into “facts” - -Hindsight’s key insight that matters here: store **narrative, self-contained facts**, not tiny snippets. - -Practical rule for `memory/YYYY-MM-DD.md`: - -- at end of day (or during), add a `## Retain` section with 2–5 bullets that are: - - narrative (cross-turn context preserved) - - self-contained (standalone makes sense later) - - tagged with type + entity mentions - -Example: - -``` -## Retain -- W @Peter: Currently in Marrakech (Nov 27–Dec 1, 2025) for Andy’s birthday. -- B @warelay: I fixed the Baileys WS crash by wrapping connection.update handlers in try/catch (see memory/2025-11-27.md). -- O(c=0.95) @Peter: Prefers concise replies (<1500 chars) on WhatsApp; long content goes into files. -``` - -Minimal parsing: - -- Type prefix: `W` (world), `B` (experience/biographical), `O` (opinion), `S` (observation/summary; usually generated) -- Entities: `@Peter`, `@warelay`, etc (slugs map to `bank/entities/*.md`) -- Opinion confidence: `O(c=0.0..1.0)` optional - -If you don’t want authors to think about it: the reflect job can infer these bullets from the rest of the log, but having an explicit `## Retain` section is the easiest “quality lever”. - -### Recall: queries over the derived index - -Recall should support: - -- **lexical**: “find exact terms / names / commands” (FTS5) -- **entity**: “tell me about X” (entity pages + entity-linked facts) -- **temporal**: “what happened around Nov 27” / “since last week” -- **opinion**: “what does Peter prefer?” (with confidence + evidence) - -Return format should be agent-friendly and cite sources: - -- `kind` (`world|experience|opinion|observation`) -- `timestamp` (source day, or extracted time range if present) -- `entities` (`["Peter","warelay"]`) -- `content` (the narrative fact) -- `source` (`memory/2025-11-27.md#L12` etc) - -### Reflect: produce stable pages + update beliefs - -Reflection is a scheduled job (daily or heartbeat `ultrathink`) that: - -- updates `bank/entities/*.md` from recent facts (entity summaries) -- updates `bank/opinions.md` confidence based on reinforcement/contradiction -- optionally proposes edits to `memory.md` (“core-ish” durable facts) - -Opinion evolution (simple, explainable): - -- each opinion has: - - statement - - confidence `c ∈ [0,1]` - - last_updated - - evidence links (supporting + contradicting fact IDs) -- when new facts arrive: - - find candidate opinions by entity overlap + similarity (FTS first, embeddings later) - - update confidence by small deltas; big jumps require strong contradiction + repeated evidence - -## CLI integration: standalone vs deep integration - -Recommendation: **deep integration in OpenClaw**, but keep a separable core library. - -### Why integrate into OpenClaw? - -- OpenClaw already knows: - - the workspace path (`agents.defaults.workspace`) - - the session model + heartbeats - - logging + troubleshooting patterns -- You want the agent itself to call the tools: - - `openclaw memory recall "…" --k 25 --since 30d` - - `openclaw memory reflect --since 7d` - -### Why still split a library? - -- keep memory logic testable without gateway/runtime -- reuse from other contexts (local scripts, future desktop app, etc.) - -Shape: -The memory tooling is intended to be a small CLI + library layer, but this is exploratory only. - -## “S-Collide” / SuCo: when to use it (research) - -If “S-Collide” refers to **SuCo (Subspace Collision)**: it’s an ANN retrieval approach that targets strong recall/latency tradeoffs by using learned/structured collisions in subspaces (paper: arXiv 2411.14754, 2024). - -Pragmatic take for `~/.openclaw/workspace`: - -- **don’t start** with SuCo. -- start with SQLite FTS + (optional) simple embeddings; you’ll get most UX wins immediately. -- consider SuCo/HNSW/ScaNN-class solutions only once: - - corpus is big (tens/hundreds of thousands of chunks) - - brute-force embedding search becomes too slow - - recall quality is meaningfully bottlenecked by lexical search - -Offline-friendly alternatives (in increasing complexity): - -- SQLite FTS5 + metadata filters (zero ML) -- Embeddings + brute force (works surprisingly far if chunk count is low) -- HNSW index (common, robust; needs a library binding) -- SuCo (research-grade; attractive if there’s a solid implementation you can embed) - -Open question: - -- what’s the **best** offline embedding model for “personal assistant memory” on your machines (laptop + desktop)? - - if you already have Ollama: embed with a local model; otherwise ship a small embedding model in the toolchain. - -## Smallest useful pilot - -If you want a minimal, still-useful version: - -- Add `bank/` entity pages and a `## Retain` section in daily logs. -- Use SQLite FTS for recall with citations (path + line numbers). -- Add embeddings only if recall quality or scale demands it. - -## References - -- Letta / MemGPT concepts: “core memory blocks” + “archival memory” + tool-driven self-editing memory. -- Hindsight Technical Report: “retain / recall / reflect”, four-network memory, narrative fact extraction, opinion confidence evolution. -- SuCo: arXiv 2411.14754 (2024): “Subspace Collision” approximate nearest neighbor retrieval. diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 882f547f65a..fb3357a46aa 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -176,12 +176,6 @@ Use these hubs to discover every page, including deep dives and reference docs t - [Templates: TOOLS](/reference/templates/TOOLS) - [Templates: USER](/reference/templates/USER) -## Experiments (exploratory) - -- [Onboarding config protocol](/experiments/onboarding-config-protocol) -- [Research: memory](/experiments/research/memory) -- [Model config exploration](/experiments/proposals/model-config) - ## Project - [Credits](/reference/credits) diff --git a/docs/zh-CN/experiments/onboarding-config-protocol.md b/docs/zh-CN/experiments/onboarding-config-protocol.md deleted file mode 100644 index 991801871ef..00000000000 --- a/docs/zh-CN/experiments/onboarding-config-protocol.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -read_when: Changing onboarding wizard steps or config schema endpoints -summary: 新手引导向导和配置模式的 RPC 协议说明 -title: 新手引导和配置协议 -x-i18n: - generated_at: "2026-02-03T07:47:10Z" - model: claude-opus-4-5 - provider: pi - source_hash: 55163b3ee029c02476800cb616a054e5adfe97dae5bb72f2763dce0079851e06 - source_path: experiments/onboarding-config-protocol.md - workflow: 15 ---- - -# 新手引导 + 配置协议 - -目的:CLI、macOS 应用和 Web UI 之间共享的新手引导 + 配置界面。 - -## 组件 - -- 向导引擎(共享会话 + 提示 + 新手引导状态)。 -- CLI 新手引导使用与 UI 客户端相同的向导流程。 -- Gateway 网关 RPC 公开向导 + 配置模式端点。 -- macOS 新手引导使用向导步骤模型。 -- Web UI 从 JSON Schema + UI 提示渲染配置表单。 - -## Gateway 网关 RPC - -- `wizard.start` 参数:`{ mode?: "local"|"remote", workspace?: string }` -- `wizard.next` 参数:`{ sessionId, answer?: { stepId, value? } }` -- `wizard.cancel` 参数:`{ sessionId }` -- `wizard.status` 参数:`{ sessionId }` -- `config.schema` 参数:`{}` - -响应(结构) - -- 向导:`{ sessionId, done, step?, status?, error? }` -- 配置模式:`{ schema, uiHints, version, generatedAt }` - -## UI 提示 - -- `uiHints` 按路径键入;可选元数据(label/help/group/order/advanced/sensitive/placeholder)。 -- 敏感字段渲染为密码输入;无脱敏层。 -- 不支持的模式节点回退到原始 JSON 编辑器。 - -## 注意 - -- 本文档是跟踪新手引导/配置协议重构的唯一位置。 diff --git a/docs/zh-CN/experiments/plans/cron-add-hardening.md b/docs/zh-CN/experiments/plans/cron-add-hardening.md deleted file mode 100644 index c1dcf1d53bd..00000000000 --- a/docs/zh-CN/experiments/plans/cron-add-hardening.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -last_updated: "2026-01-05" -owner: openclaw -status: complete -summary: 加固 cron.add 输入处理,对齐 schema,改进 cron UI/智能体工具 -title: Cron Add 加固 -x-i18n: - generated_at: "2026-02-03T07:47:26Z" - model: claude-opus-4-5 - provider: pi - source_hash: d7e469674bd9435b846757ea0d5dc8f174eaa8533917fc013b1ef4f82859496d - source_path: experiments/plans/cron-add-hardening.md - workflow: 15 ---- - -# Cron Add 加固 & Schema 对齐 - -## 背景 - -最近的 Gateway 网关日志显示重复的 `cron.add` 失败,参数无效(缺少 `sessionTarget`、`wakeMode`、`payload`,以及格式错误的 `schedule`)。这表明至少有一个客户端(可能是智能体工具调用路径)正在发送包装的或部分指定的任务负载。另外,TypeScript 中的 cron 提供商枚举、Gateway 网关 schema、CLI 标志和 UI 表单类型之间存在漂移,加上 `cron.status` 的 UI 不匹配(期望 `jobCount` 而 Gateway 网关返回 `jobs`)。 - -## 目标 - -- 通过规范化常见的包装负载并推断缺失的 `kind` 字段来停止 `cron.add` INVALID_REQUEST 垃圾。 -- 在 Gateway 网关 schema、cron 类型、CLI 文档和 UI 表单之间对齐 cron 提供商列表。 -- 使智能体 cron 工具 schema 明确,以便 LLM 生成正确的任务负载。 -- 修复 Control UI cron 状态任务计数显示。 -- 添加测试以覆盖规范化和工具行为。 - -## 非目标 - -- 更改 cron 调度语义或任务执行行为。 -- 添加新的调度类型或 cron 表达式解析。 -- 除了必要的字段修复外,不大改 cron 的 UI/UX。 - -## 发现(当前差距) - -- Gateway 网关中的 `CronPayloadSchema` 排除了 `signal` + `imessage`,而 TS 类型包含它们。 -- Control UI CronStatus 期望 `jobCount`,但 Gateway 网关返回 `jobs`。 -- 智能体 cron 工具 schema 允许任意 `job` 对象,导致格式错误的输入。 -- Gateway 网关严格验证 `cron.add` 而不进行规范化,因此包装的负载会失败。 - -## 变更内容 - -- `cron.add` 和 `cron.update` 现在规范化常见的包装形式并推断缺失的 `kind` 字段。 -- 智能体 cron 工具 schema 与 Gateway 网关 schema 匹配,减少无效负载。 -- 提供商枚举在 Gateway 网关、CLI、UI 和 macOS 选择器之间对齐。 -- Control UI 使用 Gateway 网关的 `jobs` 计数字段显示状态。 - -## 当前行为 - -- **规范化:**包装的 `data`/`job` 负载被解包;`schedule.kind` 和 `payload.kind` 在安全时被推断。 -- **默认值:**当缺失时,为 `wakeMode` 和 `sessionTarget` 应用安全默认值。 -- **提供商:**Discord/Slack/Signal/iMessage 现在在 CLI/UI 中一致显示。 - -参见 [Cron 任务](/automation/cron-jobs) 了解规范化的形式和示例。 - -## 验证 - -- 观察 Gateway 网关日志中 `cron.add` INVALID_REQUEST 错误是否减少。 -- 确认 Control UI cron 状态在刷新后显示任务计数。 - -## 可选后续工作 - -- 手动 Control UI 冒烟测试:为每个提供商添加一个 cron 任务 + 验证状态任务计数。 - -## 开放问题 - -- `cron.add` 是否应该接受来自客户端的显式 `state`(当前被 schema 禁止)? -- 我们是否应该允许 `webchat` 作为显式投递提供商(当前在投递解析中被过滤)? diff --git a/docs/zh-CN/experiments/plans/group-policy-hardening.md b/docs/zh-CN/experiments/plans/group-policy-hardening.md deleted file mode 100644 index afbb8b39d6a..00000000000 --- a/docs/zh-CN/experiments/plans/group-policy-hardening.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -read_when: - - 查看历史 Telegram 允许列表更改 -summary: Telegram 允许列表加固:前缀 + 空白规范化 -title: Telegram 允许列表加固 -x-i18n: - generated_at: "2026-02-03T07:47:16Z" - model: claude-opus-4-5 - provider: pi - source_hash: a2eca5fcc85376948cfe1b6044f1a8bc69c7f0eb94d1ceafedc1e507ba544162 - source_path: experiments/plans/group-policy-hardening.md - workflow: 15 ---- - -# Telegram 允许列表加固 - -**日期**:2026-01-05 -**状态**:已完成 -**PR**:#216 - -## 摘要 - -Telegram 允许列表现在不区分大小写地接受 `telegram:` 和 `tg:` 前缀,并容忍意外的空白。这使入站允许列表检查与出站发送规范化保持一致。 - -## 更改内容 - -- 前缀 `telegram:` 和 `tg:` 被同等对待(不区分大小写)。 -- 允许列表条目会被修剪;空条目会被忽略。 - -## 示例 - -以下所有形式都被接受为同一 ID: - -- `telegram:123456` -- `TG:123456` -- `tg:123456` - -## 为什么重要 - -从日志或聊天 ID 复制/粘贴通常会包含前缀和空白。规范化可避免在决定是否在私信或群组中响应时出现误判。 - -## 相关文档 - -- [群聊](/channels/groups) -- [Telegram 提供商](/channels/telegram) diff --git a/docs/zh-CN/experiments/plans/openresponses-gateway.md b/docs/zh-CN/experiments/plans/openresponses-gateway.md deleted file mode 100644 index 797da3d91af..00000000000 --- a/docs/zh-CN/experiments/plans/openresponses-gateway.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -last_updated: "2026-01-19" -owner: openclaw -status: draft -summary: 计划:添加 OpenResponses /v1/responses 端点并干净地弃用 chat completions -title: OpenResponses Gateway 网关计划 -x-i18n: - generated_at: "2026-02-03T07:47:33Z" - model: claude-opus-4-5 - provider: pi - source_hash: 71a22c48397507d1648b40766a3153e420c54f2a2d5186d07e51eb3d12e4636a - source_path: experiments/plans/openresponses-gateway.md - workflow: 15 ---- - -# OpenResponses Gateway 网关集成计划 - -## 背景 - -OpenClaw Gateway 网关目前在 `/v1/chat/completions` 暴露了一个最小的 OpenAI 兼容 Chat Completions 端点(参见 [OpenAI Chat Completions](/gateway/openai-http-api))。 - -Open Responses 是基于 OpenAI Responses API 的开放推理标准。它专为智能体工作流设计,使用基于项目的输入加语义流式事件。OpenResponses 规范定义的是 `/v1/responses`,而不是 `/v1/chat/completions`。 - -## 目标 - -- 添加一个遵循 OpenResponses 语义的 `/v1/responses` 端点。 -- 保留 Chat Completions 作为兼容层,易于禁用并最终移除。 -- 使用隔离的、可复用的 schema 标准化验证和解析。 - -## 非目标 - -- 第一阶段完全实现 OpenResponses 功能(图片、文件、托管工具)。 -- 替换内部智能体执行逻辑或工具编排。 -- 在第一阶段更改现有的 `/v1/chat/completions` 行为。 - -## 研究摘要 - -来源:OpenResponses OpenAPI、OpenResponses 规范网站和 Hugging Face 博客文章。 - -提取的关键点: - -- `POST /v1/responses` 接受 `CreateResponseBody` 字段,如 `model`、`input`(字符串或 `ItemParam[]`)、`instructions`、`tools`、`tool_choice`、`stream`、`max_output_tokens` 和 `max_tool_calls`。 -- `ItemParam` 是以下类型的可区分联合: - - 具有角色 `system`、`developer`、`user`、`assistant` 的 `message` 项 - - `function_call` 和 `function_call_output` - - `reasoning` - - `item_reference` -- 成功响应返回带有 `object: "response"`、`status` 和 `output` 项的 `ResponseResource`。 -- 流式传输使用语义事件,如: - - `response.created`、`response.in_progress`、`response.completed`、`response.failed` - - `response.output_item.added`、`response.output_item.done` - - `response.content_part.added`、`response.content_part.done` - - `response.output_text.delta`、`response.output_text.done` -- 规范要求: - - `Content-Type: text/event-stream` - - `event:` 必须匹配 JSON `type` 字段 - - 终止事件必须是字面量 `[DONE]` -- Reasoning 项可能暴露 `content`、`encrypted_content` 和 `summary`。 -- HF 示例在请求中包含 `OpenResponses-Version: latest`(可选头部)。 - -## 提议的架构 - -- 添加 `src/gateway/open-responses.schema.ts`,仅包含 Zod schema(无 gateway 导入)。 -- 添加 `src/gateway/openresponses-http.ts`(或 `open-responses-http.ts`)用于 `/v1/responses`。 -- 保持 `src/gateway/openai-http.ts` 不变,作为遗留兼容适配器。 -- 添加配置 `gateway.http.endpoints.responses.enabled`(默认 `false`)。 -- 保持 `gateway.http.endpoints.chatCompletions.enabled` 独立;允许两个端点分别切换。 -- 当 Chat Completions 启用时发出启动警告,以表明其遗留状态。 - -## Chat Completions 弃用路径 - -- 保持严格的模块边界:responses 和 chat completions 之间不共享 schema 类型。 -- 通过配置使 Chat Completions 成为可选,这样无需代码更改即可禁用。 -- 一旦 `/v1/responses` 稳定,更新文档将 Chat Completions 标记为遗留。 -- 可选的未来步骤:将 Chat Completions 请求映射到 Responses 处理器,以便更简单地移除。 - -## 第一阶段支持子集 - -- 接受 `input` 为字符串或带有消息角色和 `function_call_output` 的 `ItemParam[]`。 -- 将 system 和 developer 消息提取到 `extraSystemPrompt` 中。 -- 使用最近的 `user` 或 `function_call_output` 作为智能体运行的当前消息。 -- 对不支持的内容部分(图片/文件)返回 `invalid_request_error` 拒绝。 -- 返回带有 `output_text` 内容的单个助手消息。 -- 返回带有零值的 `usage`,直到 token 计数接入。 - -## 验证策略(无 SDK) - -- 为以下支持子集实现 Zod schema: - - `CreateResponseBody` - - `ItemParam` + 消息内容部分联合 - - `ResponseResource` - - Gateway 网关使用的流式事件形状 -- 将 schema 保存在单个隔离模块中,以避免漂移并允许未来代码生成。 - -## 流式实现(第一阶段) - -- 带有 `event:` 和 `data:` 的 SSE 行。 -- 所需序列(最小可行): - - `response.created` - - `response.output_item.added` - - `response.content_part.added` - - `response.output_text.delta`(根据需要重复) - - `response.output_text.done` - - `response.content_part.done` - - `response.completed` - - `[DONE]` - -## 测试和验证计划 - -- 为 `/v1/responses` 添加端到端覆盖: - - 需要认证 - - 非流式响应形状 - - 流式事件顺序和 `[DONE]` - - 使用头部和 `user` 的会话路由 -- 保持 `src/gateway/openai-http.e2e.test.ts` 不变。 -- 手动:用 `stream: true` curl `/v1/responses` 并验证事件顺序和终止 `[DONE]`。 - -## 文档更新(后续) - -- 为 `/v1/responses` 使用和示例添加新文档页面。 -- 更新 `/gateway/openai-http-api`,添加遗留说明和指向 `/v1/responses` 的指针。 diff --git a/docs/zh-CN/experiments/proposals/model-config.md b/docs/zh-CN/experiments/proposals/model-config.md deleted file mode 100644 index 291e5a193ba..00000000000 --- a/docs/zh-CN/experiments/proposals/model-config.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -read_when: - - 探索未来模型选择和认证配置文件的方案 -summary: 探索:模型配置、认证配置文件和回退行为 -title: 模型配置探索 -x-i18n: - generated_at: "2026-02-01T20:25:05Z" - model: claude-opus-4-5 - provider: pi - source_hash: 48623233d80f874c0ae853b51f888599cf8b50ae6fbfe47f6d7b0216bae9500b - source_path: experiments/proposals/model-config.md - workflow: 14 ---- - -# 模型配置(探索) - -本文档记录了未来模型配置的**构想**。这不是正式的发布规范。如需了解当前行为,请参阅: - -- [模型](/concepts/models) -- [模型故障转移](/concepts/model-failover) -- [OAuth + 配置文件](/concepts/oauth) - -## 动机 - -运营者希望: - -- 每个提供商支持多个认证配置文件(个人 vs 工作)。 -- 简单的 `/model` 选择,并具有可预测的回退行为。 -- 文本模型与图像模型之间有清晰的分离。 - -## 可能的方向(高层级) - -- 保持模型选择简洁:`provider/model` 加可选别名。 -- 允许提供商拥有多个认证配置文件,并指定明确的顺序。 -- 使用全局回退列表,使所有会话以一致的方式进行故障转移。 -- 仅在明确配置时才覆盖图像路由。 - -## 待解决的问题 - -- 配置文件轮换应该按提供商还是按模型进行? -- UI 应如何为会话展示配置文件选择? -- 从旧版配置键迁移的最安全路径是什么? diff --git a/docs/zh-CN/experiments/research/memory.md b/docs/zh-CN/experiments/research/memory.md deleted file mode 100644 index 6f5b521c06c..00000000000 --- a/docs/zh-CN/experiments/research/memory.md +++ /dev/null @@ -1,235 +0,0 @@ ---- -read_when: - - 设计超越每日 Markdown 日志的工作区记忆(~/.openclaw/workspace) - - Deciding: standalone CLI vs deep OpenClaw integration - - 添加离线回忆 + 反思(retain/recall/reflect) -summary: 研究笔记:Clawd 工作区的离线记忆系统(Markdown 作为数据源 + 派生索引) -title: 工作区记忆研究 -x-i18n: - generated_at: "2026-02-03T10:06:14Z" - model: claude-opus-4-5 - provider: pi - source_hash: 1753c8ee6284999fab4a94ff5fae7421c85233699c9d3088453d0c2133ac0feb - source_path: experiments/research/memory.md - workflow: 15 ---- - -# 工作区记忆 v2(离线):研究笔记 - -目标:Clawd 风格的工作区(`agents.defaults.workspace`,默认 `~/.openclaw/workspace`),其中"记忆"以每天一个 Markdown 文件(`memory/YYYY-MM-DD.md`)加上一小组稳定文件(例如 `memory.md`、`SOUL.md`)的形式存储。 - -本文档提出一种**离线优先**的记忆架构,保持 Markdown 作为规范的、可审查的数据源,但通过派生索引添加**结构化回忆**(搜索、实体摘要、置信度更新)。 - -## 为什么要改变? - -当前设置(每天一个文件)非常适合: - -- "仅追加"式日志记录 -- 人工编辑 -- git 支持的持久性 + 可审计性 -- 低摩擦捕获("直接写下来") - -但它在以下方面较弱: - -- 高召回率检索("我们对 X 做了什么决定?"、"上次我们尝试 Y 时?") -- 以实体为中心的答案("告诉我关于 Alice / The Castle / warelay 的信息")而无需重读多个文件 -- 观点/偏好稳定性(以及变化时的证据) -- 时间约束("2025 年 11 月期间什么是真实的?")和冲突解决 - -## 设计目标 - -- **离线**:无需网络即可工作;可在笔记本电脑/Castle 上运行;无云依赖。 -- **可解释**:检索的项目应该可归因(文件 + 位置)并与推理分离。 -- **低仪式感**:每日日志保持 Markdown,无需繁重的 schema 工作。 -- **增量式**:v1 仅使用 FTS 就很有用;语义/向量和图是可选升级。 -- **对智能体友好**:使"在 token 预算内回忆"变得简单(返回小型事实包)。 - -## 北极星模型(Hindsight × Letta) - -需要融合两个部分: - -1. **Letta/MemGPT 风格的控制循环** - -- 保持一个小的"核心"始终在上下文中(角色 + 关键用户事实) -- 其他所有内容都在上下文之外,通过工具检索 -- 记忆写入是显式的工具调用(append/replace/insert),持久化后在下一轮重新注入 - -2. **Hindsight 风格的记忆基底** - -- 分离观察到的、相信的和总结的内容 -- 支持 retain/recall/reflect -- 带有置信度的观点可以随证据演变 -- 实体感知检索 + 时间查询(即使没有完整的知识图谱) - -## 提议的架构(Markdown 数据源 + 派生索引) - -### 规范存储(git 友好) - -保持 `~/.openclaw/workspace` 作为规范的人类可读记忆。 - -建议的工作区布局: - -``` -~/.openclaw/workspace/ - memory.md # 小型:持久事实 + 偏好(类似核心) - memory/ - YYYY-MM-DD.md # 每日日志(追加;叙事) - bank/ # "类型化"记忆页面(稳定、可审查) - world.md # 关于世界的客观事实 - experience.md # 智能体做了什么(第一人称) - opinions.md # 主观偏好/判断 + 置信度 + 证据指针 - entities/ - Peter.md - The-Castle.md - warelay.md - ... -``` - -注意: - -- **每日日志保持为每日日志**。无需将其转换为 JSON。 -- `bank/` 文件是**经过整理的**,由反思任务生成,仍可手动编辑。 -- `memory.md` 保持"小型 + 类似核心":你希望 Clawd 每次会话都能看到的内容。 - -### 派生存储(机器回忆) - -在工作区下添加派生索引(不一定需要 git 跟踪): - -``` -~/.openclaw/workspace/.memory/index.sqlite -``` - -后端支持: - -- 用于事实 + 实体链接 + 观点元数据的 SQLite schema -- SQLite **FTS5** 用于词法回忆(快速、小巧、离线) -- 可选的嵌入表用于语义回忆(仍然离线) - -索引始终**可从 Markdown 重建**。 - -## Retain / Recall / Reflect(操作循环) - -### Retain:将每日日志规范化为"事实" - -Hindsight 在这里重要的关键洞察:存储**叙事性、自包含的事实**,而不是微小的片段。 - -`memory/YYYY-MM-DD.md` 的实用规则: - -- 在一天结束时(或期间),添加一个 `## Retain` 部分,包含 2-5 个要点: - - 叙事性(保留跨轮上下文) - - 自包含(独立时也有意义) - - 标记类型 + 实体提及 - -示例: - -``` -## Retain -- W @Peter: Currently in Marrakech (Nov 27–Dec 1, 2025) for Andy's birthday. -- B @warelay: I fixed the Baileys WS crash by wrapping connection.update handlers in try/catch (see memory/2025-11-27.md). -- O(c=0.95) @Peter: Prefers concise replies (<1500 chars) on WhatsApp; long content goes into files. -``` - -最小化解析: - -- 类型前缀:`W`(世界)、`B`(经历/传记)、`O`(观点)、`S`(观察/摘要;通常是生成的) -- 实体:`@Peter`、`@warelay` 等(slug 映射到 `bank/entities/*.md`) -- 观点置信度:`O(c=0.0..1.0)` 可选 - -如果你不想让作者考虑这些:反思任务可以从日志的其余部分推断这些要点,但有一个显式的 `## Retain` 部分是最简单的"质量杠杆"。 - -### Recall:对派生索引的查询 - -Recall 应支持: - -- **词法**:"查找精确的术语/名称/命令"(FTS5) -- **实体**:"告诉我关于 X 的信息"(实体页面 + 实体链接的事实) -- **时间**:"11 月 27 日前后发生了什么"/"自上周以来" -- **观点**:"Peter 偏好什么?"(带置信度 + 证据) - -返回格式应对智能体友好并引用来源: - -- `kind`(`world|experience|opinion|observation`) -- `timestamp`(来源日期,或如果存在则提取的时间范围) -- `entities`(`["Peter","warelay"]`) -- `content`(叙事性事实) -- `source`(`memory/2025-11-27.md#L12` 等) - -### Reflect:生成稳定页面 + 更新信念 - -反思是一个定时任务(每日或心跳 `ultrathink`),它: - -- 根据最近的事实更新 `bank/entities/*.md`(实体摘要) -- 根据强化/矛盾更新 `bank/opinions.md` 置信度 -- 可选地提议对 `memory.md`("类似核心"的持久事实)的编辑 - -观点演变(简单、可解释): - -- 每个观点有: - - 陈述 - - 置信度 `c ∈ [0,1]` - - last_updated - - 证据链接(支持 + 矛盾的事实 ID) -- 当新事实到达时: - - 通过实体重叠 + 相似性找到候选观点(先 FTS,后嵌入) - - 通过小幅增量更新置信度;大幅跳跃需要强矛盾 + 重复证据 - -## CLI 集成:独立 vs 深度集成 - -建议:**深度集成到 OpenClaw**,但保持可分离的核心库。 - -### 为什么要集成到 OpenClaw? - -- OpenClaw 已经知道: - - 工作区路径(`agents.defaults.workspace`) - - 会话模型 + 心跳 - - 日志记录 + 故障排除模式 -- 你希望智能体自己调用工具: - - `openclaw memory recall "…" --k 25 --since 30d` - - `openclaw memory reflect --since 7d` - -### 为什么仍要分离库? - -- 保持记忆逻辑可测试,无需 Gateway 网关/运行时 -- 可从其他上下文重用(本地脚本、未来的桌面应用等) - -形态: -记忆工具预计是一个小型 CLI + 库层,但这仅是探索性的。 - -## "S-Collide" / SuCo:何时使用(研究) - -如果"S-Collide"指的是 **SuCo(Subspace Collision)**:这是一种 ANN 检索方法,通过在子空间中使用学习/结构化碰撞来实现强召回/延迟权衡(论文:arXiv 2411.14754,2024)。 - -对于 `~/.openclaw/workspace` 的务实观点: - -- **不要从** SuCo 开始。 -- 从 SQLite FTS +(可选的)简单嵌入开始;你会立即获得大部分 UX 收益。 -- 仅在以下情况下考虑 SuCo/HNSW/ScaNN 级别的解决方案: - - 语料库很大(数万/数十万个块) - - 暴力嵌入搜索变得太慢 - - 召回质量明显受到词法搜索的瓶颈限制 - -离线友好的替代方案(按复杂性递增): - -- SQLite FTS5 + 元数据过滤(零 ML) -- 嵌入 + 暴力搜索(如果块数量低,效果出奇地好) -- HNSW 索引(常见、稳健;需要库绑定) -- SuCo(研究级;如果有可嵌入的可靠实现则很有吸引力) - -开放问题: - -- 对于你的机器(笔记本 + 台式机)上的"个人助理记忆",**最佳**的离线嵌入模型是什么? - - 如果你已经有 Ollama:使用本地模型嵌入;否则在工具链中附带一个小型嵌入模型。 - -## 最小可用试点 - -如果你想要一个最小但仍有用的版本: - -- 添加 `bank/` 实体页面和每日日志中的 `## Retain` 部分。 -- 使用 SQLite FTS 进行带引用的回忆(路径 + 行号)。 -- 仅在召回质量或规模需要时添加嵌入。 - -## 参考资料 - -- Letta / MemGPT 概念:"核心记忆块" + "档案记忆" + 工具驱动的自编辑记忆。 -- Hindsight 技术报告:"retain / recall / reflect",四网络记忆,叙事性事实提取,观点置信度演变。 -- SuCo:arXiv 2411.14754(2024):"Subspace Collision"近似最近邻检索。 From 2afa55674607da5dc852cac197945d716378fb39 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:01:50 -0700 Subject: [PATCH 377/393] Format: sync seam fixes with oxfmt --- extensions/irc/src/accounts.ts | 2 +- src/plugin-sdk/channel-config-schema.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index e54256dd7c2..8c68eb5406e 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,5 +1,5 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; diff --git a/src/plugin-sdk/channel-config-schema.ts b/src/plugin-sdk/channel-config-schema.ts index 994905f9f20..ac24cec0d27 100644 --- a/src/plugin-sdk/channel-config-schema.ts +++ b/src/plugin-sdk/channel-config-schema.ts @@ -5,4 +5,8 @@ export { buildCatchallMultiAccountChannelSchema, buildNestedDmConfigSchema, } from "../channels/plugins/config-schema.js"; -export { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema } from "../config/zod-schema.core.js"; +export { + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, +} from "../config/zod-schema.core.js"; From 9b6859e5db7756f82c199e946d6ad7ed283b0f6f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:01:57 -0700 Subject: [PATCH 378/393] Feishu: break plugin-sdk setup cycle --- extensions/feishu/setup-api.ts | 2 ++ src/auto-reply/reply/commands-acp/context.ts | 3 ++- src/plugin-sdk/feishu.ts | 5 ++--- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 extensions/feishu/setup-api.ts diff --git a/extensions/feishu/setup-api.ts b/extensions/feishu/setup-api.ts new file mode 100644 index 00000000000..8d44582cd03 --- /dev/null +++ b/extensions/feishu/setup-api.ts @@ -0,0 +1,2 @@ +export { feishuSetupAdapter } from "./src/setup-core.js"; +export { feishuSetupWizard } from "./src/setup-surface.js"; diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 59db08384af..1ec405742b6 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,3 +1,5 @@ +// Avoid routing a core ACP helper back through the Feishu plugin-sdk seam. +import { buildFeishuConversationId } from "../../../../extensions/feishu/src/conversation-id.js"; import { buildTelegramTopicConversationId, normalizeConversationText, @@ -6,7 +8,6 @@ import { import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; -import { buildFeishuConversationId } from "../../../plugin-sdk/feishu.js"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js"; diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 3a4fa4779c4..cde08767535 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -67,8 +67,7 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { feishuSetupWizard } from "../../extensions/feishu/api.js"; -export { feishuSetupAdapter } from "../../extensions/feishu/api.js"; +export { feishuSetupWizard, feishuSetupAdapter } from "../../extensions/feishu/setup-api.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; @@ -84,7 +83,7 @@ export { withTempDownloadPath } from "./temp-path.js"; export { buildFeishuConversationId, parseFeishuConversationId, -} from "../../extensions/feishu/api.js"; +} from "../../extensions/feishu/src/conversation-id.js"; export { createFixedWindowRateLimiter, createWebhookAnomalyTracker, From d9e776eb475989c0f855440c8a27cc975597b2c4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 13:21:22 +0530 Subject: [PATCH 379/393] test(telegram): align create-bot assertions --- .../src/bot.create-telegram-bot.test.ts | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 5c05d54a2c7..d0df14e7cf6 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -5,10 +5,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js"; +const harness = await import("./bot.create-telegram-bot.test-harness.js"); const { answerCallbackQuerySpy, botCtorSpy, commandSpy, + dispatchReplyWithBufferedBlockDispatcher, getLoadConfigMock, getLoadWebMediaMock, getOnHandler, @@ -22,7 +24,6 @@ const { sendChatActionSpy, sendMessageSpy, sendPhotoSpy, - sequentializeKey, sequentializeSpy, setMessageReactionSpy, setMyCommandsSpy, @@ -30,7 +31,7 @@ const { telegramBotRuntimeForTest, throttlerSpy, useSpy, -} = await import("./bot.create-telegram-bot.test-harness.js"); +} = harness; import { resolveTelegramFetch } from "./fetch.js"; // Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. @@ -130,7 +131,7 @@ describe("createTelegramBot", () => { createTelegramBot({ token: "tok" }); expect(sequentializeSpy).toHaveBeenCalledTimes(1); expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value); - expect(sequentializeKey).toBe(getTelegramSequentialKey); + expect(harness.sequentializeKey).toBe(getTelegramSequentialKey); }); it("routes callback_query payloads as messages and answers callbacks", async () => { createTelegramBot({ token: "tok" }); @@ -384,14 +385,23 @@ describe("createTelegramBot", () => { } }); it("triggers typing cue via onReplyStart", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }) => { + await dispatcherOptions.typingCallbacks?.onReplyStart?.(); + return { queuedFinal: false, counts: {} }; + }, + ); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ - message: { chat: { id: 42, type: "private" }, text: "hi" }, + message: { + chat: { id: 42, type: "private" }, + from: { id: 999, username: "random" }, + text: "hi", + }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); }); @@ -1035,6 +1045,7 @@ describe("createTelegramBot", () => { title: "Forum Group", is_forum: true, }, + from: { id: 999, username: "testuser" }, text: testCase.text, date: 1736380800, message_id: 42, @@ -1439,6 +1450,21 @@ describe("createTelegramBot", () => { for (const testCase of forumCases) { resetHarnessSpies(); sendChatActionSpy.mockClear(); + let dispatchCall: + | { + ctx: { + SessionKey?: unknown; + From?: unknown; + MessageThreadId?: unknown; + IsForum?: unknown; + }; + } + | undefined; + dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + dispatchCall = params as typeof dispatchCall; + await params.dispatcherOptions.typingCallbacks?.onReplyStart?.(); + return { queuedFinal: false, counts: {} }; + }); loadConfig.mockReturnValue({ channels: { telegram: { @@ -1451,8 +1477,7 @@ describe("createTelegramBot", () => { const handler = getMessageHandler(); await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId })); - expect(replySpy.mock.calls.length, testCase.name).toBe(1); - const payload = replySpy.mock.calls[0][0]; + const payload = dispatchCall?.ctx; if (testCase.assertTopicMetadata) { expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); @@ -1741,6 +1766,7 @@ describe("createTelegramBot", () => { await handler({ message: { chat: { id: 123, type: "group", title: "Routing" }, + from: { id: 999, username: "ops" }, text: "hello", date: 1736380800, }, @@ -1752,6 +1778,20 @@ describe("createTelegramBot", () => { }); it("applies topic skill filters and system prompts", async () => { + let dispatchCall: + | { + ctx: { + GroupSystemPrompt?: unknown; + }; + replyOptions?: { + skillFilter?: unknown; + }; + } + | undefined; + dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + dispatchCall = params as typeof dispatchCall; + return { queuedFinal: false, counts: {} }; + }); loadConfig.mockReturnValue({ channels: { telegram: { @@ -1778,11 +1818,9 @@ describe("createTelegramBot", () => { await handler(makeForumGroupMessageCtx({ threadId: 99 })); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; + const payload = dispatchCall?.ctx; expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); - const opts = replySpy.mock.calls[0][1] as { skillFilter?: unknown }; - expect(opts?.skillFilter).toEqual([]); + expect(dispatchCall?.replyOptions?.skillFilter).toEqual([]); }); it("threads native command replies inside topics", async () => { commandSpy.mockClear(); From 0567f111ac00af8f3b26d905055dfc47a3ef216b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 13:21:54 +0530 Subject: [PATCH 380/393] test(telegram): stabilize inbound media harness --- ...dia-file-path-no-file-download.e2e.test.ts | 22 ++- .../telegram/src/bot.media.e2e-harness.ts | 161 +++++++++++++----- ...t.media.stickers-and-fragments.e2e.test.ts | 90 +++++----- .../telegram/src/bot.media.test-utils.ts | 62 ++++--- src/auto-reply/inbound-debounce.ts | 4 +- 5 files changed, 220 insertions(+), 119 deletions(-) diff --git a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts index 2c02d69d33f..e385c102681 100644 --- a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts +++ b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts @@ -6,6 +6,7 @@ import { createBotHandlerWithOptions, mockTelegramFileDownload, mockTelegramPngDownload, + watchTelegramFetch, } from "./bot.media.test-utils.js"; describe("telegram inbound media", () => { @@ -39,8 +40,10 @@ describe("telegram inbound media", () => { }) => { expect(params.runtimeError).not.toHaveBeenCalled(); expect(params.fetchSpy).toHaveBeenCalledWith( - "https://api.telegram.org/file/bottok/photos/1.jpg", - expect.objectContaining({ redirect: "manual" }), + expect.objectContaining({ + url: "https://api.telegram.org/file/bottok/photos/1.jpg", + filePathHint: "photos/1.jpg", + }), ); expect(params.replySpy).toHaveBeenCalledTimes(1); const payload = params.replySpy.mock.calls[0][0]; @@ -51,7 +54,7 @@ describe("telegram inbound media", () => { name: "skips when file_path is missing", messageId: 2, getFile: async () => ({}), - setupFetch: () => vi.spyOn(globalThis, "fetch"), + setupFetch: () => watchTelegramFetch(), assert: (params: { fetchSpy: ReturnType; replySpy: ReturnType; @@ -71,6 +74,7 @@ describe("telegram inbound media", () => { message: { message_id: scenario.messageId, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, photo: [{ file_id: "fid" }], date: 1736380800, // 2025-01-09T00:00:00Z }, @@ -106,6 +110,7 @@ describe("telegram inbound media", () => { message: { message_id: 1001, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, photo: [{ file_id: "fid" }], date: 1736380800, }, @@ -245,6 +250,7 @@ describe("telegram media groups", () => { messages: [ { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 1, caption: "Here are my photos", date: 1736380800, @@ -254,6 +260,7 @@ describe("telegram media groups", () => { }, { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 2, date: 1736380801, media_group_id: "album123", @@ -272,6 +279,7 @@ describe("telegram media groups", () => { messages: [ { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 11, caption: "Album A", date: 1736380800, @@ -281,6 +289,7 @@ describe("telegram media groups", () => { }, { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 12, caption: "Album B", date: 1736380801, @@ -339,7 +348,6 @@ describe("telegram forwarded bursts", () => { const runtimeError = vi.fn(); const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError }); const fetchSpy = mockTelegramPngDownload(); - vi.useFakeTimers(); try { await handler({ @@ -368,8 +376,9 @@ describe("telegram forwarded bursts", () => { getFile: async () => ({ file_path: "photos/fwd1.jpg" }), }); - await vi.runAllTimersAsync(); - expect(replySpy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(replySpy).toHaveBeenCalledTimes(1); + }); expect(runtimeError).not.toHaveBeenCalled(); const payload = replySpy.mock.calls[0][0]; @@ -377,7 +386,6 @@ describe("telegram forwarded bursts", () => { expect(payload.MediaPaths).toHaveLength(1); } finally { fetchSpy.mockRestore(); - vi.useRealTimers(); } }, FORWARD_BURST_TEST_TIMEOUT_MS, diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index 7054b69d06a..3dbd8634ab1 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,21 +1,55 @@ +import path from "node:path"; +import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import { beforeEach, vi, type Mock } from "vitest"; -import type { TelegramBotDeps } from "./bot-deps.js"; - -const EMPTY_REPLY_COUNTS = { - block: 0, - final: 0, - tool: 0, -} as const; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); export const onSpy: Mock = vi.fn(); export const stopSpy: Mock = vi.fn(); export const sendChatActionSpy: Mock = vi.fn(); -export const undiciFetchSpy: Mock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => - globalThis.fetch(input, init), -); +function defaultUndiciFetch(input: RequestInfo | URL, init?: RequestInit) { + return globalThis.fetch(input, init); +} + +export const undiciFetchSpy: Mock = vi.fn(defaultUndiciFetch); + +export function resetUndiciFetchMock() { + undiciFetchSpy.mockReset(); + undiciFetchSpy.mockImplementation(defaultUndiciFetch); +} + +type FetchRemoteMediaFn = typeof import("openclaw/plugin-sdk/media-runtime").fetchRemoteMedia; + +async function defaultFetchRemoteMedia( + params: Parameters[0], +): ReturnType { + if (!params.fetchImpl) { + throw new MediaFetchError("fetch_failed", `Missing fetchImpl for ${params.url}`); + } + const response = await params.fetchImpl(params.url, { + redirect: "manual", + }); + if (!response.ok) { + throw new MediaFetchError( + "http_error", + `Failed to fetch media from ${params.url}: HTTP ${response.status} ${response.statusText}`, + ); + } + const arrayBuffer = await response.arrayBuffer(); + return { + buffer: Buffer.from(arrayBuffer), + contentType: response.headers.get("content-type") ?? undefined, + fileName: params.filePathHint ? path.basename(params.filePathHint) : undefined, + } as Awaited>; +} + +export const fetchRemoteMediaSpy: Mock = vi.fn(defaultFetchRemoteMedia); + +export function resetFetchRemoteMediaMock() { + fetchRemoteMediaSpy.mockReset(); + fetchRemoteMediaSpy.mockImplementation(defaultFetchRemoteMedia); +} async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) { return { @@ -63,11 +97,7 @@ const apiStub: ApiStub = { setMyCommands: vi.fn(async () => undefined), }; -export const telegramBotRuntimeForTest: { - Bot: new (token: string) => unknown; - sequentialize: () => unknown; - apiThrottler: () => unknown; -} = { +export const telegramBotRuntimeForTest = { Bot: class { api = apiStub; use = middlewareUseSpy; @@ -81,26 +111,46 @@ export const telegramBotRuntimeForTest: { apiThrottler: () => throttlerSpy(), }; -const mediaHarnessReplySpy = vi.hoisted(() => - vi.fn(async (_ctx, opts) => { - await opts?.onReplyStart?.(); - return undefined; - }), -); +const mediaHarnessReplySpy = vi.hoisted(() => vi.fn(async () => undefined)); +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; +type DispatchReplyHarnessParams = Parameters[0]; + +let actualDispatchReplyWithBufferedBlockDispatcherPromise: + | Promise + | undefined; + +async function getActualDispatchReplyWithBufferedBlockDispatcher() { + actualDispatchReplyWithBufferedBlockDispatcherPromise ??= + import("../../../src/auto-reply/reply/provider-dispatcher.js").then( + (module) => + module.dispatchReplyWithBufferedBlockDispatcher as DispatchReplyWithBufferedBlockDispatcherFn, + ); + return await actualDispatchReplyWithBufferedBlockDispatcherPromise; +} + +async function dispatchReplyWithBufferedBlockDispatcherViaActual( + params: DispatchReplyHarnessParams, +) { + const actualDispatchReplyWithBufferedBlockDispatcher = + await getActualDispatchReplyWithBufferedBlockDispatcher(); + return await actualDispatchReplyWithBufferedBlockDispatcher({ + ...params, + replyResolver: async (ctx, _cfg, opts) => { + await opts?.onReplyStart?.(); + return await mediaHarnessReplySpy(ctx, opts); + }, + }); +} + const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => - vi.fn(async (params) => { - await params.dispatcherOptions?.typingCallbacks?.start?.(); - const reply = await mediaHarnessReplySpy(params.ctx, params.replyOptions); - const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; - for (const payload of payloads) { - await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); - } - return { queuedFinal: false, counts: EMPTY_REPLY_COUNTS }; - }), + vi.fn( + dispatchReplyWithBufferedBlockDispatcherViaActual, + ), ); -export const telegramBotDepsForTest: TelegramBotDeps = { +export const telegramBotDepsForTest = { loadConfig: () => ({ - channels: { telegram: { dmPolicy: "open" as const, allowFrom: ["*"] } }, + channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }), resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/telegram-media-sessions.json"), readChannelAllowFromStore: vi.fn(async () => [] as string[]), @@ -113,6 +163,8 @@ export const telegramBotDepsForTest: TelegramBotDeps = { beforeEach(() => { resetInboundDedupe(); resetSaveMediaBufferMock(); + resetUndiciFetchMock(); + resetFetchRemoteMediaMock(); }); const throttlerSpy = vi.fn(() => "throttler"); @@ -133,6 +185,12 @@ vi.doMock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); + Object.defineProperty(mockModule, "fetchRemoteMedia", { + configurable: true, + enumerable: true, + writable: true, + value: (...args: Parameters) => fetchRemoteMediaSpy(...args), + }); Object.defineProperty(mockModule, "saveMediaBuffer", { configurable: true, enumerable: true, @@ -149,24 +207,35 @@ vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { loadConfig: () => ({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }), - }; -}); - -vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, updateLastRoute: vi.fn(async () => undefined), }; }); -vi.doMock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore: vi.fn(async () => [] as string[]), - upsertChannelPairingRequest: vi.fn(async () => ({ - code: "PAIRCODE", - created: true, - })), -})); +vi.doMock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findModelInCatalog: vi.fn(() => undefined), + loadModelCatalog: vi.fn(async () => []), + modelSupportsVision: vi.fn(() => false), + resolveDefaultModelForAgent: vi.fn(() => ({ + provider: "openai", + model: "gpt-test", + })), + }; +}); + +vi.doMock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readChannelAllowFromStore: vi.fn(async () => [] as string[]), + upsertChannelPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), + }; +}); vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); diff --git a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts index fc1b372f778..67e9cab4f19 100644 --- a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts +++ b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts @@ -7,6 +7,7 @@ import { describeStickerImageSpy, getCachedStickerSpy, mockTelegramFileDownload, + watchTelegramFetch, } from "./bot.media.test-utils.js"; describe("telegram stickers", () => { @@ -34,6 +35,7 @@ describe("telegram stickers", () => { message: { message_id: 100, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, sticker: { file_id: "sticker_file_id_123", file_unique_id: "sticker_unique_123", @@ -53,8 +55,10 @@ describe("telegram stickers", () => { expect(runtimeError).not.toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledWith( - "https://api.telegram.org/file/bottok/stickers/sticker.webp", - expect.objectContaining({ redirect: "manual" }), + expect.objectContaining({ + url: "https://api.telegram.org/file/bottok/stickers/sticker.webp", + filePathHint: "stickers/sticker.webp", + }), ); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; @@ -82,18 +86,16 @@ describe("telegram stickers", () => { cachedAt: "2026-01-20T10:00:00.000Z", }); - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/webp" }, - arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, - } as unknown as Response); + const fetchSpy = mockTelegramFileDownload({ + contentType: "image/webp", + bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), + }); await handler({ message: { message_id: 103, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, sticker: { file_id: "new_file_id", file_unique_id: "sticker_unique_456", @@ -167,12 +169,13 @@ describe("telegram stickers", () => { ]) { replySpy.mockClear(); runtimeError.mockClear(); - const fetchSpy = vi.spyOn(globalThis, "fetch"); + const fetchSpy = watchTelegramFetch(); await handler({ message: { message_id: scenario.messageId, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, sticker: scenario.sticker, date: 1736380800, }, @@ -202,43 +205,44 @@ describe("telegram text fragments", () => { "buffers near-limit text and processes sequential parts as one message", async () => { const { handler, replySpy } = await createBotHandlerWithOptions({}); - vi.useFakeTimers(); - try { - const part1 = "A".repeat(4050); - const part2 = "B".repeat(50); + const part1 = "A".repeat(4050); + const part2 = "B".repeat(50); - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 10, - date: 1736380800, - text: part1, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); + await handler({ + message: { + chat: { id: 42, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + message_id: 10, + date: 1736380800, + text: part1, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 11, - date: 1736380801, - text: part2, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); + await handler({ + message: { + chat: { id: 42, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + message_id: 11, + date: 1736380801, + text: part2, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); - expect(replySpy).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS * 2); - expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).not.toHaveBeenCalled(); + await vi.waitFor( + () => { + expect(replySpy).toHaveBeenCalledTimes(1); + }, + { timeout: TEXT_FRAGMENT_FLUSH_MS * 6, interval: 5 }, + ); - const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; - expect(payload.RawBody).toContain(part1.slice(0, 32)); - expect(payload.RawBody).toContain(part2.slice(0, 32)); - } finally { - vi.useRealTimers(); - } + const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; + expect(payload.RawBody).toContain(part1.slice(0, 32)); + expect(payload.RawBody).toContain(part2.slice(0, 32)); }, TEXT_FRAGMENT_TEST_TIMEOUT_MS, ); diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index 7c391642d67..a816cc7c4fb 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -22,6 +22,18 @@ let createTelegramBotRef: typeof import("./bot.js").createTelegramBot; let replySpyRef: ReturnType; let onSpyRef: Mock; let sendChatActionSpyRef: Mock; +let fetchRemoteMediaSpyRef: Mock; +let resetFetchRemoteMediaMockRef: () => void; + +type FetchMockHandle = Mock & { mockRestore: () => void }; + +function createFetchMockHandle(): FetchMockHandle { + return Object.assign(fetchRemoteMediaSpyRef, { + mockRestore: () => { + resetFetchRemoteMediaMockRef(); + }, + }) as FetchMockHandle; +} export async function createBotHandler(): Promise<{ handler: (ctx: Record) => Promise; @@ -68,24 +80,26 @@ export async function createBotHandlerWithOptions(options: { export function mockTelegramFileDownload(params: { contentType: string; bytes: Uint8Array; -}): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => params.contentType }, - arrayBuffer: async () => params.bytes.buffer, - } as unknown as Response); +}): FetchMockHandle { + fetchRemoteMediaSpyRef.mockResolvedValueOnce({ + buffer: Buffer.from(params.bytes), + contentType: params.contentType, + fileName: "mock-file", + }); + return createFetchMockHandle(); } -export function mockTelegramPngDownload(): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/png" }, - arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, - } as unknown as Response); +export function mockTelegramPngDownload(): FetchMockHandle { + fetchRemoteMediaSpyRef.mockResolvedValue({ + buffer: Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])), + contentType: "image/png", + fileName: "mock-file.png", + }); + return createFetchMockHandle(); +} + +export function watchTelegramFetch(): FetchMockHandle { + return createFetchMockHandle(); } beforeEach(() => { @@ -106,6 +120,8 @@ beforeAll(async () => { const harness = await import("./bot.media.e2e-harness.js"); onSpyRef = harness.onSpy; sendChatActionSpyRef = harness.sendChatActionSpy; + fetchRemoteMediaSpyRef = harness.fetchRemoteMediaSpy; + resetFetchRemoteMediaMockRef = harness.resetFetchRemoteMediaMock; const botModule = await import("./bot.js"); botModule.setTelegramBotRuntimeForTest( harness.telegramBotRuntimeForTest as unknown as Parameters< @@ -121,8 +137,12 @@ beforeAll(async () => { replySpyRef = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; }, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); -vi.mock("./sticker-cache.js", () => ({ - cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), - getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), - describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), -})); +vi.mock("./sticker-cache.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), + getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), + describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), + }; +}); diff --git a/src/auto-reply/inbound-debounce.ts b/src/auto-reply/inbound-debounce.ts index 940732800d3..debda7bc7b5 100644 --- a/src/auto-reply/inbound-debounce.ts +++ b/src/auto-reply/inbound-debounce.ts @@ -88,8 +88,8 @@ export function createInboundDebouncer(params: InboundDebounceCreateParams if (buffer.timeout) { clearTimeout(buffer.timeout); } - buffer.timeout = setTimeout(() => { - void flushBuffer(key, buffer); + buffer.timeout = setTimeout(async () => { + await flushBuffer(key, buffer); }, buffer.debounceMs); buffer.timeout.unref?.(); }; From 25011bdb1ed11763fac0cfc29ae6bc0a94dc5c4b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:08:22 -0700 Subject: [PATCH 381/393] Plugins: prefer source bundles in git checkouts --- src/plugins/bundled-dir.test.ts | 21 +++++++++++++++++++++ src/plugins/bundled-dir.ts | 13 ++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index fd978ec7069..9ff474a4ada 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -68,4 +68,25 @@ describe("resolveBundledPluginsDir", () => { fs.realpathSync(path.join(repoRoot, "extensions")), ); }); + + it("prefers source extensions in a git checkout even without vitest env", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-dir-git-"); + fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8"); + fs.writeFileSync( + path.join(repoRoot, "package.json"), + `${JSON.stringify({ name: "openclaw" }, null, 2)}\n`, + "utf8", + ); + + process.chdir(repoRoot); + delete process.env.VITEST; + + expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( + fs.realpathSync(path.join(repoRoot, "extensions")), + ); + }); }); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 6614a50aed0..419e708ed08 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -4,6 +4,14 @@ import { fileURLToPath } from "node:url"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { resolveUserPath } from "../utils.js"; +function isSourceCheckoutRoot(packageRoot: string): boolean { + return ( + fs.existsSync(path.join(packageRoot, ".git")) && + fs.existsSync(path.join(packageRoot, "src")) && + fs.existsSync(path.join(packageRoot, "extensions")) + ); +} + export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined { const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim(); if (override) { @@ -21,7 +29,10 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): ); for (const packageRoot of packageRoots) { const sourceExtensionsDir = path.join(packageRoot, "extensions"); - if (preferSourceCheckout && fs.existsSync(sourceExtensionsDir)) { + if ( + (preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) && + fs.existsSync(sourceExtensionsDir) + ) { return sourceExtensionsDir; } // Local source checkouts stage a runtime-complete bundled plugin tree under From d1ef7d64e96127c7798f151eb49db15caf7aeb17 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:30:05 -0700 Subject: [PATCH 382/393] Contracts: harden provider registry loading --- extensions/github-copilot/index.ts | 8 +--- .../contracts/provider.contract.test.ts | 11 ++++- src/plugins/contracts/registry.ts | 43 +++++++++++++------ 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index ee85f76fd61..39116636b76 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -1,15 +1,11 @@ +import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; import { definePluginEntry, type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { - coerceSecretRef, - ensureAuthProfileStore, - githubCopilotLoginCommand, - listProfilesForProvider, -} from "openclaw/plugin-sdk/provider-auth"; +import { coerceSecretRef, githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; import { fetchCopilotUsage } from "./usage.js"; diff --git a/src/plugins/contracts/provider.contract.test.ts b/src/plugins/contracts/provider.contract.test.ts index 9ff8f7458d3..db5ce6e3c03 100644 --- a/src/plugins/contracts/provider.contract.test.ts +++ b/src/plugins/contracts/provider.contract.test.ts @@ -1,7 +1,14 @@ -import { describe } from "vitest"; -import { providerContractRegistry } from "./registry.js"; +import { describe, expect, it } from "vitest"; +import { providerContractLoadError, providerContractRegistry } from "./registry.js"; import { installProviderPluginContractSuite } from "./suites.js"; +describe("provider contract registry load", () => { + it("loads bundled providers without import-time registry failure", () => { + expect(providerContractLoadError).toBeUndefined(); + expect(providerContractRegistry.length).toBeGreaterThan(0); + }); +}); + for (const entry of providerContractRegistry) { describe(`${entry.pluginId}:${entry.provider.id} provider contract`, () => { installProviderPluginContractSuite({ diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index e4b6cf1059a..142aa578b0f 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -99,19 +99,31 @@ export const providerContractRegistry: ProviderContractEntry[] = buildCapability select: () => [], }); -const loadedBundledProviderRegistry: ProviderContractEntry[] = resolvePluginProviders({ - bundledProviderAllowlistCompat: true, - bundledProviderVitestCompat: true, - cache: false, - activate: false, -}) - .filter((provider): provider is ProviderPlugin & { pluginId: string } => - Boolean(provider.pluginId), - ) - .map((provider) => ({ - pluginId: provider.pluginId, - provider, - })); +export let providerContractLoadError: Error | undefined; + +function loadBundledProviderRegistry(): ProviderContractEntry[] { + try { + providerContractLoadError = undefined; + return resolvePluginProviders({ + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + cache: false, + activate: false, + }) + .filter((provider): provider is ProviderPlugin & { pluginId: string } => + Boolean(provider.pluginId), + ) + .map((provider) => ({ + pluginId: provider.pluginId, + provider, + })); + } catch (error) { + providerContractLoadError = error instanceof Error ? error : new Error(String(error)); + return []; + } +} + +const loadedBundledProviderRegistry: ProviderContractEntry[] = loadBundledProviderRegistry(); providerContractRegistry.splice( 0, @@ -134,6 +146,11 @@ export const providerContractCompatPluginIds = providerContractPluginIds.map((pl export function requireProviderContractProvider(providerId: string): ProviderPlugin { const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId); if (!provider) { + if (providerContractLoadError) { + throw new Error( + `provider contract entry missing for ${providerId}; bundled provider registry failed to load: ${providerContractLoadError.message}`, + ); + } throw new Error(`provider contract entry missing for ${providerId}`); } return provider; From 3cecbcf8b6f0de4395a36c3af9f2e205e5b81ab4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:31:25 -0700 Subject: [PATCH 383/393] docs: fix curly quotes, non-breaking hyphens, and remaining apostrophes in headings --- README.md | 2 +- docs/automation/cron-jobs.md | 2 +- docs/channels/group-messages.md | 2 +- docs/cli/directory.md | 2 +- docs/concepts/context.md | 4 ++-- docs/concepts/model-failover.md | 2 +- docs/concepts/models.md | 2 +- docs/concepts/multi-agent.md | 2 +- docs/concepts/presence.md | 2 +- docs/concepts/streaming.md | 2 +- docs/concepts/typebox.md | 2 +- docs/gateway/authentication.md | 2 +- docs/gateway/bonjour.md | 6 +++--- docs/gateway/discovery.md | 2 +- docs/gateway/remote.md | 2 +- docs/gateway/sandbox-vs-tool-policy-vs-elevated.md | 8 ++++---- docs/gateway/security/index.md | 2 +- docs/help/testing.md | 4 ++-- docs/install/updating.md | 2 +- docs/nodes/media-understanding.md | 4 ++-- docs/platforms/mac/dev-setup.md | 2 +- docs/platforms/mac/peekaboo.md | 2 +- docs/platforms/mac/webchat.md | 2 +- docs/plugins/agent-tools.md | 2 +- docs/providers/bedrock.md | 2 +- docs/providers/minimax.md | 2 +- docs/reference/session-management-compaction.md | 2 +- docs/start/openclaw.md | 2 +- docs/start/setup.md | 2 +- docs/tools/elevated.md | 2 +- docs/tools/plugin.md | 2 +- docs/web/dashboard.md | 2 +- docs/zh-CN/start/hubs.md | 6 ------ 33 files changed, 40 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 1c836da84ee..e483bcc9446 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ If you plan to build/run companion apps, follow the platform runbooks below. - WebChat + debug tools. - Remote gateway control over SSH. -Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`). +Note: signed builds required for macOS permissions to stick across rebuilds (see [macOS Permissions](https://docs.openclaw.ai/platforms/mac/permissions)). ### iOS node (optional) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index cb27380416b..d58683aedea 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -700,7 +700,7 @@ openclaw system event --mode now --text "Next heartbeat: check battery." ## Troubleshooting -### “Nothing runs” +### "Nothing runs" - Check cron is enabled: `cron.enabled` and `OPENCLAW_SKIP_CRON`. - Check the Gateway is running continuously (cron runs inside the Gateway process). diff --git a/docs/channels/group-messages.md b/docs/channels/group-messages.md index 078ae9e7845..c1858bf1d96 100644 --- a/docs/channels/group-messages.md +++ b/docs/channels/group-messages.md @@ -11,7 +11,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback). -## What’s implemented (2025-12-03) +## Current implementation (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, safe regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). - Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders). diff --git a/docs/cli/directory.md b/docs/cli/directory.md index 9d8f8a92b68..15ba7ba60e1 100644 --- a/docs/cli/directory.md +++ b/docs/cli/directory.md @@ -40,7 +40,7 @@ openclaw message send --channel slack --target user:U012ABCDEF --message "hello" - Zalo (plugin): user id (Bot API) - Zalo Personal / `zalouser` (plugin): thread id (DM/group) from `zca` (`me`, `friend list`, `group list`) -## Self (“me”) +## Self ("me") ```bash openclaw directory self --channel zalouser diff --git a/docs/concepts/context.md b/docs/concepts/context.md index 356f8b810c3..107afc164ae 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -116,7 +116,7 @@ Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (de When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`). -## Skills: what’s injected vs loaded on-demand +## Skills: injected vs loaded on-demand The system prompt includes a compact **skills list** (name + description + location). This list has real overhead. @@ -131,7 +131,7 @@ Tools affect context in two ways: `/context detail` breaks down the biggest tool schemas so you can see what dominates. -## Commands, directives, and “inline shortcuts” +## Commands, directives, and "inline shortcuts" Slash commands are handled by the Gateway. There are a few different behaviors: diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 80b3420d07c..80592bcc2c9 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -70,7 +70,7 @@ they are tried first, but OpenClaw may rotate to another profile on rate limits/ User‑pinned profiles stay locked to that profile; if it fails and model fallbacks are configured, OpenClaw moves to the next model instead of switching profiles. -### Why OAuth can “look lost” +### Why OAuth can "look lost" If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 6ed1d1de3ab..0a32e1b5d8b 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -60,7 +60,7 @@ to `zai/*`. Provider configuration examples (including OpenCode) live in [/gateway/configuration](/gateway/configuration#opencode). -## “Model is not allowed” (and why replies stop) +## "Model is not allowed" (and why replies stop) If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for session overrides. When a user selects a model that isn’t in that allowlist, diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 6f0bd086690..3f52fa77e74 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -9,7 +9,7 @@ status: active Goal: multiple _isolated_ agents (separate workspace + `agentDir` + sessions), plus multiple channel accounts (e.g. two WhatsApps) in one running Gateway. Inbound is routed to an agent via bindings. -## What is “one agent”? +## What is "one agent"? An **agent** is a fully scoped brain with its own: diff --git a/docs/concepts/presence.md b/docs/concepts/presence.md index a185205793a..1c9a7e3a12a 100644 --- a/docs/concepts/presence.md +++ b/docs/concepts/presence.md @@ -45,7 +45,7 @@ even before any clients connect. Every WS client begins with a `connect` request. On successful handshake the Gateway upserts a presence entry for that connection. -#### Why one‑off CLI commands don’t show up +#### Why one-off CLI commands do not show up The CLI often connects for short, one‑off commands. To avoid spamming the Instances list, `client.mode === "cli"` is **not** turned into a presence entry. diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index c31048cb268..3f69ada2b91 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -90,7 +90,7 @@ more natural. - Modes: `off` (default), `natural` (800–2500ms), `custom` (`minMs`/`maxMs`). - Applies only to **block replies**, not final replies or tool summaries. -## “Stream chunks or everything” +## "Stream chunks or everything" This maps to: diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md index 92c6eef2fe9..274e9e3beaa 100644 --- a/docs/concepts/typebox.md +++ b/docs/concepts/typebox.md @@ -185,7 +185,7 @@ ws.on("message", (data) => { }); ``` -## Worked example: add a method end‑to‑end +## Worked example: add a method end-to-end Example: add a new `system.echo` request that returns `{ ok: true, text }`. diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 8a7eae00194..895124bd8c3 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -159,7 +159,7 @@ Use `--agent ` to target a specific agent; omit it to use the configured def ## Troubleshooting -### “No credentials found” +### "No credentials found" If the Anthropic token profile is missing, run `claude setup-token` on the **gateway host**, then re-check: diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index 03643717d55..16aa5c68d2b 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -12,7 +12,7 @@ OpenClaw uses Bonjour (mDNS / DNS‑SD) as a **LAN‑only convenience** to disco an active Gateway (WebSocket endpoint). It is best‑effort and does **not** replace SSH or Tailnet-based connectivity. -## Wide‑area Bonjour (Unicast DNS‑SD) over Tailscale +## Wide-area Bonjour (Unicast DNS-SD) over Tailscale If the node and gateway are on different networks, multicast mDNS won’t cross the boundary. You can keep the same discovery UX by switching to **unicast DNS‑SD** @@ -38,7 +38,7 @@ iOS/Android nodes browse both `local.` and your configured wide‑area domain. } ``` -### One‑time DNS server setup (gateway host) +### One-time DNS server setup (gateway host) ```bash openclaw dns setup --apply @@ -84,7 +84,7 @@ Only the Gateway advertises `_openclaw-gw._tcp`. - `_openclaw-gw._tcp` — gateway transport beacon (used by macOS/iOS/Android nodes). -## TXT keys (non‑secret hints) +## TXT keys (non-secret hints) The Gateway advertises small non‑secret hints to make UI flows convenient: diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md index af1144125d3..cfdc3afdfe0 100644 --- a/docs/gateway/discovery.md +++ b/docs/gateway/discovery.md @@ -29,7 +29,7 @@ Protocol details: - [Gateway protocol](/gateway/protocol) - [Bridge protocol (legacy)](/gateway/bridge-protocol) -## Why we keep both “direct” and SSH +## Why we keep both "direct" and SSH - **Direct WS** is the best UX on the same network and within a tailnet: - auto-discovery on LAN via Bonjour diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index dcbae985b74..a1bc4720ad6 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -126,7 +126,7 @@ WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects direct - Forward `18789` over SSH (see above), then connect clients to `ws://127.0.0.1:18789`. - On macOS, prefer the app’s “Remote over SSH” mode, which manages the tunnel automatically. -## macOS app “Remote over SSH” +## macOS app "Remote over SSH" The macOS menu bar app can drive the same setup end-to-end (remote status checks, WebChat, and Voice Wake forwarding). diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index 9e7fecfd949..080ced13b2f 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -95,7 +95,7 @@ Available groups: - `group:nodes`: `nodes` - `group:openclaw`: all built-in OpenClaw tools (excludes provider plugins) -## Elevated: exec-only “run on host” +## Elevated: exec-only "run on host" Elevated does **not** grant extra tools; it only affects `exec`. @@ -112,9 +112,9 @@ Gates: See [Elevated Mode](/tools/elevated). -## Common “sandbox jail” fixes +## Common "sandbox jail" fixes -### “Tool X blocked by sandbox tool policy” +### "Tool X blocked by sandbox tool policy" Fix-it keys (pick one): @@ -123,6 +123,6 @@ Fix-it keys (pick one): - remove it from `tools.sandbox.tools.deny` (or per-agent `agents.list[].tools.sandbox.tools.deny`) - or add it to `tools.sandbox.tools.allow` (or per-agent allow) -### “I thought this was main, why is it sandboxed?” +### "I thought this was main, why is it sandboxed?" In `"non-main"` mode, group/channel keys are _not_ main. Use the main session key (shown by `sandbox explain`) or switch mode to `"off"`. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index b9f37597b58..8cea1b42766 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -840,7 +840,7 @@ Avoid: - Exposing relay/control ports over LAN or public Internet. - Tailscale Funnel for browser control endpoints (public exposure). -### 0.7) Secrets on disk (what’s sensitive) +### 0.7) Secrets on disk (sensitive data) Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain secrets or private data: diff --git a/docs/help/testing.md b/docs/help/testing.md index e2cae188c0e..2d7e9664176 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -176,7 +176,7 @@ Live tests are split into two layers so we can isolate failures: - Separates “provider API is broken / key is invalid” from “gateway agent pipeline is broken” - Contains small, isolated regressions (example: OpenAI Responses/Codex Responses reasoning replay + tool-call flows) -### Layer 2: Gateway + dev agent smoke (what “@openclaw” actually does) +### Layer 2: Gateway + dev agent smoke (what "@openclaw" actually does) - Test: `src/gateway/gateway-models.profiles.live.test.ts` - Goal: @@ -395,7 +395,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local - Optional auth behavior: - `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides -## Docker runners (optional “works in Linux” checks) +## Docker runners (optional "works in Linux" checks) These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present, then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: diff --git a/docs/install/updating.md b/docs/install/updating.md index dd3128c553e..0b88d91ed9e 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -268,7 +268,7 @@ git checkout main git pull ``` -## If you’re stuck +## If you are stuck - Run `openclaw doctor` again and read the output carefully (it often tells you the fix). - Check: [Troubleshooting](/gateway/troubleshooting) diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index 3178854ccfb..9d20c0c83d4 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -21,7 +21,7 @@ integration. - Support **provider APIs** and **CLI fallbacks**. - Allow multiple models with ordered fallback (error/size/timeout). -## High‑level behavior +## High-level behavior 1. Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`). 2. For each enabled capability (image/audio/video), select attachments per policy (default: **first**). @@ -334,7 +334,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc. } ``` -### 4) Multi‑modal single entry (explicit capabilities) +### 4) Multi-modal single entry (explicit capabilities) ```json5 { diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 982f687049c..0e7c058a934 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -97,7 +97,7 @@ If the gateway status stays on "Starting...", check if a zombie process is holdi openclaw gateway status openclaw gateway stop -# If you’re not using a LaunchAgent (dev mode / manual runs), find the listener: +# If you're not using a LaunchAgent (dev mode / manual runs), find the listener: lsof -nP -iTCP:18789 -sTCP:LISTEN ``` diff --git a/docs/platforms/mac/peekaboo.md b/docs/platforms/mac/peekaboo.md index d1947734735..96761a0ad74 100644 --- a/docs/platforms/mac/peekaboo.md +++ b/docs/platforms/mac/peekaboo.md @@ -13,7 +13,7 @@ OpenClaw can host **PeekabooBridge** as a local, permission‑aware UI automatio broker. This lets the `peekaboo` CLI drive UI automation while reusing the macOS app’s TCC permissions. -## What this is (and isn’t) +## What this is (and is not) - **Host**: OpenClaw.app can act as a PeekabooBridge host. - **Client**: use the `peekaboo` CLI (no separate `openclaw ui ...` surface). diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 6bc27203fae..bf8b23c35e4 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -26,7 +26,7 @@ agent (with a session switcher for other sessions). - Logs: `./scripts/clawlog.sh` (subsystem `ai.openclaw`, category `WebChatSwiftUI`). -## How it’s wired +## How it is wired - Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`, `chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`. diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md index f5d5d8cc3a8..8740fd51fa4 100644 --- a/docs/plugins/agent-tools.md +++ b/docs/plugins/agent-tools.md @@ -35,7 +35,7 @@ export default function (api) { } ``` -## Optional tool (opt‑in) +## Optional tool (opt-in) Optional tools are **never** auto‑enabled. Users must add them to an agent allowlist. diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index e6e3f807ee9..5fbed2b261f 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -12,7 +12,7 @@ OpenClaw can use **Amazon Bedrock** models via pi‑ai’s **Bedrock Converse** streaming provider. Bedrock auth uses the **AWS SDK default credential chain**, not an API key. -## What pi‑ai supports +## What pi-ai supports - Provider: `amazon-bedrock` - API: `bedrock-converse-stream` diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index c578a89d6e5..cc678349423 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -194,7 +194,7 @@ Use the interactive config wizard to set MiniMax without editing JSON: ## Troubleshooting -### “Unknown model: minimax/MiniMax-M2.5” +### "Unknown model: minimax/MiniMax-M2.5" This usually means the **MiniMax provider isn’t configured** (no provider entry and no MiniMax auth profile/env key found). A fix for this detection is in diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index d258eeb6722..02ff1115e4a 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -280,7 +280,7 @@ As of `2026.1.10`, OpenClaw also suppresses **draft/typing streaming** when a pa --- -## Pre-compaction “memory flush” (implemented) +## Pre-compaction "memory flush" (implemented) Goal: before auto-compaction happens, run a silent agentic turn that writes durable state to disk (e.g. `memory/YYYY-MM-DD.md` in the agent workspace) so compaction can’t diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index 671efe420c7..3bb0b454b25 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -102,7 +102,7 @@ If you already ship your own workspace files from a repo, you can disable bootst } ``` -## The config that turns it into “an assistant” +## The config that turns it into "an assistant" OpenClaw defaults to a good assistant setup, but you’ll usually want to tune: diff --git a/docs/start/setup.md b/docs/start/setup.md index 7e3ec6dfc2d..70da5578c08 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -27,7 +27,7 @@ Last updated: 2026-01-01 - `pnpm` - Docker (optional; only for containerized setup/e2e — see [Docker](/install/docker)) -## Tailoring strategy (so updates don’t hurt) +## Tailoring strategy (so updates do not hurt) If you want “100% tailored to me” _and_ easy updates, keep your customization in: diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index eed788eda8c..c10b955ce2d 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -17,7 +17,7 @@ title: "Elevated Mode" - Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. - Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. -## What it controls (and what it doesn’t) +## What it controls (and what it does not) - **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). - **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 438a3975e14..b3872c8ae67 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -2290,7 +2290,7 @@ Preferred setup split: - optional DM allowlist resolution (for example `@username` -> numeric id) - optional completion note after setup finishes -### Write a new messaging channel (step‑by‑step) +### Write a new messaging channel (step-by-step) Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. Model provider docs live under `/providers/*`. diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 86cd6fffd4e..71238e0b2bc 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -42,7 +42,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. - If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance. - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). -## If you see “unauthorized” / 1008 +## If you see "unauthorized" / 1008 - Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`). - For `AUTH_TOKEN_MISMATCH`, clients may do one trusted retry with a cached device token when the gateway returns retry hints. If auth still fails after that retry, resolve token drift manually. diff --git a/docs/zh-CN/start/hubs.md b/docs/zh-CN/start/hubs.md index c5dce882420..c0e1ed0851a 100644 --- a/docs/zh-CN/start/hubs.md +++ b/docs/zh-CN/start/hubs.md @@ -183,12 +183,6 @@ x-i18n: - [模板:TOOLS](/reference/templates/TOOLS) - [模板:USER](/reference/templates/USER) -## 实验(探索性) - -- [新手引导配置协议](/experiments/onboarding-config-protocol) -- [研究:记忆](/experiments/research/memory) -- [模型配置探索](/experiments/proposals/model-config) - ## 项目 - [致谢](/reference/credits) From 5625cf4724532ce1b0ab0847db838013ac2d92ae Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:33:04 -0700 Subject: [PATCH 384/393] fix(agents): correct broken docs/testing.md path in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 12a86185aaa..9bb22dafbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,7 @@ - Do not set test workers above 16; tried already. - If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. -- Full kit + what’s covered: `docs/testing.md`. +- Full kit + what’s covered: `docs/help/testing.md`. - Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). - Changelog placement: in the active version block, append new entries to the end of the target section (`### Changes` or `### Fixes`); do not insert new entries at the top of a section. - Changelog attribution: use at most one contributor mention per line; prefer `Thanks @author` and do not also add `by @author` on the same entry. From 7ac23ae7c2b6c788c2c2ca785777808ae9c4941e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:41:44 -0700 Subject: [PATCH 385/393] Plugins: fix bundled web search compat registry --- src/plugins/bundled-web-search.test.ts | 13 ++++++++++++ src/plugins/bundled-web-search.ts | 29 ++++++++++++++++++++++++++ src/plugins/web-search-providers.ts | 19 +++-------------- 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 src/plugins/bundled-web-search.test.ts create mode 100644 src/plugins/bundled-web-search.ts diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts new file mode 100644 index 00000000000..7db116a426f --- /dev/null +++ b/src/plugins/bundled-web-search.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; +import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js"; + +it("keeps bundled web search compat ids aligned with bundled manifests", () => { + expect(resolveBundledWebSearchPluginIds({})).toEqual([ + "brave", + "firecrawl", + "google", + "moonshot", + "perplexity", + "xai", + ]); +}); diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts new file mode 100644 index 00000000000..248928b093c --- /dev/null +++ b/src/plugins/bundled-web-search.ts @@ -0,0 +1,29 @@ +import type { PluginLoadOptions } from "./loader.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; + +export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [ + "brave", + "firecrawl", + "google", + "moonshot", + "perplexity", + "xai", +] as const; + +const bundledWebSearchPluginIdSet = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); + +export function resolveBundledWebSearchPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + return registry.plugins + .filter((plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id)) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 2cf44d9eac4..b415d7c7675 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -3,6 +3,7 @@ import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; +import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import { getActivePluginRegistry } from "./runtime.js"; @@ -41,25 +42,11 @@ function resolveBundledWebSearchCompatPluginIds(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; }): string[] { - const registry = loadOpenClawPlugins({ - config: { - ...params.config, - plugins: { - enabled: true, - }, - }, + return resolveBundledWebSearchPluginIds({ + config: params.config, workspaceDir: params.workspaceDir, env: params.env, - cache: false, - activate: false, - logger: createPluginLoaderLogger(log), }); - const bundledPluginIds = new Set( - registry.plugins.filter((plugin) => plugin.origin === "bundled").map((plugin) => plugin.id), - ); - return [...new Set(registry.webSearchProviders.map((entry) => entry.pluginId))] - .filter((pluginId) => bundledPluginIds.has(pluginId)) - .toSorted((left, right) => left.localeCompare(right)); } function withBundledWebSearchVitestCompat(params: { From 4ac9024de9cfcd58c80db45c782ac0750c9ed47b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:46:50 -0700 Subject: [PATCH 386/393] Contracts: harden plugin registry loading --- .../contracts/registry.contract.test.ts | 6 + src/plugins/contracts/registry.ts | 250 ++++++++---------- 2 files changed, 120 insertions(+), 136 deletions(-) diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 997aa560579..5c8d06785ce 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { + capabilityContractLoadError, imageGenerationProviderContractRegistry, mediaUnderstandingProviderContractRegistry, pluginRegistrationContractRegistry, @@ -85,6 +86,11 @@ function findRegistrationForPlugin(pluginId: string) { } describe("plugin contract registry", () => { + it("loads bundled non-provider capability registries without import-time failure", () => { + expect(capabilityContractLoadError).toBeUndefined(); + expect(pluginRegistrationContractRegistry.length).toBeGreaterThan(0); + }); + it("does not duplicate bundled provider ids", () => { const ids = providerContractRegistry.map((entry) => entry.provider.id); expect(ids).toEqual([...new Set(ids)]); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 142aa578b0f..acee90323b9 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,17 +1,8 @@ -import anthropicPlugin from "../../../extensions/anthropic/index.js"; -import bravePlugin from "../../../extensions/brave/index.js"; -import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; -import firecrawlPlugin from "../../../extensions/firecrawl/index.js"; -import googlePlugin from "../../../extensions/google/index.js"; -import microsoftPlugin from "../../../extensions/microsoft/index.js"; -import minimaxPlugin from "../../../extensions/minimax/index.js"; -import mistralPlugin from "../../../extensions/mistral/index.js"; -import moonshotPlugin from "../../../extensions/moonshot/index.js"; -import openAIPlugin from "../../../extensions/openai/index.js"; -import perplexityPlugin from "../../../extensions/perplexity/index.js"; -import xaiPlugin from "../../../extensions/xai/index.js"; -import zaiPlugin from "../../../extensions/zai/index.js"; -import { createCapturedPluginRegistration } from "../captured-registration.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { withBundledPluginEnablementCompat } from "../bundled-compat.js"; +import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js"; +import { loadOpenClawPlugins } from "../loader.js"; +import { createPluginLoaderLogger } from "../logger.js"; import { resolvePluginProviders } from "../providers.js"; import type { ImageGenerationProviderPlugin, @@ -21,11 +12,6 @@ import type { WebSearchProviderPlugin, } from "../types.js"; -type RegistrablePlugin = { - id: string; - register: (api: ReturnType["api"]) => void; -}; - type CapabilityContractEntry = { pluginId: string; provider: T; @@ -52,52 +38,30 @@ type PluginRegistrationContractEntry = { toolNames: string[]; }; -const bundledWebSearchPlugins: Array = [ - { ...bravePlugin, credentialValue: "BSA-test" }, - { ...firecrawlPlugin, credentialValue: "fc-test" }, - { ...googlePlugin, credentialValue: "AIza-test" }, - { ...moonshotPlugin, credentialValue: "sk-test" }, - { ...perplexityPlugin, credentialValue: "pplx-test" }, - { ...xaiPlugin, credentialValue: "xai-test" }, -]; +const log = createSubsystemLogger("plugins"); -const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin]; +const BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES: Readonly> = { + brave: "BSA-test", + firecrawl: "fc-test", + google: "AIza-test", + moonshot: "sk-test", + perplexity: "pplx-test", + xai: "xai-test", +}; -const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [ - anthropicPlugin, - googlePlugin, - minimaxPlugin, - mistralPlugin, - moonshotPlugin, - openAIPlugin, - zaiPlugin, -]; +const BUNDLED_SPEECH_PLUGIN_IDS = ["elevenlabs", "microsoft", "openai"] as const; +const BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS = [ + "anthropic", + "google", + "minimax", + "mistral", + "moonshot", + "openai", + "zai", +] as const; +const BUNDLED_IMAGE_GENERATION_PLUGIN_IDS = ["google", "openai"] as const; -const bundledImageGenerationPlugins: RegistrablePlugin[] = [googlePlugin, openAIPlugin]; - -function captureRegistrations(plugin: RegistrablePlugin) { - const captured = createCapturedPluginRegistration(); - plugin.register(captured.api); - return captured; -} - -function buildCapabilityContractRegistry(params: { - plugins: RegistrablePlugin[]; - select: (captured: ReturnType) => T[]; -}): CapabilityContractEntry[] { - return params.plugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return params.select(captured).map((provider) => ({ - pluginId: plugin.id, - provider, - })); - }); -} - -export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({ - plugins: [], - select: () => [], -}); +export const providerContractRegistry: ProviderContractEntry[] = []; export let providerContractLoadError: Error | undefined; @@ -143,6 +107,55 @@ export const providerContractCompatPluginIds = providerContractPluginIds.map((pl pluginId === "kimi-coding" ? "kimi" : pluginId, ); +const bundledCapabilityContractPluginIds = [ + ...new Set([ + ...providerContractCompatPluginIds, + ...resolveBundledWebSearchPluginIds({}), + ...BUNDLED_SPEECH_PLUGIN_IDS, + ...BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS, + ...BUNDLED_IMAGE_GENERATION_PLUGIN_IDS, + ]), +].toSorted((left, right) => left.localeCompare(right)); + +export let capabilityContractLoadError: Error | undefined; + +function loadBundledCapabilityRegistry() { + try { + capabilityContractLoadError = undefined; + return loadOpenClawPlugins({ + config: withBundledPluginEnablementCompat({ + config: { + plugins: { + enabled: true, + allow: bundledCapabilityContractPluginIds, + slots: { + memory: "none", + }, + }, + }, + pluginIds: bundledCapabilityContractPluginIds, + }), + cache: false, + activate: false, + logger: createPluginLoaderLogger(log), + }); + } catch (error) { + capabilityContractLoadError = error instanceof Error ? error : new Error(String(error)); + return loadOpenClawPlugins({ + config: { + plugins: { + enabled: false, + }, + }, + cache: false, + activate: false, + logger: createPluginLoaderLogger(log), + }); + } +} + +const loadedBundledCapabilityRegistry = loadBundledCapabilityRegistry(); + export function requireProviderContractProvider(providerId: string): ProviderPlugin { const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId); if (!provider) { @@ -183,85 +196,50 @@ export function resolveProviderContractProvidersForPluginIds( } export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] = - bundledWebSearchPlugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return captured.webSearchProviders.map((provider) => ({ - pluginId: plugin.id, - provider, - credentialValue: plugin.credentialValue, + loadedBundledCapabilityRegistry.webSearchProviders + .filter((entry) => entry.pluginId in BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES) + .map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES[entry.pluginId], })); - }); export const speechProviderContractRegistry: SpeechProviderContractEntry[] = - buildCapabilityContractRegistry({ - plugins: bundledSpeechPlugins, - select: (captured) => captured.speechProviders, - }); + loadedBundledCapabilityRegistry.speechProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProviderContractEntry[] = - buildCapabilityContractRegistry({ - plugins: bundledMediaUnderstandingPlugins, - select: (captured) => captured.mediaUnderstandingProviders, - }); + loadedBundledCapabilityRegistry.mediaUnderstandingProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); export const imageGenerationProviderContractRegistry: ImageGenerationProviderContractEntry[] = - buildCapabilityContractRegistry({ - plugins: bundledImageGenerationPlugins, - select: (captured) => captured.imageGenerationProviders, - }); + loadedBundledCapabilityRegistry.imageGenerationProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); -const bundledPluginRegistrationList = [ - ...new Map( - [ - ...bundledSpeechPlugins, - ...bundledMediaUnderstandingPlugins, - ...bundledImageGenerationPlugins, - ...bundledWebSearchPlugins, - ].map((plugin) => [plugin.id, plugin]), - ).values(), -]; - -export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = [ - ...new Map( - providerContractRegistry.map((entry) => [ - entry.pluginId, - { - pluginId: entry.pluginId, - providerIds: providerContractRegistry - .filter((candidate) => candidate.pluginId === entry.pluginId) - .map((candidate) => candidate.provider.id), - speechProviderIds: [] as string[], - mediaUnderstandingProviderIds: [] as string[], - imageGenerationProviderIds: [] as string[], - webSearchProviderIds: [] as string[], - toolNames: [] as string[], - }, - ]), - ).values(), -]; - -for (const plugin of bundledPluginRegistrationList) { - const captured = captureRegistrations(plugin); - const existing = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === plugin.id); - const next = { - pluginId: plugin.id, - providerIds: captured.providers.map((provider) => provider.id), - speechProviderIds: captured.speechProviders.map((provider) => provider.id), - mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( - (provider) => provider.id, - ), - imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id), - webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), - toolNames: captured.tools.map((tool) => tool.name), - }; - if (!existing) { - pluginRegistrationContractRegistry.push(next); - continue; - } - existing.providerIds = next.providerIds.length > 0 ? next.providerIds : existing.providerIds; - existing.speechProviderIds = next.speechProviderIds; - existing.mediaUnderstandingProviderIds = next.mediaUnderstandingProviderIds; - existing.imageGenerationProviderIds = next.imageGenerationProviderIds; - existing.webSearchProviderIds = next.webSearchProviderIds; - existing.toolNames = next.toolNames; -} +export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = + loadedBundledCapabilityRegistry.plugins + .filter( + (plugin) => + plugin.origin === "bundled" && + (plugin.providerIds.length > 0 || + plugin.speechProviderIds.length > 0 || + plugin.mediaUnderstandingProviderIds.length > 0 || + plugin.imageGenerationProviderIds.length > 0 || + plugin.webSearchProviderIds.length > 0 || + plugin.toolNames.length > 0), + ) + .map((plugin) => ({ + pluginId: plugin.id, + providerIds: plugin.providerIds, + speechProviderIds: plugin.speechProviderIds, + mediaUnderstandingProviderIds: plugin.mediaUnderstandingProviderIds, + imageGenerationProviderIds: plugin.imageGenerationProviderIds, + webSearchProviderIds: plugin.webSearchProviderIds, + toolNames: plugin.toolNames, + })); From 61a19107e1b8939078351ab60ecbe54ee3b958b9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:49:47 -0700 Subject: [PATCH 387/393] Tlon: install api from tarball artifact --- extensions/tlon/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 071280374a3..f909834f1c6 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { - "@tloncorp/api": "github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87", + "@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87", "@tloncorp/tlon-skill": "0.2.2", "@urbit/aura": "^3.0.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fb25b899d8..1439fa6b2a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -530,7 +530,7 @@ importers: extensions/tlon: dependencies: '@tloncorp/api': - specifier: github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87 + specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 '@tloncorp/tlon-skill': specifier: 0.2.2 From 2f3bc89f4fac96fa01d66e37fc3207e864906c52 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:53:14 -0700 Subject: [PATCH 388/393] Config: align model compat thinking format schema --- src/config/zod-schema.core.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 22c589c8490..25ef5d54346 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -192,14 +192,7 @@ export const ModelCompatSchema = z maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), - thinkingFormat: z - .union([ - z.literal("openai"), - z.literal("zai"), - z.literal("qwen"), - z.literal("qwen-chat-template"), - ]) - .optional(), + thinkingFormat: z.union([z.literal("openai"), z.literal("zai"), z.literal("qwen")]).optional(), requiresToolResultName: z.boolean().optional(), requiresAssistantAfterToolResult: z.boolean().optional(), requiresThinkingAsText: z.boolean().optional(), From 1040ae56b5034fe6e9bb03ad452744dd5aaaea10 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:53:16 -0700 Subject: [PATCH 389/393] Telegram: fix reply-runtime test typings --- .../src/bot.create-telegram-bot.test.ts | 13 +++++-- .../telegram/src/bot.media.e2e-harness.ts | 36 ++++++++++++++----- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index d0df14e7cf6..7fbab89cdab 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -59,6 +59,7 @@ const TELEGRAM_TEST_TIMINGS = { mediaGroupFlushMs: 20, textFragmentGapMs: 30, } as const; +const EMPTY_REPLY_COUNTS = { block: 0, final: 0, tool: 0 } as const; describe("createTelegramBot", () => { beforeAll(() => { @@ -388,7 +389,7 @@ describe("createTelegramBot", () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( async ({ dispatcherOptions }) => { await dispatcherOptions.typingCallbacks?.onReplyStart?.(); - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; }, ); createTelegramBot({ token: "tok" }); @@ -1463,7 +1464,7 @@ describe("createTelegramBot", () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { dispatchCall = params as typeof dispatchCall; await params.dispatcherOptions.typingCallbacks?.onReplyStart?.(); - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; }); loadConfig.mockReturnValue({ channels: { @@ -1479,6 +1480,9 @@ describe("createTelegramBot", () => { const payload = dispatchCall?.ctx; if (testCase.assertTopicMetadata) { + if (!payload) { + throw new Error("Expected forum dispatch payload"); + } expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); expect(payload.MessageThreadId).toBe(99); @@ -1790,7 +1794,7 @@ describe("createTelegramBot", () => { | undefined; dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { dispatchCall = params as typeof dispatchCall; - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; }); loadConfig.mockReturnValue({ channels: { @@ -1819,6 +1823,9 @@ describe("createTelegramBot", () => { await handler(makeForumGroupMessageCtx({ threadId: 99 })); const payload = dispatchCall?.ctx; + if (!payload) { + throw new Error("Expected topic dispatch payload"); + } expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); expect(dispatchCall?.replyOptions?.skillFilter).toEqual([]); }); diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index 3dbd8634ab1..56af46fc304 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,7 +1,14 @@ import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; -import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; +import { + resetInboundDedupe, + type GetReplyOptions, + type MsgContext, + type ReplyPayload, +} from "openclaw/plugin-sdk/reply-runtime"; import { beforeEach, vi, type Mock } from "vitest"; +import type { TelegramBotDeps } from "./bot-deps.js"; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); @@ -97,7 +104,11 @@ const apiStub: ApiStub = { setMyCommands: vi.fn(async () => undefined), }; -export const telegramBotRuntimeForTest = { +export const telegramBotRuntimeForTest: { + Bot: new (token: string) => unknown; + sequentialize: () => unknown; + apiThrottler: () => unknown; +} = { Bot: class { api = apiStub; use = middlewareUseSpy; @@ -111,7 +122,13 @@ export const telegramBotRuntimeForTest = { apiThrottler: () => throttlerSpy(), }; -const mediaHarnessReplySpy = vi.hoisted(() => vi.fn(async () => undefined)); +type MediaHarnessReplyFn = ( + ctx: MsgContext, + opts?: GetReplyOptions, + configOverride?: OpenClawConfig, +) => Promise; + +const mediaHarnessReplySpy = vi.hoisted(() => vi.fn(async () => undefined)); type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyHarnessParams = Parameters[0]; @@ -121,8 +138,11 @@ let actualDispatchReplyWithBufferedBlockDispatcherPromise: | undefined; async function getActualDispatchReplyWithBufferedBlockDispatcher() { - actualDispatchReplyWithBufferedBlockDispatcherPromise ??= - import("../../../src/auto-reply/reply/provider-dispatcher.js").then( + actualDispatchReplyWithBufferedBlockDispatcherPromise ??= vi + .importActual( + "openclaw/plugin-sdk/reply-runtime", + ) + .then( (module) => module.dispatchReplyWithBufferedBlockDispatcher as DispatchReplyWithBufferedBlockDispatcherFn, ); @@ -136,9 +156,9 @@ async function dispatchReplyWithBufferedBlockDispatcherViaActual( await getActualDispatchReplyWithBufferedBlockDispatcher(); return await actualDispatchReplyWithBufferedBlockDispatcher({ ...params, - replyResolver: async (ctx, _cfg, opts) => { + replyResolver: async (ctx, opts, configOverride) => { await opts?.onReplyStart?.(); - return await mediaHarnessReplySpy(ctx, opts); + return await mediaHarnessReplySpy(ctx, opts, configOverride as OpenClawConfig | undefined); }, }); } @@ -148,7 +168,7 @@ const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => dispatchReplyWithBufferedBlockDispatcherViaActual, ), ); -export const telegramBotDepsForTest = { +export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig: () => ({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }), From 1890089f49944b2940183dac212e69b4dfafc285 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Wed, 18 Mar 2026 01:56:28 -0700 Subject: [PATCH 390/393] fix: serialize duplicate channel starts (#49583) (thanks @sudie-codes) --- CHANGELOG.md | 1 + src/gateway/server-channels.test.ts | 56 ++++++ src/gateway/server-channels.ts | 254 ++++++++++++++++------------ 3 files changed, 204 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d99a6fdcff..471970d48d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. - Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. - Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007. +- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes. ### Fixes diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 2e886962d33..01dd6aa17d3 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -45,6 +45,7 @@ function createTestPlugin(params?: { startAccount?: NonNullable["gateway"]>["startAccount"]; includeDescribeAccount?: boolean; resolveAccount?: ChannelPlugin["config"]["resolveAccount"]; + isConfigured?: ChannelPlugin["config"]["isConfigured"]; }): ChannelPlugin { const account = params?.account ?? { enabled: true, configured: true }; const includeDescribeAccount = params?.includeDescribeAccount !== false; @@ -52,6 +53,7 @@ function createTestPlugin(params?: { listAccountIds: () => [DEFAULT_ACCOUNT_ID], resolveAccount: params?.resolveAccount ?? (() => account), isEnabled: (resolved) => resolved.enabled !== false, + ...(params?.isConfigured ? { isConfigured: params.isConfigured } : {}), }; if (includeDescribeAccount) { config.describeAccount = (resolved) => ({ @@ -79,6 +81,14 @@ function createTestPlugin(params?: { }; } +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolvePromise = () => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} + function installTestRegistry(plugin: ChannelPlugin) { const registry = createEmptyPluginRegistry(); registry.channels.push({ @@ -189,6 +199,52 @@ describe("server-channels auto restart", () => { expect(startAccount).toHaveBeenCalledTimes(1); }); + it("deduplicates concurrent start requests for the same account", async () => { + const startupGate = createDeferred(); + const isConfigured = vi.fn(async () => { + await startupGate.promise; + return true; + }); + const startAccount = vi.fn(async () => {}); + + installTestRegistry(createTestPlugin({ startAccount, isConfigured })); + const manager = createManager(); + + const firstStart = manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + const secondStart = manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + + await Promise.resolve(); + expect(isConfigured).toHaveBeenCalledTimes(1); + expect(startAccount).not.toHaveBeenCalled(); + + startupGate.resolve(); + await Promise.all([firstStart, secondStart]); + + expect(startAccount).toHaveBeenCalledTimes(1); + }); + + it("cancels a pending startup when the account is stopped mid-boot", async () => { + const startupGate = createDeferred(); + const isConfigured = vi.fn(async () => { + await startupGate.promise; + return true; + }); + const startAccount = vi.fn(async () => {}); + + installTestRegistry(createTestPlugin({ startAccount, isConfigured })); + const manager = createManager(); + + const startTask = manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + await Promise.resolve(); + + const stopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID); + startupGate.resolve(); + + await Promise.all([startTask, stopTask]); + + expect(startAccount).not.toHaveBeenCalled(); + }); + it("does not resolve channelRuntime until a channel starts", async () => { const channelRuntime = { marker: "lazy-channel-runtime", diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index a016826f69b..16cad24b07d 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -32,6 +32,7 @@ type SubsystemLogger = ReturnType; type ChannelRuntimeStore = { aborts: Map; + starting: Map>; tasks: Map>; runtimes: Map; }; @@ -49,6 +50,7 @@ type ChannelHealthMonitorConfig = HealthMonitorConfig & { function createRuntimeStore(): ChannelRuntimeStore { return { aborts: new Map(), + starting: new Map(), tasks: new Map(), runtimes: new Map(), }; @@ -256,137 +258,174 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (store.tasks.has(id)) { return; } - const account = plugin.config.resolveAccount(cfg, id); - const enabled = plugin.config.isEnabled - ? plugin.config.isEnabled(account, cfg) - : isAccountEnabled(account); - if (!enabled) { - setRuntime(channelId, id, { - accountId: id, - enabled: false, - configured: true, - running: false, - restartPending: false, - lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled", - }); + const existingStart = store.starting.get(id); + if (existingStart) { + await existingStart; return; } - let configured = true; - if (plugin.config.isConfigured) { - configured = await plugin.config.isConfigured(account, cfg); - } - if (!configured) { - setRuntime(channelId, id, { - accountId: id, - enabled: true, - configured: false, - running: false, - restartPending: false, - lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured", - }); - return; - } - - const rKey = restartKey(channelId, id); - if (!preserveManualStop) { - manuallyStopped.delete(rKey); - } + let resolveStart: (() => void) | undefined; + const startGate = new Promise((resolve) => { + resolveStart = resolve; + }); + store.starting.set(id, startGate); + // Reserve the account before the first await so overlapping start calls + // cannot race into duplicate provider boots for the same account. const abort = new AbortController(); store.aborts.set(id, abort); - if (!preserveRestartAttempts) { - restartAttempts.delete(rKey); - } - setRuntime(channelId, id, { - accountId: id, - enabled: true, - configured: true, - running: true, - restartPending: false, - lastStartAt: Date.now(), - lastError: null, - reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0, - }); + let handedOffTask = false; - const log = channelLogs[channelId]; - const resolvedChannelRuntime = getChannelRuntime(); - const task = startAccount({ - cfg, - accountId: id, - account, - runtime: channelRuntimeEnvs[channelId], - abortSignal: abort.signal, - log, - getStatus: () => getRuntime(channelId, id), - setStatus: (next) => setRuntime(channelId, id, next), - ...(resolvedChannelRuntime ? { channelRuntime: resolvedChannelRuntime } : {}), - }); - const trackedPromise = Promise.resolve(task) - .catch((err) => { - const message = formatErrorMessage(err); - setRuntime(channelId, id, { accountId: id, lastError: message }); - log.error?.(`[${id}] channel exited: ${message}`); - }) - .finally(() => { + try { + const account = plugin.config.resolveAccount(cfg, id); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : isAccountEnabled(account); + if (!enabled) { + setRuntime(channelId, id, { + accountId: id, + enabled: false, + configured: true, + running: false, + restartPending: false, + lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled", + }); + return; + } + + let configured = true; + if (plugin.config.isConfigured) { + configured = await plugin.config.isConfigured(account, cfg); + } + if (!configured) { + setRuntime(channelId, id, { + accountId: id, + enabled: true, + configured: false, + running: false, + restartPending: false, + lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured", + }); + return; + } + + const rKey = restartKey(channelId, id); + if (!preserveManualStop) { + manuallyStopped.delete(rKey); + } + + if (abort.signal.aborted || manuallyStopped.has(rKey)) { setRuntime(channelId, id, { accountId: id, running: false, + restartPending: false, lastStopAt: Date.now(), }); - }) - .then(async () => { - if (manuallyStopped.has(rKey)) { - return; - } - const attempt = (restartAttempts.get(rKey) ?? 0) + 1; - restartAttempts.set(rKey, attempt); - if (attempt > MAX_RESTART_ATTEMPTS) { + return; + } + + if (!preserveRestartAttempts) { + restartAttempts.delete(rKey); + } + setRuntime(channelId, id, { + accountId: id, + enabled: true, + configured: true, + running: true, + restartPending: false, + lastStartAt: Date.now(), + lastError: null, + reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0, + }); + + const log = channelLogs[channelId]; + const resolvedChannelRuntime = getChannelRuntime(); + const task = startAccount({ + cfg, + accountId: id, + account, + runtime: channelRuntimeEnvs[channelId], + abortSignal: abort.signal, + log, + getStatus: () => getRuntime(channelId, id), + setStatus: (next) => setRuntime(channelId, id, next), + ...(resolvedChannelRuntime ? { channelRuntime: resolvedChannelRuntime } : {}), + }); + const trackedPromise = Promise.resolve(task) + .catch((err) => { + const message = formatErrorMessage(err); + setRuntime(channelId, id, { accountId: id, lastError: message }); + log.error?.(`[${id}] channel exited: ${message}`); + }) + .finally(() => { setRuntime(channelId, id, { accountId: id, - restartPending: false, - reconnectAttempts: attempt, + running: false, + lastStopAt: Date.now(), }); - log.error?.(`[${id}] giving up after ${MAX_RESTART_ATTEMPTS} restart attempts`); - return; - } - const delayMs = computeBackoff(CHANNEL_RESTART_POLICY, attempt); - log.info?.( - `[${id}] auto-restart attempt ${attempt}/${MAX_RESTART_ATTEMPTS} in ${Math.round(delayMs / 1000)}s`, - ); - setRuntime(channelId, id, { - accountId: id, - restartPending: true, - reconnectAttempts: attempt, - }); - try { - await sleepWithAbort(delayMs, abort.signal); + }) + .then(async () => { if (manuallyStopped.has(rKey)) { return; } + const attempt = (restartAttempts.get(rKey) ?? 0) + 1; + restartAttempts.set(rKey, attempt); + if (attempt > MAX_RESTART_ATTEMPTS) { + setRuntime(channelId, id, { + accountId: id, + restartPending: false, + reconnectAttempts: attempt, + }); + log.error?.(`[${id}] giving up after ${MAX_RESTART_ATTEMPTS} restart attempts`); + return; + } + const delayMs = computeBackoff(CHANNEL_RESTART_POLICY, attempt); + log.info?.( + `[${id}] auto-restart attempt ${attempt}/${MAX_RESTART_ATTEMPTS} in ${Math.round(delayMs / 1000)}s`, + ); + setRuntime(channelId, id, { + accountId: id, + restartPending: true, + reconnectAttempts: attempt, + }); + try { + await sleepWithAbort(delayMs, abort.signal); + if (manuallyStopped.has(rKey)) { + return; + } + if (store.tasks.get(id) === trackedPromise) { + store.tasks.delete(id); + } + if (store.aborts.get(id) === abort) { + store.aborts.delete(id); + } + await startChannelInternal(channelId, id, { + preserveRestartAttempts: true, + preserveManualStop: true, + }); + } catch { + // abort or startup failure — next crash will retry + } + }) + .finally(() => { if (store.tasks.get(id) === trackedPromise) { store.tasks.delete(id); } if (store.aborts.get(id) === abort) { store.aborts.delete(id); } - await startChannelInternal(channelId, id, { - preserveRestartAttempts: true, - preserveManualStop: true, - }); - } catch { - // abort or startup failure — next crash will retry - } - }) - .finally(() => { - if (store.tasks.get(id) === trackedPromise) { - store.tasks.delete(id); - } - if (store.aborts.get(id) === abort) { - store.aborts.delete(id); - } - }); - store.tasks.set(id, trackedPromise); + }); + handedOffTask = true; + store.tasks.set(id, trackedPromise); + } finally { + resolveStart?.(); + if (store.starting.get(id) === startGate) { + store.starting.delete(id); + } + if (!handedOffTask && store.aborts.get(id) === abort) { + store.aborts.delete(id); + } + } }), ); }; @@ -405,6 +444,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const cfg = loadConfig(); const knownIds = new Set([ ...store.aborts.keys(), + ...store.starting.keys(), ...store.tasks.keys(), ...(plugin ? plugin.config.listAccountIds(cfg) : []), ]); From d8a1ad0f0d5c00138ebb7742eebf4ad7958b0eaf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:03:47 -0700 Subject: [PATCH 391/393] Plugin SDK: split provider auth login seam --- extensions/chutes/index.ts | 2 +- extensions/github-copilot/index.ts | 3 ++- extensions/openai/openai-codex-provider.ts | 2 +- package.json | 4 ++++ src/plugin-sdk/provider-auth-login.ts | 5 +++++ src/plugin-sdk/provider-auth.ts | 3 --- src/plugins/contracts/auth.contract.test.ts | 8 ++++---- 7 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 src/plugin-sdk/provider-auth-login.ts diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts index a61cd4ec93f..b715ad46c5a 100644 --- a/extensions/chutes/index.ts +++ b/extensions/chutes/index.ts @@ -2,11 +2,11 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildOauthProviderAuthResult, createProviderApiKeyAuthMethod, - loginChutes, resolveOAuthApiKeyMarker, type ProviderAuthContext, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; +import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login"; import { CHUTES_DEFAULT_MODEL_REF, applyChutesApiKeyConfig, diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 39116636b76..633ff274f82 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -5,7 +5,8 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { coerceSecretRef, githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth"; +import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth"; +import { githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth-login"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; import { fetchCopilotUsage } from "./usage.js"; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 5714b09a7d0..cb8d6d2519c 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -9,9 +9,9 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, - loginOpenAICodexOAuth, type OAuthCredential, } from "openclaw/plugin-sdk/provider-auth"; +import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login"; import { DEFAULT_CONTEXT_TOKENS, normalizeModelCompat, diff --git a/package.json b/package.json index 09a8c047869..a181861c2ae 100644 --- a/package.json +++ b/package.json @@ -414,6 +414,10 @@ "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" }, + "./plugin-sdk/provider-auth-login": { + "types": "./dist/plugin-sdk/provider-auth-login.d.ts", + "default": "./dist/plugin-sdk/provider-auth-login.js" + }, "./plugin-sdk/provider-catalog": { "types": "./dist/plugin-sdk/provider-catalog.d.ts", "default": "./dist/plugin-sdk/provider-catalog.js" diff --git a/src/plugin-sdk/provider-auth-login.ts b/src/plugin-sdk/provider-auth-login.ts new file mode 100644 index 00000000000..4d6f55902ab --- /dev/null +++ b/src/plugin-sdk/provider-auth-login.ts @@ -0,0 +1,5 @@ +// Public interactive auth/login helpers for provider plugins. + +export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { loginChutes } from "../commands/chutes-oauth.js"; +export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 84373befb88..645073a4d02 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -36,9 +36,6 @@ export { validateAnthropicSetupToken, } from "../plugins/provider-auth-token.js"; export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; -export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; -export { loginChutes } from "../commands/chutes-oauth.js"; -export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef } from "../config/types.secrets.js"; export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index 355ceb43962..92b6cd11fea 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -14,11 +14,11 @@ import type { import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; type LoginOpenAICodexOAuth = - (typeof import("openclaw/plugin-sdk/provider-auth"))["loginOpenAICodexOAuth"]; + (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; type LoginQwenPortalOAuth = (typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"]; type GithubCopilotLoginCommand = - (typeof import("openclaw/plugin-sdk/provider-auth"))["githubCopilotLoginCommand"]; + (typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = (typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; @@ -26,8 +26,8 @@ const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn( const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); -vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, From afad0697aabe7622bb13f9a632d3716e9f1076f8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:06:06 -0700 Subject: [PATCH 392/393] Plugin SDK: register provider auth login entrypoint --- scripts/lib/plugin-sdk-entrypoints.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 288fefb7fd0..7378f3b4d9d 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -93,6 +93,7 @@ "json-store", "keyed-async-queue", "provider-auth", + "provider-auth-login", "provider-catalog", "provider-models", "provider-onboard", From 93a31b69de9b052b04b5490b4535badb82867032 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 14:54:38 +0530 Subject: [PATCH 393/393] fix(config): add missing qwen-chat-template to thinking format schema --- src/config/zod-schema.core.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 25ef5d54346..22c589c8490 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -192,7 +192,14 @@ export const ModelCompatSchema = z maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), - thinkingFormat: z.union([z.literal("openai"), z.literal("zai"), z.literal("qwen")]).optional(), + thinkingFormat: z + .union([ + z.literal("openai"), + z.literal("zai"), + z.literal("qwen"), + z.literal("qwen-chat-template"), + ]) + .optional(), requiresToolResultName: z.boolean().optional(), requiresAssistantAfterToolResult: z.boolean().optional(), requiresThinkingAsText: z.boolean().optional(),