mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
refactor(config): tighten plugin config guardrails
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
5
scripts/lib/config-boundary-guard.d.mts
Normal file
5
scripts/lib/config-boundary-guard.d.mts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function collectDeprecatedInternalConfigApiViolations(options?: {
|
||||
repoRoot?: string;
|
||||
}): string[];
|
||||
|
||||
export function collectRuntimeActionLoadConfigViolations(options?: { repoRoot?: string }): string[];
|
||||
399
scripts/lib/config-boundary-guard.mjs
Normal file
399
scripts/lib/config-boundary-guard.mjs
Normal 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()}`]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
export function collectDeprecatedInternalConfigApiViolations(options?: {
|
||||
repoRoot?: string;
|
||||
}): string[];
|
||||
export { collectDeprecatedInternalConfigApiViolations } from "./config-boundary-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: /(?<!\.)\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";
|
||||
|
||||
221
src/gateway/server-methods/config-write-flow.ts
Normal file
221
src/gateway/server-methods/config-write-flow.ts
Normal 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 };
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
78
src/plugins/contracts/config-boundary-guard.test.ts
Normal file
78
src/plugins/contracts/config-boundary-guard.test.ts
Normal 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(); }",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
Reference in New Issue
Block a user