refactor(config): tighten plugin config guardrails

This commit is contained in:
Peter Steinberger
2026-04-27 13:46:23 +01:00
parent ef9d108436
commit 5dd1e264eb
16 changed files with 789 additions and 649 deletions

View File

@@ -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,

View File

@@ -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.
</Step>

View File

@@ -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.

View File

@@ -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();

View File

@@ -0,0 +1,5 @@
export function collectDeprecatedInternalConfigApiViolations(options?: {
repoRoot?: string;
}): string[];
export function collectRuntimeActionLoadConfigViolations(options?: { repoRoot?: string }): string[];

View File

@@ -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: /(?<!\.)\bloadConfig\s*\(/,
replacement: "use getRuntimeConfig(), runtime.config.current(), or passed config",
},
{
pattern: /\bcreateConfigIO\b|\.\s*loadConfig\s*\(/,
replacement: "use runtime.config.current(), getRuntimeConfig(), or passed config",
},
{
pattern: /\bwriteConfigFile\s*\(/,
replacement: "use mutateConfigFile(...) or replaceConfigFile(...) with afterWrite",
},
];
for (const guard of guards) {
for (const line of findLineNumbers(source, guard.pattern)) {
violations.push(`${relPath}:${line} ${guard.replacement}`);
}
}
}
const repoFiles = repoCodeRoots(repoRoot)
.flatMap(collectTypeScriptFiles)
.map((filePath) => ({ 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<typeof import\(["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core|memory-core)["']\)\.(?:loadConfig|writeConfigFile)>/,
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, /(?<!\.)\bloadConfig\s*\(/)) {
violations.push(
`${relPath}:${line} use a passed cfg, context.getRuntimeConfig(), or getRuntimeConfig() at an explicit process boundary`,
);
}
for (const line of findNonCommentLineNumbers(source, /\.\s*loadConfig\s*\(/)) {
violations.push(
`${relPath}:${line} use a passed cfg, context.getRuntimeConfig(), or getRuntimeConfig() at an explicit process boundary`,
);
}
}
for (const { filePath, relPath } of collectTypeScriptFiles(gatewayServerMethodsRoot)
.map((filePath) => ({ 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, /(?<!\.)\bloadConfig\s*\(/)) {
violations.push(
`${relPath}:${line} use context.getRuntimeConfig() in gateway request handlers`,
);
}
}
for (const { filePath, relPath } of ambientRuntimeConfigRoots
.flatMap(collectTypeScriptFiles)
.map((filePath) => ({ filePath, relPath: repoRelative(repoRoot, filePath) }))
.filter(
({ relPath }) =>
!isTestOrHarnessFile(relPath) &&
!isCompatConfigApiFile(relPath) &&
!isAmbientRuntimeConfigCompatFile(relPath),
)) {
const source = readFileSync(filePath, "utf8");
const loadConfigLines = findNonCommentLineNumbers(source, /(?<!\.)\bloadConfig\s*\(/);
if (loadConfigLines.length === 0) {
continue;
}
violations.push(
`${relPath}:${loadConfigLines.join(",")} has ${loadConfigLines.length} ambient loadConfig() calls. Pass cfg through the call path, use context.getRuntimeConfig(), or use getRuntimeConfig() at a process boundary.`,
);
}
return violations;
}
const CHANNEL_EXTENSION_IDS = new Set([
"discord",
"imessage",
"irc",
"line",
"matrix",
"mattermost",
"nextcloud-talk",
"signal",
"slack",
"telegram",
"whatsapp",
]);
const RUNTIME_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 RUNTIME_ACTION_FORBIDDEN_CONFIG_LOAD_PATTERNS = [
/\bloadConfig\s*\(/,
/\.config\.loadConfig\s*\(/,
];
function isRuntimeActionLoadConfigCandidate(relPath) {
const parts = relPath.split("/");
if (parts[0] !== "extensions" || parts[2] !== "src") {
return false;
}
if (!CHANNEL_EXTENSION_IDS.has(parts[1])) {
return false;
}
if (
relPath.endsWith(".test.ts") ||
relPath.endsWith(".test-harness.ts") ||
relPath.endsWith(".d.ts")
) {
return false;
}
if (parts.includes("monitor") || parts.includes("cli")) {
return false;
}
if (parts.includes("actions")) {
return true;
}
const basename = parts.at(-1) ?? "";
return RUNTIME_HELPER_BASENAME_PATTERNS.some((pattern) => 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()}`]
: [],
);
});
}

View File

@@ -1,3 +1 @@
export function collectDeprecatedInternalConfigApiViolations(options?: {
repoRoot?: string;
}): string[];
export { collectDeprecatedInternalConfigApiViolations } from "./config-boundary-guard.mjs";

View File

@@ -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: /(?<!\.)\bloadConfig\s*\(/,
replacement: "use getRuntimeConfig(), runtime.config.current(), or passed config",
},
{
pattern: /\bcreateConfigIO\b|\.\s*loadConfig\s*\(/,
replacement: "use runtime.config.current(), getRuntimeConfig(), or passed config",
},
{
pattern: /\bwriteConfigFile\s*\(/,
replacement: "use mutateConfigFile(...) or replaceConfigFile(...) with afterWrite",
},
];
for (const guard of guards) {
for (const line of findLineNumbers(source, guard.pattern)) {
violations.push(`${relPath}:${line} ${guard.replacement}`);
}
}
}
const repoFiles = repoCodeRoots(repoRoot)
.flatMap(collectTypeScriptFiles)
.map((filePath) => ({ 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<typeof import\(["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core|memory-core)["']\)\.(?:loadConfig|writeConfigFile)>/,
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, /(?<!\.)\bloadConfig\s*\(/)) {
violations.push(
`${relPath}:${line} use a passed cfg, context.getRuntimeConfig(), or getRuntimeConfig() at an explicit process boundary`,
);
}
for (const line of findNonCommentLineNumbers(source, /\.\s*loadConfig\s*\(/)) {
violations.push(
`${relPath}:${line} use a passed cfg, context.getRuntimeConfig(), or getRuntimeConfig() at an explicit process boundary`,
);
}
}
for (const { filePath, relPath } of collectTypeScriptFiles(gatewayServerMethodsRoot)
.map((filePath) => ({ 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, /(?<!\.)\bloadConfig\s*\(/)) {
violations.push(
`${relPath}:${line} use context.getRuntimeConfig() in gateway request handlers`,
);
}
}
for (const { filePath, relPath } of ambientRuntimeConfigRoots
.flatMap(collectTypeScriptFiles)
.map((filePath) => ({ filePath, relPath: repoRelative(repoRoot, filePath) }))
.filter(
({ relPath }) =>
!isTestOrHarnessFile(relPath) &&
!isCompatConfigApiFile(relPath) &&
!isAmbientRuntimeConfigCompatFile(relPath),
)) {
const source = readFileSync(filePath, "utf8");
const loadConfigLines = findNonCommentLineNumbers(source, /(?<!\.)\bloadConfig\s*\(/);
if (loadConfigLines.length === 0) {
continue;
}
violations.push(
`${relPath}:${loadConfigLines.join(",")} has ${loadConfigLines.length} ambient loadConfig() calls. Pass cfg through the call path, use context.getRuntimeConfig(), or use getRuntimeConfig() at a process boundary.`,
);
}
return violations;
}
export { collectDeprecatedInternalConfigApiViolations } from "./config-boundary-guard.mjs";

View File

@@ -0,0 +1,221 @@
import { isDeepStrictEqual } from "node:util";
import {
createConfigIO,
readConfigFileSnapshotForWrite,
replaceConfigFile,
} from "../../config/config.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { resolveEffectiveSharedGatewayAuth } from "../auth.js";
import { buildGatewayReloadPlan, resolveGatewayReloadSettings } from "../config-reload.js";
import { formatControlPlaneActor, type ControlPlaneActor } from "../control-plane-audit.js";
import { parseRestartRequestParams } from "./restart-request.js";
import type { GatewayRequestContext } from "./types.js";
export type ConfigWriteSnapshot = Awaited<
ReturnType<typeof readConfigFileSnapshotForWrite>
>["snapshot"];
export type ConfigWriteOptions = Awaited<
ReturnType<typeof readConfigFileSnapshotForWrite>
>["writeOptions"];
export function resolveGatewayConfigPath(snapshot?: Pick<ConfigWriteSnapshot, "path">): 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<typeof extractDeliveryInfo>["deliveryContext"];
threadId: ReturnType<typeof extractDeliveryInfo>["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<typeof extractDeliveryInfo>["deliveryContext"];
threadId: ReturnType<typeof extractDeliveryInfo>["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<string | null> {
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<typeof scheduleGatewaySigusr1Restart> | 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 };
}

View File

@@ -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<ReturnType<typeof readConfigFileSnapshotForWrite>>["snapshot"];
type ConfigWriteOptions = Awaited<
ReturnType<typeof readConfigFileSnapshotForWrite>
>["writeOptions"];
function resolveGatewayConfigPath(snapshot?: Pick<ConfigWriteSnapshot, "path">): string {
return snapshot?.path ?? createConfigIO().configPath;
}
function requireConfigBaseHash(
params: unknown,
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
@@ -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<ConfigValidationIssue>): 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<ConfigValidationI
}`;
}
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;
}
async function ensureResolvableSecretRefsOrRespond(params: {
config: OpenClawConfig;
respond: RespondFn;
@@ -330,70 +255,6 @@ async function ensureResolvableSecretRefsOrRespond(params: {
}
}
function resolveConfigRestartRequest(params: unknown): {
sessionKey: string | undefined;
note: string | undefined;
restartDelayMs: number | undefined;
deliveryContext: ReturnType<typeof extractDeliveryInfo>["deliveryContext"];
threadId: ReturnType<typeof extractDeliveryInfo>["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<typeof extractDeliveryInfo>["deliveryContext"];
threadId: ReturnType<typeof extractDeliveryInfo>["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<string | null> {
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<typeof resolveControlPlaneActor>;
context?: GatewayRequestContext;
}): Promise<{
payload: RestartSentinelPayload;
sentinelPath: string | null;
restart: ReturnType<typeof scheduleGatewaySigusr1Restart> | 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)) {

View File

@@ -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<string, MediaUnderstandingProvider> | null = null;
const configRegistryCache = new WeakMap<OpenClawConfig, Map<string, MediaUnderstandingProvider>>();
const configRegistryCache = new Map<string, Map<string, MediaUnderstandingProvider>>();
const MAX_CONFIG_REGISTRY_CACHE_ENTRIES = 32;
function cacheConfigRegistry(
key: string,
registry: Map<string, MediaUnderstandingProvider>,
): Map<string, MediaUnderstandingProvider> {
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(

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(); }",
]);
});
});

View File

@@ -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,

View File

@@ -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<string>();
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"] {