From 5dd1e264eb3ddefbffbe825788f7b8e87493e73d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 13:46:23 +0100 Subject: [PATCH] refactor(config): tighten plugin config guardrails --- docs/plugins/compatibility.md | 3 +- docs/plugins/sdk-migration.md | 15 +- docs/plugins/sdk-runtime.md | 2 +- .../check-no-runtime-action-load-config.mjs | 94 +---- scripts/lib/config-boundary-guard.d.mts | 5 + scripts/lib/config-boundary-guard.mjs | 399 ++++++++++++++++++ scripts/lib/deprecated-config-api-guard.d.mts | 4 +- scripts/lib/deprecated-config-api-guard.mjs | 326 +------------- .../server-methods/config-write-flow.ts | 221 ++++++++++ src/gateway/server-methods/config.ts | 225 +--------- src/media-understanding/defaults.ts | 27 +- src/plugins/compat/registry.test.ts | 5 + src/plugins/compat/registry.ts | 23 + .../contracts/config-boundary-guard.test.ts | 78 ++++ src/plugins/runtime/runtime-config.test.ts | 5 +- src/plugins/runtime/runtime-config.ts | 6 +- 16 files changed, 789 insertions(+), 649 deletions(-) create mode 100644 scripts/lib/config-boundary-guard.d.mts create mode 100644 scripts/lib/config-boundary-guard.mjs create mode 100644 src/gateway/server-methods/config-write-flow.ts create mode 100644 src/plugins/contracts/config-boundary-guard.test.ts diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index 579c4572cbc..fe336e65398 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -105,7 +105,8 @@ Current compatibility records include: - legacy provider plugin hooks and type aliases while providers move to explicit catalog, auth, thinking, replay, and transport hooks - legacy runtime aliases such as `api.runtime.taskFlow`, - `api.runtime.subagent.getSession`, and `api.runtime.stt` + `api.runtime.subagent.getSession`, `api.runtime.stt`, and deprecated + `api.runtime.config.loadConfig()` / `api.runtime.config.writeConfigFile(...)` - legacy memory-plugin split registration while memory plugins move to `registerMemoryCapability` - legacy channel SDK helpers for native message schemas, mention gating, diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 851436cdf66..49cb030080f 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -119,12 +119,15 @@ releases. Mutation results include a typed `followUp` summary for tests and logging; the gateway remains responsible for applying or scheduling the restart. `loadConfig` and `writeConfigFile` remain as deprecated compatibility - helpers for external plugins during the migration window and warn once when - called. Bundled plugins and repo runtime code are protected by scanner - guardrails in `pnpm check:deprecated-internal-config-api`: new production - plugin usage fails outright, direct config writes fail, gateway server - methods must use the request runtime snapshot, and long-lived runtime - modules have zero allowed ambient `loadConfig()` calls. + helpers for external plugins during the migration window and warn once with + the `runtime-config-load-write` compatibility code. Bundled plugins and repo + runtime code are protected by scanner guardrails in + `pnpm check:deprecated-internal-config-api` and + `pnpm check:no-runtime-action-load-config`: new production plugin usage + fails outright, direct config writes fail, gateway server methods must use + the request runtime snapshot, runtime channel send/action/client helpers + must receive config from their boundary, and long-lived runtime modules have + zero allowed ambient `loadConfig()` calls. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 3c1571f4376..2412111c798 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -41,7 +41,7 @@ Persist changes with `api.runtime.config.mutateConfigFile(...)` or `api.runtime. The mutation helpers return `afterWrite` plus a typed `followUp` summary so callers can log or test whether they requested a restart. The gateway still owns when that restart actually happens. -`api.runtime.config.loadConfig()` and `api.runtime.config.writeConfigFile(...)` are deprecated compatibility helpers. They warn once at runtime, and bundled plugins must not use them; the architecture guard fails if production plugin code calls them or imports those helpers from plugin SDK subpaths. +`api.runtime.config.loadConfig()` and `api.runtime.config.writeConfigFile(...)` are deprecated compatibility helpers under `runtime-config-load-write`. They warn once at runtime, and bundled plugins must not use them; the config boundary guards fail if production plugin code calls them or imports those helpers from plugin SDK subpaths. Internal OpenClaw runtime code has the same direction: load config once at the CLI, gateway, or process boundary, then pass that value through. Successful mutation writes refresh the process runtime snapshot and advance its internal revision; long-lived caches should key off the runtime-owned cache key instead of serializing config locally. Long-lived runtime modules have a zero-tolerance scanner for ambient `loadConfig()` calls; use a passed `cfg`, a request `context.getRuntimeConfig()`, or `getRuntimeConfig()` at an explicit process boundary. diff --git a/scripts/check-no-runtime-action-load-config.mjs b/scripts/check-no-runtime-action-load-config.mjs index 9feeb709b4d..57a15a69a6e 100644 --- a/scripts/check-no-runtime-action-load-config.mjs +++ b/scripts/check-no-runtime-action-load-config.mjs @@ -1,94 +1,10 @@ #!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; - -const repoRoot = path.resolve(new URL("..", import.meta.url).pathname); - -const CHANNEL_EXTENSION_IDS = new Set([ - "discord", - "imessage", - "irc", - "line", - "matrix", - "mattermost", - "nextcloud-talk", - "signal", - "slack", - "telegram", - "whatsapp", -]); - -const HELPER_BASENAME_PATTERNS = [ - /^action-runtime\.ts$/, - /^actions(?:\..*)?\.ts$/, - /^active-listener\.ts$/, - /^access-control\.ts$/, - /^channel\.ts$/, - /^client(?:[-.].*)?\.ts$/, - /^recipient-resolution\.ts$/, - /^rich-menu\.ts$/, - /^send(?:[-.].*)?\.ts$/, - /^sent-message-cache\.ts$/, - /^thread-bindings\.ts$/, -]; - -const FORBIDDEN_PATTERNS = [/\bloadConfig\s*\(/, /\.config\.loadConfig\s*\(/]; - -function* walk(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === "dist") { - continue; - } - const absolute = path.join(dir, entry.name); - if (entry.isDirectory()) { - yield* walk(absolute); - continue; - } - yield absolute; - } -} - -function isCandidate(relativePath) { - const parts = relativePath.split(path.sep); - if (parts[0] !== "extensions" || parts[2] !== "src") { - return false; - } - if (!CHANNEL_EXTENSION_IDS.has(parts[1])) { - return false; - } - if ( - relativePath.endsWith(".test.ts") || - relativePath.endsWith(".test-harness.ts") || - relativePath.endsWith(".d.ts") - ) { - return false; - } - if (parts.includes("monitor") || parts.includes("cli")) { - return false; - } - if (parts.includes("actions")) { - return true; - } - const basename = path.basename(relativePath); - return HELPER_BASENAME_PATTERNS.some((pattern) => pattern.test(basename)); -} +import { collectRuntimeActionLoadConfigViolations } from "./lib/config-boundary-guard.mjs"; function main() { - const violations = []; - for (const absolute of walk(path.join(repoRoot, "extensions"))) { - const relativePath = path.relative(repoRoot, absolute); - if (!isCandidate(relativePath)) { - continue; - } - const lines = fs.readFileSync(absolute, "utf8").split(/\r?\n/); - lines.forEach((line, index) => { - if (FORBIDDEN_PATTERNS.some((pattern) => pattern.test(line))) { - violations.push(`${relativePath}:${index + 1}: ${line.trim()}`); - } - }); - } + const violations = collectRuntimeActionLoadConfigViolations(); if (violations.length === 0) { - return; + return 0; } console.error( [ @@ -98,7 +14,7 @@ function main() { ...violations, ].join("\n"), ); - process.exitCode = 1; + return 1; } -main(); +process.exitCode = main(); diff --git a/scripts/lib/config-boundary-guard.d.mts b/scripts/lib/config-boundary-guard.d.mts new file mode 100644 index 00000000000..3e8fe15a396 --- /dev/null +++ b/scripts/lib/config-boundary-guard.d.mts @@ -0,0 +1,5 @@ +export function collectDeprecatedInternalConfigApiViolations(options?: { + repoRoot?: string; +}): string[]; + +export function collectRuntimeActionLoadConfigViolations(options?: { repoRoot?: string }): string[]; diff --git a/scripts/lib/config-boundary-guard.mjs b/scripts/lib/config-boundary-guard.mjs new file mode 100644 index 00000000000..94f4982d0aa --- /dev/null +++ b/scripts/lib/config-boundary-guard.mjs @@ -0,0 +1,399 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +const COMPAT_CONFIG_API_FILES = new Set([ + "src/config/config.ts", + "src/config/io.ts", + "src/config/mutate.ts", + "src/memory-host-sdk/runtime-core.ts", + "src/plugin-sdk/browser-config-runtime.ts", + "src/plugin-sdk/config-runtime.ts", + "src/plugin-sdk/memory-core.ts", + "src/plugin-sdk/memory-core-host-runtime-core.ts", + "src/plugins/compat/registry.ts", + "src/plugins/contracts/config-boundary-guard.test.ts", + "src/plugins/contracts/deprecated-internal-config-api.test.ts", + "src/plugins/runtime/runtime-config.test.ts", + "src/plugins/runtime/runtime-config.ts", + "src/plugins/runtime/types-core.ts", +]); + +const AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES = new Set([ + "src/plugins/runtime/load-context.ts", + "src/plugins/runtime/runtime-config.ts", + "src/plugins/runtime/runtime-plugin-boundary.ts", +]); + +const PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES = new Set([ + "src/cli/banner-config-lite.ts", + "src/cli/daemon-cli/status.gather.ts", +]); + +function collectTypeScriptFiles(dir) { + if (!existsSync(dir)) { + return []; + } + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = resolve(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "dist" || entry.name === "node_modules") { + continue; + } + files.push(...collectTypeScriptFiles(fullPath)); + continue; + } + if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(fullPath); + } + } + return files; +} + +function repoRelative(repoRoot, filePath) { + return relative(repoRoot, filePath).split(sep).join("/"); +} + +function isProductionExtensionFile(relPath) { + if ( + relPath.includes("/test-support/") || + relPath.includes(".test.") || + relPath.includes(".live.test.") || + relPath.includes(".test-d.") || + relPath.includes(".test-harness.") || + relPath.includes(".test-shared.") || + relPath.endsWith("-test-helpers.ts") || + relPath.endsWith("-test-support.ts") + ) { + return false; + } + return true; +} + +function isTestOrHarnessFile(relPath) { + return ( + relPath.includes("test-support") || + relPath.includes("/test-support/") || + relPath.includes("/test-helpers/") || + relPath.includes(".test.") || + relPath.includes(".live.test.") || + relPath.includes(".test-d.") || + relPath.includes(".test-harness.") || + relPath.includes(".test-shared.") || + relPath.endsWith(".test-helpers.ts") || + relPath.endsWith(".test-support.ts") || + relPath.endsWith("-test-helpers.ts") || + relPath.endsWith("-test-support.ts") + ); +} + +function isCompatConfigApiFile(relPath) { + return COMPAT_CONFIG_API_FILES.has(relPath); +} + +function isAmbientRuntimeConfigCompatFile(relPath) { + return AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES.has(relPath); +} + +function findLineNumbers(source, pattern) { + const lines = source.split(/\r?\n/); + return lines.flatMap((line, index) => (pattern.test(line) ? [index + 1] : [])); +} + +function findMatchLineNumbers(source, pattern) { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + const regex = new RegExp(pattern.source, flags); + const lines = []; + for (let match = regex.exec(source); match; match = regex.exec(source)) { + lines.push(source.slice(0, match.index).split(/\r?\n/).length); + } + return lines; +} + +function findNonCommentLineNumbers(source, pattern) { + return source.split(/\r?\n/).flatMap((line, index) => { + const trimmed = line.trimStart(); + if (trimmed.startsWith("//") || trimmed.startsWith("*")) { + return []; + } + return pattern.test(line) ? [index + 1] : []; + }); +} + +function repoCodeRoots(repoRoot) { + return ["src", "extensions", "packages", "test", "scripts"].map((entry) => + resolve(repoRoot, entry), + ); +} + +function pushDeprecatedRuntimeApiViolations(violations, files) { + const guards = [ + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.loadConfig\b/, + replacement: "use runtime.config.current() or pass the already loaded config", + }, + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.writeConfigFile\b/, + replacement: + "use runtime.config.mutateConfigFile(...) or replaceConfigFile(...) with afterWrite", + }, + ]; + + for (const { filePath, relPath } of files) { + const source = readFileSync(filePath, "utf8"); + for (const guard of guards) { + for (const line of findMatchLineNumbers(source, guard.pattern)) { + violations.push(`${relPath}:${line} ${guard.replacement}`); + } + } + } +} + +export function collectDeprecatedInternalConfigApiViolations({ + repoRoot = DEFAULT_REPO_ROOT, +} = {}) { + const srcRoot = resolve(repoRoot, "src"); + const extensionsRoot = resolve(repoRoot, "extensions"); + const gatewayServerMethodsRoot = resolve(srcRoot, "gateway/server-methods"); + const ambientRuntimeConfigRoots = [ + "src/gateway", + "src/auto-reply", + "src/agents", + "src/infra", + "src/mcp", + "src/plugins/runtime", + "src/config/sessions", + ].map((entry) => resolve(repoRoot, entry)); + + const violations = []; + + const productionExtensionFiles = collectTypeScriptFiles(extensionsRoot) + .map((filePath) => ({ filePath, relPath: repoRelative(repoRoot, filePath) })) + .filter(({ relPath }) => isProductionExtensionFile(relPath)); + pushDeprecatedRuntimeApiViolations(violations, productionExtensionFiles); + + for (const { filePath, relPath } of productionExtensionFiles) { + const source = readFileSync(filePath, "utf8"); + const guards = [ + { + pattern: + /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\bloadConfig\b[^}]*\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core)["']/, + replacement: + "use getRuntimeConfig(), runtime.config.current(), or pass the already loaded config", + }, + { + pattern: /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })); + + pushDeprecatedRuntimeApiViolations( + violations, + repoFiles.filter(({ relPath }) => !isCompatConfigApiFile(relPath)), + ); + + for (const { filePath, relPath } of repoFiles.filter( + ({ relPath }) => !isCompatConfigApiFile(relPath), + )) { + const source = readFileSync(filePath, "utf8"); + const guards = [ + { + pattern: + /\b(?:import|export)\s+(?:type\s+)?\{[\s\S]*?\b(?:loadConfig|writeConfigFile)\b[\s\S]*?\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core|memory-core)["']/, + replacement: + "use getRuntimeConfig(), runtime.config.current(), or mutation helpers with afterWrite", + }, + { + pattern: + /ReturnType/, + replacement: "use OpenClawConfig or the explicit mutation helper type", + }, + ]; + for (const guard of guards) { + for (const line of findMatchLineNumbers(source, guard.pattern)) { + violations.push(`${relPath}:${line} ${guard.replacement}`); + } + } + } + + for (const { filePath, relPath } of repoFiles.filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !relPath.startsWith("test/"), + )) { + const source = readFileSync(filePath, "utf8"); + const importPattern = + /\bimport\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; + const dynamicImportPattern = + /\bconst\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s*=\s*await\s+import\(["'][^"']*(?:config\/config|config\/io)\.js["']\)/; + const directMethodPattern = /\.\s*writeConfigFile\s*\(/; + for (const pattern of [importPattern, dynamicImportPattern]) { + for (const line of findMatchLineNumbers(source, pattern)) { + violations.push( + `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, + ); + } + } + for (const line of findNonCommentLineNumbers(source, directMethodPattern)) { + violations.push( + `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, + ); + } + } + + for (const { filePath, relPath } of repoFiles.filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES.has(relPath) && + !relPath.startsWith("test/"), + )) { + const source = readFileSync(filePath, "utf8"); + for (const line of findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) + .filter(({ relPath }) => !isTestOrHarnessFile(relPath))) { + const source = readFileSync(filePath, "utf8"); + const importPattern = + /\bimport\s+\{[\s\S]*?\bloadConfig\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; + for (const line of findMatchLineNumbers(source, importPattern)) { + violations.push( + `${relPath}:${line} use context.getRuntimeConfig() in gateway request handlers`, + ); + } + for (const line of findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) + .filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !isAmbientRuntimeConfigCompatFile(relPath), + )) { + const source = readFileSync(filePath, "utf8"); + const loadConfigLines = findNonCommentLineNumbers(source, /(? pattern.test(basename)); +} + +export function collectRuntimeActionLoadConfigViolations({ repoRoot = DEFAULT_REPO_ROOT } = {}) { + return collectTypeScriptFiles(resolve(repoRoot, "extensions")) + .map((filePath) => ({ filePath, relPath: repoRelative(repoRoot, filePath) })) + .filter(({ relPath }) => isRuntimeActionLoadConfigCandidate(relPath)) + .flatMap(({ filePath, relPath }) => { + const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + return lines.flatMap((line, index) => + RUNTIME_ACTION_FORBIDDEN_CONFIG_LOAD_PATTERNS.some((pattern) => pattern.test(line)) + ? [`${relPath}:${index + 1}: ${line.trim()}`] + : [], + ); + }); +} diff --git a/scripts/lib/deprecated-config-api-guard.d.mts b/scripts/lib/deprecated-config-api-guard.d.mts index fe2df225986..21ffd0af02b 100644 --- a/scripts/lib/deprecated-config-api-guard.d.mts +++ b/scripts/lib/deprecated-config-api-guard.d.mts @@ -1,3 +1 @@ -export function collectDeprecatedInternalConfigApiViolations(options?: { - repoRoot?: string; -}): string[]; +export { collectDeprecatedInternalConfigApiViolations } from "./config-boundary-guard.mjs"; diff --git a/scripts/lib/deprecated-config-api-guard.mjs b/scripts/lib/deprecated-config-api-guard.mjs index a087c02dcd6..21ffd0af02b 100644 --- a/scripts/lib/deprecated-config-api-guard.mjs +++ b/scripts/lib/deprecated-config-api-guard.mjs @@ -1,325 +1 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { dirname, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; - -const DEFAULT_REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); - -const COMPAT_CONFIG_API_FILES = new Set([ - "src/config/config.ts", - "src/config/io.ts", - "src/config/mutate.ts", - "src/memory-host-sdk/runtime-core.ts", - "src/plugin-sdk/browser-config-runtime.ts", - "src/plugin-sdk/config-runtime.ts", - "src/plugin-sdk/memory-core.ts", - "src/plugin-sdk/memory-core-host-runtime-core.ts", - "src/plugins/contracts/deprecated-internal-config-api.test.ts", - "src/plugins/runtime/runtime-config.test.ts", - "src/plugins/runtime/runtime-config.ts", - "src/plugins/runtime/types-core.ts", -]); - -const AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES = new Set([ - "src/plugins/runtime/load-context.ts", - "src/plugins/runtime/runtime-config.ts", - "src/plugins/runtime/runtime-plugin-boundary.ts", -]); - -const PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES = new Set([ - "src/cli/banner-config-lite.ts", - "src/cli/daemon-cli/status.gather.ts", -]); - -function collectTypeScriptFiles(dir) { - if (!existsSync(dir)) { - return []; - } - const entries = readdirSync(dir, { withFileTypes: true }); - const files = []; - for (const entry of entries) { - const fullPath = resolve(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === "dist" || entry.name === "node_modules") { - continue; - } - files.push(...collectTypeScriptFiles(fullPath)); - continue; - } - if (entry.isFile() && entry.name.endsWith(".ts")) { - files.push(fullPath); - } - } - return files; -} - -function repoRelative(repoRoot, filePath) { - return relative(repoRoot, filePath).split(sep).join("/"); -} - -function isProductionExtensionFile(relPath) { - if ( - relPath.includes("/test-support/") || - relPath.includes(".test.") || - relPath.includes(".live.test.") || - relPath.includes(".test-d.") || - relPath.includes(".test-harness.") || - relPath.includes(".test-shared.") || - relPath.endsWith("-test-helpers.ts") || - relPath.endsWith("-test-support.ts") - ) { - return false; - } - return true; -} - -function isTestOrHarnessFile(relPath) { - return ( - relPath.includes("test-support") || - relPath.includes("/test-support/") || - relPath.includes("/test-helpers/") || - relPath.includes(".test.") || - relPath.includes(".live.test.") || - relPath.includes(".test-d.") || - relPath.includes(".test-harness.") || - relPath.includes(".test-shared.") || - relPath.endsWith(".test-helpers.ts") || - relPath.endsWith(".test-support.ts") || - relPath.endsWith("-test-helpers.ts") || - relPath.endsWith("-test-support.ts") - ); -} - -function isCompatConfigApiFile(relPath) { - return COMPAT_CONFIG_API_FILES.has(relPath); -} - -function isAmbientRuntimeConfigCompatFile(relPath) { - return AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES.has(relPath); -} - -function findLineNumbers(source, pattern) { - const lines = source.split(/\r?\n/); - return lines.flatMap((line, index) => (pattern.test(line) ? [index + 1] : [])); -} - -function findMatchLineNumbers(source, pattern) { - const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; - const regex = new RegExp(pattern.source, flags); - const lines = []; - for (let match = regex.exec(source); match; match = regex.exec(source)) { - lines.push(source.slice(0, match.index).split(/\r?\n/).length); - } - return lines; -} - -function findNonCommentLineNumbers(source, pattern) { - return source.split(/\r?\n/).flatMap((line, index) => { - const trimmed = line.trimStart(); - if (trimmed.startsWith("//") || trimmed.startsWith("*")) { - return []; - } - return pattern.test(line) ? [index + 1] : []; - }); -} - -function repoCodeRoots(repoRoot) { - return ["src", "extensions", "packages", "test", "scripts"].map((entry) => - resolve(repoRoot, entry), - ); -} - -function pushDeprecatedRuntimeApiViolations(violations, files) { - const guards = [ - { - pattern: - /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.loadConfig\b/, - replacement: "use runtime.config.current() or pass the already loaded config", - }, - { - pattern: - /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.writeConfigFile\b/, - replacement: - "use runtime.config.mutateConfigFile(...) or replaceConfigFile(...) with afterWrite", - }, - ]; - - for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); - for (const guard of guards) { - for (const line of findMatchLineNumbers(source, guard.pattern)) { - violations.push(`${relPath}:${line} ${guard.replacement}`); - } - } - } -} - -export function collectDeprecatedInternalConfigApiViolations({ - repoRoot = DEFAULT_REPO_ROOT, -} = {}) { - const srcRoot = resolve(repoRoot, "src"); - const extensionsRoot = resolve(repoRoot, "extensions"); - const gatewayServerMethodsRoot = resolve(srcRoot, "gateway/server-methods"); - const ambientRuntimeConfigRoots = [ - "src/gateway", - "src/auto-reply", - "src/agents", - "src/infra", - "src/mcp", - "src/plugins/runtime", - "src/config/sessions", - ].map((entry) => resolve(repoRoot, entry)); - - const violations = []; - - const productionExtensionFiles = collectTypeScriptFiles(extensionsRoot) - .map((filePath) => ({ filePath, relPath: repoRelative(repoRoot, filePath) })) - .filter(({ relPath }) => isProductionExtensionFile(relPath)); - pushDeprecatedRuntimeApiViolations(violations, productionExtensionFiles); - - for (const { filePath, relPath } of productionExtensionFiles) { - const source = readFileSync(filePath, "utf8"); - const guards = [ - { - pattern: - /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\bloadConfig\b[^}]*\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core)["']/, - replacement: - "use getRuntimeConfig(), runtime.config.current(), or pass the already loaded config", - }, - { - pattern: /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })); - - pushDeprecatedRuntimeApiViolations( - violations, - repoFiles.filter(({ relPath }) => !isCompatConfigApiFile(relPath)), - ); - - for (const { filePath, relPath } of repoFiles.filter( - ({ relPath }) => !isCompatConfigApiFile(relPath), - )) { - const source = readFileSync(filePath, "utf8"); - const guards = [ - { - pattern: - /\b(?:import|export)\s+(?:type\s+)?\{[\s\S]*?\b(?:loadConfig|writeConfigFile)\b[\s\S]*?\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core|memory-core)["']/, - replacement: - "use getRuntimeConfig(), runtime.config.current(), or mutation helpers with afterWrite", - }, - { - pattern: - /ReturnType/, - replacement: "use OpenClawConfig or the explicit mutation helper type", - }, - ]; - for (const guard of guards) { - for (const line of findMatchLineNumbers(source, guard.pattern)) { - violations.push(`${relPath}:${line} ${guard.replacement}`); - } - } - } - - for (const { filePath, relPath } of repoFiles.filter( - ({ relPath }) => - !isTestOrHarnessFile(relPath) && - !isCompatConfigApiFile(relPath) && - !relPath.startsWith("test/"), - )) { - const source = readFileSync(filePath, "utf8"); - const importPattern = - /\bimport\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; - const dynamicImportPattern = - /\bconst\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s*=\s*await\s+import\(["'][^"']*(?:config\/config|config\/io)\.js["']\)/; - const directMethodPattern = /\.\s*writeConfigFile\s*\(/; - for (const pattern of [importPattern, dynamicImportPattern]) { - for (const line of findMatchLineNumbers(source, pattern)) { - violations.push( - `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, - ); - } - } - for (const line of findNonCommentLineNumbers(source, directMethodPattern)) { - violations.push( - `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, - ); - } - } - - for (const { filePath, relPath } of repoFiles.filter( - ({ relPath }) => - !isTestOrHarnessFile(relPath) && - !isCompatConfigApiFile(relPath) && - !PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES.has(relPath) && - !relPath.startsWith("test/"), - )) { - const source = readFileSync(filePath, "utf8"); - for (const line of findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) - .filter(({ relPath }) => !isTestOrHarnessFile(relPath))) { - const source = readFileSync(filePath, "utf8"); - const importPattern = - /\bimport\s+\{[\s\S]*?\bloadConfig\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; - for (const line of findMatchLineNumbers(source, importPattern)) { - violations.push( - `${relPath}:${line} use context.getRuntimeConfig() in gateway request handlers`, - ); - } - for (const line of findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) - .filter( - ({ relPath }) => - !isTestOrHarnessFile(relPath) && - !isCompatConfigApiFile(relPath) && - !isAmbientRuntimeConfigCompatFile(relPath), - )) { - const source = readFileSync(filePath, "utf8"); - const loadConfigLines = findNonCommentLineNumbers(source, /(? +>["snapshot"]; +export type ConfigWriteOptions = Awaited< + ReturnType +>["writeOptions"]; + +export function resolveGatewayConfigPath(snapshot?: Pick): string { + return snapshot?.path ?? createConfigIO().configPath; +} + +export function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): boolean { + const prevAuth = resolveEffectiveSharedGatewayAuth({ + authConfig: prev.gateway?.auth, + env: process.env, + tailscaleMode: prev.gateway?.tailscale?.mode, + }); + const nextAuth = resolveEffectiveSharedGatewayAuth({ + authConfig: next.gateway?.auth, + env: process.env, + tailscaleMode: next.gateway?.tailscale?.mode, + }); + if (prevAuth === null || nextAuth === null) { + return prevAuth !== nextAuth; + } + return prevAuth.mode !== nextAuth.mode || !isDeepStrictEqual(prevAuth.secret, nextAuth.secret); +} + +function queueSharedGatewayAuthDisconnect( + shouldDisconnect: boolean, + context?: GatewayRequestContext, +): void { + if (!shouldDisconnect) { + return; + } + queueMicrotask(() => { + context?.disconnectClientsUsingSharedGatewayAuth?.(); + }); +} + +function queueSharedGatewayAuthGenerationRefresh( + shouldRefresh: boolean, + nextConfig: OpenClawConfig, + context?: GatewayRequestContext, +): void { + if (!shouldRefresh) { + return; + } + queueMicrotask(() => { + context?.enforceSharedGatewayAuthGenerationForConfigWrite?.(nextConfig); + }); +} + +function shouldScheduleDirectConfigRestart(params: { + changedPaths: string[]; + nextConfig: OpenClawConfig; +}): boolean { + const reloadSettings = resolveGatewayReloadSettings(params.nextConfig); + if (reloadSettings.mode === "off") { + return true; + } + const plan = buildGatewayReloadPlan(params.changedPaths); + if (reloadSettings.mode === "hot" && plan.restartGateway) { + return true; + } + return false; +} + +function resolveConfigRestartRequest(params: unknown): { + sessionKey: string | undefined; + note: string | undefined; + restartDelayMs: number | undefined; + deliveryContext: ReturnType["deliveryContext"]; + threadId: ReturnType["threadId"]; +} { + const { + sessionKey, + deliveryContext: requestedDeliveryContext, + threadId: requestedThreadId, + note, + restartDelayMs, + } = parseRestartRequestParams(params); + + // Extract deliveryContext + threadId for routing after restart. + // Uses generic :thread: parsing plus plugin-owned session grammars. + const { deliveryContext: sessionDeliveryContext, threadId: sessionThreadId } = + extractDeliveryInfo(sessionKey); + + return { + sessionKey, + note, + restartDelayMs, + deliveryContext: requestedDeliveryContext ?? sessionDeliveryContext, + threadId: requestedThreadId ?? sessionThreadId, + }; +} + +function buildConfigRestartSentinelPayload(params: { + kind: RestartSentinelPayload["kind"]; + mode: string; + configPath: string; + sessionKey: string | undefined; + deliveryContext: ReturnType["deliveryContext"]; + threadId: ReturnType["threadId"]; + note: string | undefined; +}): RestartSentinelPayload { + return { + kind: params.kind, + status: "ok", + ts: Date.now(), + sessionKey: params.sessionKey, + deliveryContext: params.deliveryContext, + threadId: params.threadId, + message: params.note ?? null, + doctorHint: formatDoctorNonInteractiveHint(), + stats: { + mode: params.mode, + root: params.configPath, + }, + }; +} + +async function tryWriteRestartSentinelPayload( + payload: RestartSentinelPayload, +): Promise { + try { + return await writeRestartSentinel(payload); + } catch { + return null; + } +} + +export async function commitGatewayConfigWrite(params: { + snapshot: ConfigWriteSnapshot; + writeOptions: ConfigWriteOptions; + nextConfig: OpenClawConfig; + context?: GatewayRequestContext; + disconnectSharedAuthClients?: boolean; +}): Promise<{ path: string; queueFollowUp: () => void }> { + await replaceConfigFile({ + nextConfig: params.nextConfig, + writeOptions: params.writeOptions, + afterWrite: { mode: "auto" }, + }); + return { + path: resolveGatewayConfigPath(params.snapshot), + queueFollowUp: () => { + queueSharedGatewayAuthGenerationRefresh(true, params.nextConfig, params.context); + queueSharedGatewayAuthDisconnect(Boolean(params.disconnectSharedAuthClients), params.context); + }, + }; +} + +export async function resolveGatewayConfigRestartWriteResult(params: { + requestParams: unknown; + kind: RestartSentinelPayload["kind"]; + mode: "config.patch" | "config.apply"; + configPath: string; + changedPaths: string[]; + nextConfig: OpenClawConfig; + actor: ControlPlaneActor; + context?: GatewayRequestContext; +}): Promise<{ + payload: RestartSentinelPayload; + sentinelPath: string | null; + restart: ReturnType | undefined; +}> { + const { sessionKey, note, restartDelayMs, deliveryContext, threadId } = + resolveConfigRestartRequest(params.requestParams); + const payload = buildConfigRestartSentinelPayload({ + kind: params.kind, + mode: params.mode, + configPath: params.configPath, + sessionKey, + deliveryContext, + threadId, + note, + }); + const sentinelPath = await tryWriteRestartSentinelPayload(payload); + const restart = shouldScheduleDirectConfigRestart({ + changedPaths: params.changedPaths, + nextConfig: params.nextConfig, + }) + ? scheduleGatewaySigusr1Restart({ + delayMs: restartDelayMs, + reason: params.mode, + audit: { + actor: params.actor.actor, + deviceId: params.actor.deviceId, + clientIp: params.actor.clientIp, + changedPaths: params.changedPaths, + }, + }) + : undefined; + if (restart?.coalesced) { + params.context?.logGateway?.warn( + `${params.mode} restart coalesced ${formatControlPlaneActor(params.actor)} delayMs=${restart.delayMs}`, + ); + } + return { payload, sentinelPath, restart }; +} diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 0336b1d0a63..34e672b1e71 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,11 +1,9 @@ import { execFile } from "node:child_process"; -import { isDeepStrictEqual } from "node:util"; import { createConfigIO, parseConfigJson5, readConfigFileSnapshot, readConfigFileSnapshotForWrite, - replaceConfigFile, resolveConfigSnapshotHash, validateConfigObjectWithPlugins, } from "../../config/config.js"; @@ -18,22 +16,10 @@ import { } from "../../config/redact-snapshot.js"; import { loadGatewayRuntimeConfigSchema } from "../../config/runtime-schema.js"; import { lookupConfigSchema, type ConfigSchemaResponse } from "../../config/schema.js"; -import { extractDeliveryInfo } from "../../config/sessions.js"; import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { - formatDoctorNonInteractiveHint, - type RestartSentinelPayload, - writeRestartSentinel, -} from "../../infra/restart-sentinel.js"; -import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { prepareSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; -import { resolveEffectiveSharedGatewayAuth } from "../auth.js"; -import { - buildGatewayReloadPlan, - diffConfigPaths, - resolveGatewayReloadSettings, -} from "../config-reload.js"; +import { diffConfigPaths } from "../config-reload.js"; import { formatControlPlaneActor, resolveControlPlaneActor, @@ -52,8 +38,13 @@ import { validateConfigSetParams, } from "../protocol/index.js"; import { resolveBaseHashParam } from "./base-hash.js"; -import { parseRestartRequestParams } from "./restart-request.js"; -import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js"; +import { + commitGatewayConfigWrite, + didSharedGatewayAuthChange, + resolveGatewayConfigPath, + resolveGatewayConfigRestartWriteResult, +} from "./config-write-flow.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; const MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE = 3; @@ -63,15 +54,6 @@ type ConfigOpenCommand = { args: string[]; }; -type ConfigWriteSnapshot = Awaited>["snapshot"]; -type ConfigWriteOptions = Awaited< - ReturnType ->["writeOptions"]; - -function resolveGatewayConfigPath(snapshot?: Pick): string { - return snapshot?.path ?? createConfigIO().configPath; -} - function requireConfigBaseHash( params: unknown, snapshot: Awaited>, @@ -235,48 +217,6 @@ function parseValidateConfigFromRawOrRespond( return { config: validated.config, schema }; } -function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): boolean { - const prevAuth = resolveEffectiveSharedGatewayAuth({ - authConfig: prev.gateway?.auth, - env: process.env, - tailscaleMode: prev.gateway?.tailscale?.mode, - }); - const nextAuth = resolveEffectiveSharedGatewayAuth({ - authConfig: next.gateway?.auth, - env: process.env, - tailscaleMode: next.gateway?.tailscale?.mode, - }); - if (prevAuth === null || nextAuth === null) { - return prevAuth !== nextAuth; - } - return prevAuth.mode !== nextAuth.mode || !isDeepStrictEqual(prevAuth.secret, nextAuth.secret); -} - -function queueSharedGatewayAuthDisconnect( - shouldDisconnect: boolean, - context?: GatewayRequestContext, -): void { - if (!shouldDisconnect) { - return; - } - queueMicrotask(() => { - context?.disconnectClientsUsingSharedGatewayAuth?.(); - }); -} - -function queueSharedGatewayAuthGenerationRefresh( - shouldRefresh: boolean, - nextConfig: OpenClawConfig, - context?: GatewayRequestContext, -): void { - if (!shouldRefresh) { - return; - } - queueMicrotask(() => { - context?.enforceSharedGatewayAuthGenerationForConfigWrite?.(nextConfig); - }); -} - function summarizeConfigValidationIssues(issues: ReadonlyArray): string { const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE); const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true }) @@ -291,21 +231,6 @@ function summarizeConfigValidationIssues(issues: ReadonlyArray["deliveryContext"]; - threadId: ReturnType["threadId"]; -} { - const { - sessionKey, - deliveryContext: requestedDeliveryContext, - threadId: requestedThreadId, - note, - restartDelayMs, - } = parseRestartRequestParams(params); - - // Extract deliveryContext + threadId for routing after restart. - // Uses generic :thread: parsing plus plugin-owned session grammars. - const { deliveryContext: sessionDeliveryContext, threadId: sessionThreadId } = - extractDeliveryInfo(sessionKey); - - return { - sessionKey, - note, - restartDelayMs, - deliveryContext: requestedDeliveryContext ?? sessionDeliveryContext, - threadId: requestedThreadId ?? sessionThreadId, - }; -} - -function buildConfigRestartSentinelPayload(params: { - kind: RestartSentinelPayload["kind"]; - mode: string; - configPath: string; - sessionKey: string | undefined; - deliveryContext: ReturnType["deliveryContext"]; - threadId: ReturnType["threadId"]; - note: string | undefined; -}): RestartSentinelPayload { - return { - kind: params.kind, - status: "ok", - ts: Date.now(), - sessionKey: params.sessionKey, - deliveryContext: params.deliveryContext, - threadId: params.threadId, - message: params.note ?? null, - doctorHint: formatDoctorNonInteractiveHint(), - stats: { - mode: params.mode, - root: params.configPath, - }, - }; -} - -async function tryWriteRestartSentinelPayload( - payload: RestartSentinelPayload, -): Promise { - try { - return await writeRestartSentinel(payload); - } catch { - return null; - } -} - function loadSchemaWithPlugins(): ConfigSchemaResponse { // Note: We can't easily cache this, as there are no callback that can invalidate // our cache. However, getRuntimeConfig() and loadOpenClawPlugins() (called inside @@ -402,76 +263,6 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { return loadGatewayRuntimeConfigSchema(); } -async function commitGatewayConfigWrite(params: { - snapshot: ConfigWriteSnapshot; - writeOptions: ConfigWriteOptions; - nextConfig: OpenClawConfig; - context?: GatewayRequestContext; - disconnectSharedAuthClients?: boolean; -}): Promise<{ path: string; queueFollowUp: () => void }> { - await replaceConfigFile({ - nextConfig: params.nextConfig, - writeOptions: params.writeOptions, - afterWrite: { mode: "auto" }, - }); - return { - path: resolveGatewayConfigPath(params.snapshot), - queueFollowUp: () => { - queueSharedGatewayAuthGenerationRefresh(true, params.nextConfig, params.context); - queueSharedGatewayAuthDisconnect(Boolean(params.disconnectSharedAuthClients), params.context); - }, - }; -} - -async function resolveGatewayConfigRestartWriteResult(params: { - requestParams: unknown; - kind: RestartSentinelPayload["kind"]; - mode: "config.patch" | "config.apply"; - configPath: string; - changedPaths: string[]; - nextConfig: OpenClawConfig; - actor: ReturnType; - context?: GatewayRequestContext; -}): Promise<{ - payload: RestartSentinelPayload; - sentinelPath: string | null; - restart: ReturnType | undefined; -}> { - const { sessionKey, note, restartDelayMs, deliveryContext, threadId } = - resolveConfigRestartRequest(params.requestParams); - const payload = buildConfigRestartSentinelPayload({ - kind: params.kind, - mode: params.mode, - configPath: params.configPath, - sessionKey, - deliveryContext, - threadId, - note, - }); - const sentinelPath = await tryWriteRestartSentinelPayload(payload); - const restart = shouldScheduleDirectConfigRestart({ - changedPaths: params.changedPaths, - nextConfig: params.nextConfig, - }) - ? scheduleGatewaySigusr1Restart({ - delayMs: restartDelayMs, - reason: params.mode, - audit: { - actor: params.actor.actor, - deviceId: params.actor.deviceId, - clientIp: params.actor.clientIp, - changedPaths: params.changedPaths, - }, - }) - : undefined; - if (restart?.coalesced) { - params.context?.logGateway?.warn( - `${params.mode} restart coalesced ${formatControlPlaneActor(params.actor)} delayMs=${restart.delayMs}`, - ); - } - return { payload, sentinelPath, restart }; -} - export const configHandlers: GatewayRequestHandlers = { "config.get": async ({ params, respond }) => { if (!assertValidParams(params, validateConfigGetParams, "config.get", respond)) { diff --git a/src/media-understanding/defaults.ts b/src/media-understanding/defaults.ts index 3c7c544cbb3..0e11d77469d 100644 --- a/src/media-understanding/defaults.ts +++ b/src/media-understanding/defaults.ts @@ -1,3 +1,4 @@ +import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { buildMediaUnderstandingManifestMetadataRegistry } from "./manifest-metadata.js"; @@ -36,20 +37,38 @@ export const CLI_OUTPUT_MAX_BUFFER = 5 * MB; export const DEFAULT_MEDIA_CONCURRENCY = 2; let defaultRegistryCache: Map | null = null; -const configRegistryCache = new WeakMap>(); +const configRegistryCache = new Map>(); +const MAX_CONFIG_REGISTRY_CACHE_ENTRIES = 32; + +function cacheConfigRegistry( + key: string, + registry: Map, +): Map { + if ( + !configRegistryCache.has(key) && + configRegistryCache.size >= MAX_CONFIG_REGISTRY_CACHE_ENTRIES + ) { + const oldestKey = configRegistryCache.keys().next().value; + if (oldestKey) { + configRegistryCache.delete(oldestKey); + } + } + configRegistryCache.set(key, registry); + return registry; +} function resolveDefaultRegistry(cfg?: OpenClawConfig) { if (!cfg) { defaultRegistryCache ??= buildMediaUnderstandingManifestMetadataRegistry(); return defaultRegistryCache; } - const cached = configRegistryCache.get(cfg); + const cacheKey = resolveRuntimeConfigCacheKey(cfg); + const cached = configRegistryCache.get(cacheKey); if (cached) { return cached; } const registry = buildMediaUnderstandingManifestMetadataRegistry(cfg); - configRegistryCache.set(cfg, registry); - return registry; + return cacheConfigRegistry(cacheKey, registry); } function providerHasDeclaredCapability( diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 7897546e0f4..9988b3d6bb5 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -45,6 +45,11 @@ const knownDeprecatedSurfaceMarkers = [ file: "src/plugins/agent-tool-result-middleware-types.ts", marker: "AgentToolResultMiddlewareHarness", }, + { + code: "runtime-config-load-write", + file: "src/plugins/runtime/runtime-config.ts", + marker: "RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE", + }, { code: "runtime-taskflow-legacy-alias", file: "src/plugins/runtime/types-core.ts", diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index bf937d06226..b31ddd31400 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -594,6 +594,29 @@ export const PLUGIN_COMPAT_RECORDS = [ "src/agents/codex-app-server.extensions.test.ts", ], }, + { + code: "runtime-config-load-write", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-27", + deprecated: "2026-04-27", + warningStarts: "2026-04-27", + removeAfter: "2026-07-27", + replacement: + "`api.runtime.config.current()`, passed config values, `mutateConfigFile(...)`, or `replaceConfigFile(...)`", + docsPath: "/plugins/sdk-runtime#config-loading-and-writes", + surfaces: ["api.runtime.config.loadConfig", "api.runtime.config.writeConfigFile"], + diagnostics: [ + "plugin runtime compatibility warning", + "deprecated internal config API guard", + "runtime channel config boundary guard", + ], + tests: [ + "src/plugins/runtime/runtime-config.test.ts", + "src/plugins/contracts/deprecated-internal-config-api.test.ts", + "src/plugins/contracts/config-boundary-guard.test.ts", + ], + }, { code: "runtime-taskflow-legacy-alias", status: "deprecated", diff --git a/src/plugins/contracts/config-boundary-guard.test.ts b/src/plugins/contracts/config-boundary-guard.test.ts new file mode 100644 index 00000000000..92ee4eac9b2 --- /dev/null +++ b/src/plugins/contracts/config-boundary-guard.test.ts @@ -0,0 +1,78 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + collectDeprecatedInternalConfigApiViolations, + collectRuntimeActionLoadConfigViolations, +} from "../../../scripts/lib/config-boundary-guard.mjs"; + +let tempRoots: string[] = []; + +function makeRepoFixture(): string { + const repoRoot = mkdtempSync(join(tmpdir(), "openclaw-config-boundary-")); + tempRoots.push(repoRoot); + for (const dir of ["src", "extensions", "packages", "test", "scripts"]) { + mkdirSync(join(repoRoot, dir), { recursive: true }); + } + return repoRoot; +} + +function writeFixture(repoRoot: string, relPath: string, source: string): void { + const filePath = join(repoRoot, relPath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, source); +} + +describe("config boundary guard", () => { + afterEach(() => { + for (const repoRoot of tempRoots) { + rmSync(repoRoot, { recursive: true, force: true }); + } + tempRoots = []; + }); + + it("flags deprecated runtime config calls in production plugin code", () => { + const repoRoot = makeRepoFixture(); + writeFixture( + repoRoot, + "extensions/telegram/src/index.ts", + "export function register(api) { return api.runtime.config.loadConfig(); }\n", + ); + + const violations = collectDeprecatedInternalConfigApiViolations({ repoRoot }); + expect(violations).toEqual( + expect.arrayContaining([ + "extensions/telegram/src/index.ts:1 use runtime.config.current() or pass the already loaded config", + "extensions/telegram/src/index.ts:1 use runtime.config.current(), getRuntimeConfig(), or passed config", + "extensions/telegram/src/index.ts:1 use a passed cfg, context.getRuntimeConfig(), or getRuntimeConfig() at an explicit process boundary", + ]), + ); + expect( + violations.every((violation) => violation.startsWith("extensions/telegram/src/index.ts:")), + ).toBe(true); + }); + + it("flags loadConfig in runtime channel action helpers only", () => { + const repoRoot = makeRepoFixture(); + writeFixture( + repoRoot, + "extensions/telegram/src/send.ts", + "export async function send() { return loadConfig(); }\n", + ); + writeFixture( + repoRoot, + "extensions/telegram/src/monitor/status.ts", + "export async function monitor() { return loadConfig(); }\n", + ); + writeFixture( + repoRoot, + "extensions/openai/src/send.ts", + "export async function provider() { return loadConfig(); }\n", + ); + + expect(collectRuntimeActionLoadConfigViolations({ repoRoot })).toEqual([ + "extensions/telegram/src/send.ts:1: export async function send() { return loadConfig(); }", + ]); + }); +}); diff --git a/src/plugins/runtime/runtime-config.test.ts b/src/plugins/runtime/runtime-config.test.ts index acbc3c6f602..53a5a748697 100644 --- a/src/plugins/runtime/runtime-config.test.ts +++ b/src/plugins/runtime/runtime-config.test.ts @@ -20,6 +20,7 @@ vi.mock("../../logger.js", () => ({ })); const { createRuntimeConfig } = await import("./runtime-config.js"); +const deprecatedConfigCode = "runtime-config-load-write"; describe("createRuntimeConfig", () => { beforeEach(() => { @@ -41,7 +42,7 @@ describe("createRuntimeConfig", () => { expect(configApi.loadConfig()).toBe(runtimeConfig); expect(getRuntimeConfigMock).toHaveBeenCalledTimes(2); expect(logWarnMock).toHaveBeenCalledWith( - "plugin runtime config.loadConfig() is deprecated; use config.current().", + `plugin runtime config.loadConfig() is deprecated (${deprecatedConfigCode}); use config.current().`, ); }); @@ -52,7 +53,7 @@ describe("createRuntimeConfig", () => { await configApi.writeConfigFile(nextConfig); expect(logWarnMock).toHaveBeenCalledWith( - "plugin runtime config.writeConfigFile() is deprecated; use config.mutateConfigFile(...) or config.replaceConfigFile(...).", + `plugin runtime config.writeConfigFile() is deprecated (${deprecatedConfigCode}); use config.mutateConfigFile(...) or config.replaceConfigFile(...).`, ); expect(replaceConfigFileMock).toHaveBeenCalledWith({ nextConfig, diff --git a/src/plugins/runtime/runtime-config.ts b/src/plugins/runtime/runtime-config.ts index 9792c14d048..0516dbd99c7 100644 --- a/src/plugins/runtime/runtime-config.ts +++ b/src/plugins/runtime/runtime-config.ts @@ -6,6 +6,8 @@ import { import { logWarn } from "../../logger.js"; import type { PluginRuntime } from "./types.js"; +export const RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE = "runtime-config-load-write"; + const warnedDeprecatedConfigApis = new Set(); function warnDeprecatedConfigApiOnce( @@ -16,7 +18,9 @@ function warnDeprecatedConfigApiOnce( return; } warnedDeprecatedConfigApis.add(name); - logWarn(`plugin runtime config.${name}() is deprecated; use ${replacement}.`); + logWarn( + `plugin runtime config.${name}() is deprecated (${RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE}); use ${replacement}.`, + ); } export function createRuntimeConfig(): PluginRuntime["config"] {