perf(doctor): delete the heavy doctor barrel and finish slimming enumeration (#120882)

* refactor(plugin-sdk): delete the heavy runtime-doctor barrel

Nothing may pull the state-db/kysely graph through a doctor barrel anymore.
The barrel's remaining heavy exports move to two narrow private-local
subpaths, each with a single purpose:

- doctor-repair-runtime: install-path diagnosis, plugin config removal, and
  state-database schema detect/repair (matrix doctor, voice-call lazy import)
- plugin-state-store-runtime: the sync keyed-store factory. It stays out of
  plugin-state-runtime because hot channel entrypoints import that at module
  load and opening a store pulls the state-database graph.

Doctor closures also stop pulling ssrf-runtime (fetch-guard + gateway net)
for two legacy private-network helpers that live in the lighter ssrf-policy
subpath: mattermost, nextcloud-talk, tlon, matrix.

The closure guard now forbids the two new heavy subpaths instead of the
deleted barrel, so the invariant keeps being enforced where it still applies.

* perf(doctor): keep heavy graphs out of every doctor closure

Doctor enumeration cold-loads each declaring plugin's contract closure, so
one heavy import in a closure is paid by the whole sweep. Four barrels were
still dragging unrelated graphs in for trivial helpers; each is repaired at
the leaf rather than by caching downstream:

- Legacy private-network config migration moves to a config leaf. It only
  reshapes records, but lived beside the SSRF runtime (DNS, proxy, logging),
  costing mattermost ~2.7s. ssrf-policy re-exports it, surface unchanged.
- Streaming config readers move to a leaf. They read two config keys, but
  streaming.ts also formats tool aggregates, pulling tool-display/logging/
  acp-core; that cost slack ~2.3s.
- signal took the channel-secret barrel for isRecord; the canonical plugin
  record guard is string-coerce-runtime (root AGENTS.md).
- llm-task took the provider-model barrel for parseModelRef, now a narrow
  model-ref-parse subpath.

Full doctor enumeration of all 42 declaring plugins, built mode:
legacy config rules 6668ms -> 1265ms, state migrations 184ms -> 127ms.
No plugin remains an outlier; the slowest is now ~380ms against a ~200ms floor.

Public export surfaces of every touched SDK subpath are byte-identical
(verified by diffing built module exports before/after); the API baseline
hashes move only because re-exported declarations emit differently.

The closure guard gains rules for each repaired barrel so the invariant
holds for future closures.

* fix(release): exclude new private-local declarations from the published package

Same pack-path rule as c41da3759f: private-local subpaths ship without d.ts.

* fix(doctor): repair the closure guard violations that break main

The landed guard fails on main: three closures import heavy barrels for one
symbol each. Two more surfaced once the guard learned about the provider-model
barrel. Each gets a narrow subpath at the leaf:

- telegram sent-message-cache + state-migrations took the session-store barrel
  (session accessor + state-db) for resolveStorePath -> session-store-paths
- discord thread-bindings.state took the channel-outbound barrel (reply
  pipeline + channel registry) for one identity write -> outbound-echo-runtime
- discord model-picker took the provider-model barrel for normalizeProviderId,
  which model-ref-parse now exposes beside parseModelRef

The guard also stops walking artifacts of plugins whose manifest declares no
doctor surface. Such a declaration gates the artifact off every enumeration
path exactly as resolvePluginDoctorContracts does, so its closure cost is never
paid; anthropic ("doctorContract": {}) was being held to a cost it cannot
incur. Absent declarations still load eagerly and stay enforced.

Side effect worth naming: discord's built doctor contract now loads again.
On main both discord and telegram fail to require in packaged builds (an
ESM-only transitive dep) and silently lose their repairs; this restores
discord and takes enumerated legacy config rules from 87 to 99. Telegram's
built artifact still pulls execa through dist chunking - a build-level defect
with a different owner, filed as follow-up.
This commit is contained in:
Peter Steinberger
2026-08-08 22:01:44 -07:00
committed by GitHub
parent 4e74ae229e
commit 6192673da4
41 changed files with 401 additions and 427 deletions
@@ -35,8 +35,8 @@ ba41c40956d6b4565605fa38c2d12f4b8471a0f8afe842798716b9032ee4d74a module/channel
df567ce2f4a4ba8a0937f825c46e83763412a724e36b72ce727dc4203c7dd134 module/channel-ingress-runtime
0e6efb79730fae59bb549ad00d9af2848c139b4bb1762e83b66886284e1c1421 module/channel-lifecycle
159d034b431d113f3a6dc41ec0bcadba2d6664051f158330b0e3dd3da8b5d42f module/channel-logging
e649231e5136e8a8045c098754adad4b2e7bf7acc29c759e02015d244f7f107a module/channel-message
146c87e18187fbf90d7780d946b614c89c415e084c7206de2f0be1928a2b3434 module/channel-outbound
3aaa573834c68f71490a0b0a8687cd942122cdca03d3b08781cf52220ab71544 module/channel-message
9182ed800c19f6324654dc62f0c78fc11e6a5ef18f4387284066e5b2104da848 module/channel-outbound
930beff13ed42a138f65164013c82f4f4c96422292c5fc634a5edee1dce71367 module/channel-pairing
ee4292b069d4d48cce4fc2dc26df5b5c87eb1fa4769f1f6be9a10c3e1221e1a9 module/channel-plugin-common
94ef57c8f6087fcaa56e59e493c391a04377ed03f23c681edd8d8f6e2d64e0da module/channel-policy
@@ -47,8 +47,8 @@ f3cee48527f5cddde81912a588e72947d40443322b716871a8d9e5dc0c37c542 module/channel
7c90157a95bc0523fc66b1f78ce140f7ec7dbf809dfa3a480244efc01f972754 module/channel-send-result
fb123c1b557ed2527e335f13c3d6de41ab0c3305151001cf2b1075d1da8034c5 module/channel-setup
aeac9bb8127faf636dde79772a577d9cb003660ef3d1df03ddf606101020c402 module/channel-status
f008d1d9ea73cd17e9833143bd117436599438523041fd157b6b6cb17500ac06 module/channel-streaming
d1ff8728d99acf8130b94db7ba75ad7050a549c96365f56b8d4f601a533ca947 module/channel-streaming-config
d70ef71f48f9fe5dc01bf13ba3a2ba515322f81dc8f32d08528a113fbc78e30e module/channel-streaming
7f4714752b40f014605e2e7d46006b519972f81b528a286e1a40758493fdef5d module/channel-streaming-config
14f0103adb14627b662fbe9fdd5ed08596ed702a250853e8beb8bb8dd1361588 module/cli-argv
c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collection-runtime
391e6f0c77da2e17a058fc5aea87fd193830b2a14e12fae77c7f63b0226f0bb5 module/command-auth
@@ -76,7 +76,7 @@ d117ebba8cc490501725778676a9d75855872b6e5fe2b2f64b1270d4808a2277 module/health
f6e3c44e7d1090a97aca554a3c247219b8de78b3cb4399cac5efde8a0a6c1156 module/inbound-envelope
1d5ca69fcf2a8476a2309dcd830fc3bbae04b7029fa30eea1081b6590a789f10 module/inbound-event-delivery
36721c58f479fe9ca32737f67850f4607bf8753ef6b193ff129e7a9e5fdbc6bd module/inbound-reply-dispatch
58675872c8985b66cfa32f3a1085bfb6d40c5abbafb3cd59d97b7bdde09dbe1a module/infra-runtime
03f7e1190d4101c486908383da099d545e59a6a90f5699b397c976ce6624c029 module/infra-runtime
2e717cccb3db127aed0287d4ea14c41a8e64e46d60c728638153e31e2fb0d296 module/ingress-effect-once
9389d91a090259f06e5ec2aae14c9857e45988d4edc4f91170cb8bc573b966a2 module/interactive-runtime
9dd66baf2def46386ad4706380e57f9068fa9bb3878be2d9ba961ec2f46d3d87 module/json-store
@@ -132,8 +132,8 @@ f112bdabc51ba8659b37d0a6f6a32a2b1d471e5b49b56e108bf750ec55a7ea71 module/session
df263e722a36ffa168522fa739924bec6b4b122ac3f1e25497a30962bec30607 module/setup-tools
18e384ec43d9eaee52c8e286e127bda2048370e2337964a754d94b236724ca9e module/skill-commands-runtime
ae469f32799380e6b045abaefefee6eb3f00d714ffbf36b6eeef5025dc529472 module/speech-settings
9e521fe9073dfd1a6a6855f909fa6befe8613e18403f0a65faaba973a8b630c1 module/ssrf-policy
f85d5be0f635de6a77bbd9224a37c373998e098a8dcf2048ddc2c9ac735b47ef module/ssrf-runtime
92f8e3fe8b29fa5050f30bcc7a19f1085a590064d9e8b6ac54991e60a691a61e module/ssrf-policy
19c2ac7f81de3203ab91fc88793cf7e0b5bf4eaa2d5d61f4ee9e9f34e3f56be8 module/ssrf-runtime
ff35f9f74d35d37a2eb6126b57f3dc5a4d580b6222fe3a34c9368779ad32eab7 module/state-paths
44cc071d9ba2baa26d6f33ba9407867fb07c33384f49d3e3d5210232022c1b5f module/status-helpers
537047854c21ad20ea0572f8019503cbda3bbafece8194a28c30b0639bde2fde module/string-coerce-runtime
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeProviderId } from "openclaw/plugin-sdk/model-ref-parse";
export function normalizeModelRef(raw?: string): string | null {
const value = raw?.trim();
@@ -1,5 +1,5 @@
// Discord plugin module implements thread bindings.state behavior.
import { recordOutboundMessageIdentity } from "openclaw/plugin-sdk/channel-outbound";
import { recordOutboundMessageIdentity } from "openclaw/plugin-sdk/outbound-echo-runtime";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
+1 -1
View File
@@ -1,6 +1,6 @@
// LLM Task doctor contract migrates shipped plugin-local completion policy.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { parseModelRef } from "openclaw/plugin-sdk/provider-model-shared";
import { parseModelRef } from "openclaw/plugin-sdk/model-ref-parse";
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor-migrations";
const ENTRY_PATH = "plugins.entries.llm-task";
+1 -1
View File
@@ -1,6 +1,6 @@
// Matrix plugin module implements auth presence behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/runtime-doctor";
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-store-runtime";
import {
MATRIX_CREDENTIALS_MAX_ENTRIES,
MATRIX_CREDENTIALS_NAMESPACE,
+2 -4
View File
@@ -9,12 +9,10 @@ import {
defineKeyMoveMigration,
hasLegacyAccountStreamingAliases,
normalizeChannelConfigEntries,
stripRetiredChannelKeys,
} from "openclaw/plugin-sdk/runtime-doctor-migrations";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
stripRetiredChannelKeys,
} from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { isRecord } from "./record-shared.js";
import type { MatrixStreamingMode } from "./types.js";
+1 -1
View File
@@ -5,7 +5,7 @@ import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
removePluginFromConfig,
} from "openclaw/plugin-sdk/runtime-doctor";
} from "openclaw/plugin-sdk/doctor-repair-runtime";
import {
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
@@ -3,7 +3,7 @@
// heavy sync plugin-state store access.
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/runtime-doctor";
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-store-runtime";
import { getOptionalMatrixRuntime } from "../runtime.js";
import {
MATRIX_CREDENTIALS_MAX_ENTRIES,
+4 -2
View File
@@ -1,8 +1,10 @@
// Mattermost plugin module implements doctor contract behavior.
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createLegacyPrivateNetworkDoctorContract,
defineChannelAliasMigration,
} from "openclaw/plugin-sdk/runtime-doctor-migrations";
const networkContract = createLegacyPrivateNetworkDoctorContract({
channelKey: "mattermost",
@@ -1,8 +1,10 @@
// Nextcloud Talk plugin module implements doctor contract behavior.
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createLegacyPrivateNetworkDoctorContract,
defineChannelAliasMigration,
} from "openclaw/plugin-sdk/runtime-doctor-migrations";
const networkContract = createLegacyPrivateNetworkDoctorContract({
channelKey: "nextcloud-talk",
+1 -1
View File
@@ -7,8 +7,8 @@ import { resolveModelRefFromString } from "openclaw/plugin-sdk/agent-runtime";
import { formatErrorMessage as formatQaErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-host-core";
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-store-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/runtime-doctor";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import * as browserRuntime from "./browser-runtime.js";
import * as cronRunWait from "./cron-run-wait.js";
+1 -1
View File
@@ -3,9 +3,9 @@ import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { migrateLegacySignalTransportConfigSync } from "./src/config-compat.js";
const RETIRED_SIGNAL_ACCOUNT_TRANSPORT_FIELDS = [
+1 -1
View File
@@ -1,8 +1,8 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
// Signal compatibility migration moves shipped flat transport config into account ownership.
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SignalTransportConfig } from "./account-types.js";
import {
allocateSignalManagedNativePort,
+1 -1
View File
@@ -16,7 +16,7 @@ import {
requireAllowlistAllowFrom,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-schema";
import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { signalChannelConfigUiHints } from "./config-ui-hints.js";
+1 -1
View File
@@ -6,8 +6,8 @@ import {
type ChannelIngressMonitorDeliveryResult,
type ChannelIngressMonitorLifecycle,
} from "openclaw/plugin-sdk/channel-outbound";
import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeNullableString as normalizeRawString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SignalSseEvent } from "./client-adapter.js";
import { getOptionalSignalRuntime } from "./runtime.js";
@@ -5,7 +5,7 @@ import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-paths";
import { getTelegramRuntime } from "./runtime.js";
const TTL_MS = 24 * 60 * 60 * 1000;
+1 -1
View File
@@ -5,7 +5,7 @@ import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { fileExists } from "openclaw/plugin-sdk/security-runtime";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-paths";
import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { listTelegramAccountIds, resolveDefaultTelegramAccountId } from "./account-selection.js";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
// Tlon plugin module implements doctor contract behavior.
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/runtime-doctor-migrations";
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "tlon",
@@ -11,9 +11,6 @@
"openclaw/plugin-sdk/sandbox": [
"../packages/plugin-sdk/dist/src/plugin-sdk/sandbox.d.ts"
],
"openclaw/plugin-sdk/runtime-doctor": [
"../packages/plugin-sdk/dist/src/plugin-sdk/runtime-doctor.d.ts"
],
"openclaw/plugin-sdk/runtime-doctor-migrations": [
"../packages/plugin-sdk/dist/src/plugin-sdk/runtime-doctor-migrations.d.ts"
],
@@ -879,6 +876,21 @@
],
"@openclaw/plugin-sdk/*": [
"../dist/plugin-sdk/*.d.ts"
],
"openclaw/plugin-sdk/doctor-repair-runtime": [
"../packages/plugin-sdk/dist/src/plugin-sdk/doctor-repair-runtime.d.ts"
],
"openclaw/plugin-sdk/plugin-state-store-runtime": [
"../packages/plugin-sdk/dist/src/plugin-sdk/plugin-state-store-runtime.d.ts"
],
"openclaw/plugin-sdk/model-ref-parse": [
"../packages/plugin-sdk/dist/src/plugin-sdk/model-ref-parse.d.ts"
],
"openclaw/plugin-sdk/outbound-echo-runtime": [
"../packages/plugin-sdk/dist/src/plugin-sdk/outbound-echo-runtime.d.ts"
],
"openclaw/plugin-sdk/session-store-paths": [
"../packages/plugin-sdk/dist/src/plugin-sdk/session-store-paths.d.ts"
]
}
}
+5 -5
View File
@@ -2,11 +2,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
// Doctor enumeration cold-loads this closure; the state-DB helpers stay behind a
// lazy doctor-repair-runtime import so enumeration never pulls the kysely/state-db graph.
import type { OpenClawStateDatabaseSchemaMigration } from "openclaw/plugin-sdk/doctor-repair-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
// Doctor enumeration cold-loads this closure; the state-DB helpers stay behind a
// lazy runtime-doctor import so enumeration never pulls the kysely/state-db graph.
import type { OpenClawStateDatabaseSchemaMigration } from "openclaw/plugin-sdk/runtime-doctor";
import {
archiveLegacyStateSource,
type PluginDoctorStateMigration,
@@ -304,7 +304,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
label: "Voice Call call log",
async detectLegacyState(params) {
const { detectOpenClawStateDatabaseSchemaMigrations } =
await import("openclaw/plugin-sdk/runtime-doctor");
await import("openclaw/plugin-sdk/doctor-repair-runtime");
const storePath = resolveVoiceCallStorePath(params);
const filePath = resolveVoiceCallLegacyCallLogPath(storePath);
const { entries } = await readLegacyCallRecords(filePath);
@@ -330,7 +330,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
},
async migrateLegacyState(params) {
const { detectOpenClawStateDatabaseSchemaMigrations, repairOpenClawStateDatabaseSchema } =
await import("openclaw/plugin-sdk/runtime-doctor");
await import("openclaw/plugin-sdk/doctor-repair-runtime");
const changes: string[] = [];
const warnings: string[] = [];
const storePath = resolveVoiceCallStorePath(params);
+15 -3
View File
@@ -11,9 +11,6 @@
"openclaw/plugin-sdk/sandbox": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/sandbox.d.ts"
],
"openclaw/plugin-sdk/runtime-doctor": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/runtime-doctor.d.ts"
],
"openclaw/plugin-sdk/runtime-doctor-migrations": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/runtime-doctor-migrations.d.ts"
],
@@ -866,6 +863,21 @@
],
"@openclaw/ollama/runtime-api.js": [
"./.boundary-stubs/ollama-runtime-api.d.ts"
],
"openclaw/plugin-sdk/doctor-repair-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/doctor-repair-runtime.d.ts"
],
"openclaw/plugin-sdk/plugin-state-store-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-state-store-runtime.d.ts"
],
"openclaw/plugin-sdk/model-ref-parse": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/model-ref-parse.d.ts"
],
"openclaw/plugin-sdk/outbound-echo-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/outbound-echo-runtime.d.ts"
],
"openclaw/plugin-sdk/session-store-paths": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/session-store-paths.d.ts"
]
}
}
+19 -2
View File
@@ -183,6 +183,11 @@
"!dist/plugin-sdk/response-limit-runtime.d.ts",
"!dist/plugin-sdk/retry-runtime.d.ts",
"!dist/plugin-sdk/runtime-doctor-migrations.d.ts",
"!dist/plugin-sdk/doctor-repair-runtime.d.ts",
"!dist/plugin-sdk/model-ref-parse.d.ts",
"!dist/plugin-sdk/outbound-echo-runtime.d.ts",
"!dist/plugin-sdk/plugin-state-store-runtime.d.ts",
"!dist/plugin-sdk/session-store-paths.d.ts",
"!dist/plugin-sdk/runtime-doctor.d.ts",
"!dist/plugin-sdk/runtime-fetch.d.ts",
"!dist/plugin-sdk/sandbox.d.ts",
@@ -402,8 +407,20 @@
"types": "./dist/plugin-sdk/health.d.ts",
"default": "./dist/plugin-sdk/health.js"
},
"./plugin-sdk/runtime-doctor": {
"default": "./dist/plugin-sdk/runtime-doctor.js"
"./plugin-sdk/doctor-repair-runtime": {
"default": "./dist/plugin-sdk/doctor-repair-runtime.js"
},
"./plugin-sdk/model-ref-parse": {
"default": "./dist/plugin-sdk/model-ref-parse.js"
},
"./plugin-sdk/outbound-echo-runtime": {
"default": "./dist/plugin-sdk/outbound-echo-runtime.js"
},
"./plugin-sdk/session-store-paths": {
"default": "./dist/plugin-sdk/session-store-paths.js"
},
"./plugin-sdk/plugin-state-store-runtime": {
"default": "./dist/plugin-sdk/plugin-state-store-runtime.js"
},
"./plugin-sdk/runtime-doctor-migrations": {
"default": "./dist/plugin-sdk/runtime-doctor-migrations.js"
-4
View File
@@ -192,10 +192,6 @@
"types": "./dist/src/plugin-sdk/provider-web-search-config-contract.d.ts",
"default": "./src/provider-web-search-config-contract.ts"
},
"./runtime-doctor": {
"types": "./dist/src/plugin-sdk/runtime-doctor.d.ts",
"default": "./src/runtime-doctor.ts"
},
"./runtime-env": {
"types": "./dist/src/plugin-sdk/runtime-env.d.ts",
"default": "./src/runtime-env.ts"
@@ -1,3 +0,0 @@
// Public package facade for runtime doctor contracts.
export * from "../../../src/plugin-sdk/runtime-doctor.js";
+5 -1
View File
@@ -5,7 +5,11 @@
"routing",
"runtime",
"health",
"runtime-doctor",
"doctor-repair-runtime",
"model-ref-parse",
"outbound-echo-runtime",
"session-store-paths",
"plugin-state-store-runtime",
"runtime-doctor-migrations",
"runtime-env",
"proxy-capture",
@@ -40,6 +40,7 @@
"delivery-queue-runtime",
"direct-dm-guard-policy",
"directory-config-runtime",
"doctor-repair-runtime",
"document-extractor",
"embedding-providers",
"exec-approvals-runtime",
@@ -80,12 +81,15 @@
"message-tool-delivery-hints",
"migration",
"migration-runtime",
"model-ref-parse",
"music-generation",
"node-host",
"number-runtime",
"outbound-echo-runtime",
"outbound-media",
"pair-loop-guard-runtime",
"plugin-state-runtime",
"plugin-state-store-runtime",
"plugin-state-test-runtime",
"plugin-test-api",
"plugin-test-contracts",
@@ -127,15 +131,14 @@
"qa-runtime",
"realtime-bootstrap-context",
"realtime-transcription",
"realtime-voice-audio-queue",
"realtime-voice-activation",
"realtime-voice",
"realtime-voice-activation",
"realtime-voice-audio-queue",
"reply-payload-testing",
"reply-reference",
"request-url",
"response-limit-runtime",
"retry-runtime",
"runtime-doctor",
"runtime-doctor-migrations",
"runtime-fetch",
"sandbox",
@@ -144,6 +147,7 @@
"session-binding-runtime",
"session-catalog-runtime",
"session-key-runtime",
"session-store-paths",
"session-transcript-hit",
"session-transcript-runtime",
"session-visibility",
+24
View File
@@ -0,0 +1,24 @@
// Pure readers for a channel entry's `streaming` config object.
//
// Split out of `streaming.ts` because that module also formats tool aggregates
// and therefore value-loads the tool-display/logging graph. Doctor contract
// closures read streaming config during config-compat migration, and doctor
// enumeration cold-loads those closures for every declaring plugin.
import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce";
import type { ChannelStreamingConfig } from "../config/types.base.js";
import { asBoolean } from "../utils/boolean.js";
export type StreamingCompatEntry = { streaming?: unknown };
export function getChannelStreamingConfigObject(
entry: StreamingCompatEntry | null | undefined,
): ChannelStreamingConfig | undefined {
const streaming = asObjectRecord(entry?.streaming);
return streaming ? (streaming as ChannelStreamingConfig) : undefined;
}
export function resolveChannelStreamingNativeTransport(
entry: StreamingCompatEntry | null | undefined,
): boolean | undefined {
return asBoolean(getChannelStreamingConfigObject(entry)?.nativeTransport);
}
+9 -15
View File
@@ -13,7 +13,6 @@ import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
ChannelStreamingCommandTextMode,
ChannelStreamingConfig,
ChannelStreamingProgressConfig,
StreamingMode,
TextChunkMode,
@@ -23,8 +22,16 @@ import {
selectProgressLabel,
} from "../shared/progress-labels.js";
import { asBoolean } from "../utils/boolean.js";
import {
getChannelStreamingConfigObject,
type StreamingCompatEntry,
} from "./streaming-config-readers.js";
export type StreamingCompatEntry = { streaming?: unknown };
export {
getChannelStreamingConfigObject,
resolveChannelStreamingNativeTransport,
} from "./streaming-config-readers.js";
export type { StreamingCompatEntry } from "./streaming-config-readers.js";
export type {
ChannelDeliveryStreamingConfig,
@@ -756,13 +763,6 @@ export function createChannelProgressDraftGate(params: {
};
}
export function getChannelStreamingConfigObject(
entry: StreamingCompatEntry | null | undefined,
): ChannelStreamingConfig | undefined {
const streaming = asObjectRecord(entry?.streaming);
return streaming ? (streaming as ChannelStreamingConfig) : undefined;
}
export function resolveChannelStreamingChunkMode(
entry: StreamingCompatEntry | null | undefined,
): TextChunkMode | undefined {
@@ -911,12 +911,6 @@ export function resolveChannelStreamingSuppressDefaultToolProgressMessages(
return options?.previewToolProgressEnabled ?? resolveChannelStreamingPreviewToolProgress(entry);
}
export function resolveChannelStreamingNativeTransport(
entry: StreamingCompatEntry | null | undefined,
): boolean | undefined {
return asBoolean(getChannelStreamingConfigObject(entry)?.nativeTransport);
}
export function resolveChannelPreviewStreamMode(
entry: StreamingCompatEntry | null | undefined,
defaultMode: StreamingMode,
@@ -0,0 +1,155 @@
// Doctor migration for the retired flat `allowPrivateNetwork` channel key.
//
// Kept as a leaf beside the other channel alias migrations, not in
// `plugin-sdk/ssrf-policy`: these helpers only reshape config records, while
// that module value-loads the SSRF runtime (DNS, proxy state, logging). Doctor
// enumeration cold-loads every declaring plugin's contract closure, so a
// closure reaching this policy through the runtime graph paid for hundreds of
// modules it never called.
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "../plugin-sdk/channel-contract.js";
import type { OpenClawConfig } from "./types.openclaw.js";
/** Detects the retired flat `allowPrivateNetwork` key before doctor migration. */
export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean {
const entry = asNullableRecord(value);
return Boolean(entry && Object.hasOwn(entry, "allowPrivateNetwork"));
}
/** Moves flat private-network config into `network.dangerouslyAllowPrivateNetwork`. */
export function migrateLegacyFlatAllowPrivateNetworkAlias(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
if (!hasLegacyFlatAllowPrivateNetworkAlias(params.entry)) {
return { entry: params.entry, changed: false };
}
const legacyAllowPrivateNetwork = params.entry.allowPrivateNetwork;
const currentNetworkRecord = asNullableRecord(params.entry.network);
const currentNetwork = currentNetworkRecord ? { ...currentNetworkRecord } : {};
const currentDangerousAllowPrivateNetwork = currentNetwork.dangerouslyAllowPrivateNetwork;
let resolvedDangerousAllowPrivateNetwork: unknown = currentDangerousAllowPrivateNetwork;
if (typeof currentDangerousAllowPrivateNetwork === "boolean") {
// The canonical key wins when both shapes are present.
resolvedDangerousAllowPrivateNetwork = currentDangerousAllowPrivateNetwork;
} else if (typeof legacyAllowPrivateNetwork === "boolean") {
resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork;
} else if (currentDangerousAllowPrivateNetwork === undefined) {
resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork;
}
delete currentNetwork.dangerouslyAllowPrivateNetwork;
if (resolvedDangerousAllowPrivateNetwork !== undefined) {
currentNetwork.dangerouslyAllowPrivateNetwork = resolvedDangerousAllowPrivateNetwork;
}
const nextEntry = { ...params.entry };
delete nextEntry.allowPrivateNetwork;
if (Object.keys(currentNetwork).length > 0) {
nextEntry.network = currentNetwork;
} else {
delete nextEntry.network;
}
params.changes.push(
`Moved ${params.pathPrefix}.allowPrivateNetwork → ${params.pathPrefix}.network.dangerouslyAllowPrivateNetwork (${String(resolvedDangerousAllowPrivateNetwork)}).`,
);
return { entry: nextEntry, changed: true };
}
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = asNullableRecord(value);
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(asNullableRecord(account) ?? {}),
),
);
}
/** Build doctor rules that migrate legacy private-network aliases for one channel config. */
export function createLegacyPrivateNetworkDoctorContract(params: { channelKey: string }): {
legacyConfigRules: ChannelDoctorLegacyConfigRule[];
normalizeCompatibilityConfig: (params: { cfg: OpenClawConfig }) => ChannelDoctorConfigMutation;
} {
const pathPrefix = `channels.${params.channelKey}`;
return {
legacyConfigRules: [
{
path: ["channels", params.channelKey],
message: `${pathPrefix}.allowPrivateNetwork is legacy; use ${pathPrefix}.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(asNullableRecord(value) ?? {}),
},
{
path: ["channels", params.channelKey, "accounts"],
message: `${pathPrefix}.accounts.<id>.allowPrivateNetwork is legacy; use ${pathPrefix}.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: hasLegacyAllowPrivateNetworkInAccounts,
},
],
normalizeCompatibilityConfig: ({ cfg }) => {
const channels = asNullableRecord(cfg.channels);
const channelEntry = asNullableRecord(channels?.[params.channelKey]);
if (!channelEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedChannel = channelEntry;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedChannel,
pathPrefix,
changes,
});
updatedChannel = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = asNullableRecord(updatedChannel.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asNullableRecord(accountValue);
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `${pathPrefix}.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedChannel = { ...updatedChannel, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
[params.channelKey]: updatedChannel,
} as OpenClawConfig["channels"],
},
changes,
};
},
};
}
+1 -1
View File
@@ -9,4 +9,4 @@
export {
getChannelStreamingConfigObject,
resolveChannelStreamingNativeTransport,
} from "../channels/streaming.js";
} from "../channels/streaming-config-readers.js";
+21
View File
@@ -0,0 +1,21 @@
/**
* Heavy doctor repair operations: plugin install-path diagnosis, plugin config
* removal, and OpenClaw state-database schema detection/repair.
*
* These pull the state-database and plugin-registry graphs, so they are kept off
* `runtime-doctor-migrations` (the dependency-light subpath doctor contract
* closures import). Doctor enumeration cold-loads those closures per plugin;
* only code that actually performs a repair should reach this subpath, and
* closure-resident callers must import it lazily.
*/
export {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../infra/plugin-install-path-warnings.js";
export { removePluginFromConfig } from "../plugins/uninstall-config.js";
export {
detectOpenClawStateDatabaseSchemaMigrations,
repairOpenClawStateDatabaseSchema,
} from "../state/openclaw-state-db.js";
export type { OpenClawStateDatabaseSchemaMigration } from "../state/openclaw-state-db.js";
+9
View File
@@ -0,0 +1,9 @@
// Canonical provider/model reference parsing and provider-id normalization.
//
// Split from the `provider-model-shared` barrel, which also value-loads replay
// policy, endpoint, and catalog-compat helpers. Doctor contract closures
// normalize model refs during config migration and doctor enumeration
// cold-loads those closures for every declaring plugin.
export { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
export { parseModelRef } from "../agents/model-selection-normalize.js";
+7
View File
@@ -0,0 +1,7 @@
// Outbound message identity recording for echo suppression.
//
// Split from the `channel-outbound` barrel, which also value-loads the
// reply-pipeline/channel-registry graph that doctor enumeration must not
// cold-load from a legacy-setup closure.
export { recordOutboundMessageIdentity } from "../channels/message/outbound-echo.js";
@@ -0,0 +1,10 @@
/**
* Synchronous keyed plugin-state store factory.
*
* Separate from `plugin-state-runtime`, which stays type-only plus light
* helpers because hot channel entrypoints import it at module load; opening a
* store pulls the state-database graph, so only callers that actually read or
* write state take that cost.
*/
export { createPluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js";
@@ -15,6 +15,11 @@ import { archiveLegacyStateSource } from "../plugins/doctor-state-migration-fs.j
export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
export { defineChannelAliasMigration } from "../config/channel-alias-migration.js";
export {
createLegacyPrivateNetworkDoctorContract,
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "../config/legacy-private-network-migration.js";
export type {
ChannelAliasMigrationSpec,
StreamingAliasMode,
-150
View File
@@ -1,150 +0,0 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "./config-contracts.js";
import {
collectChannelAccountScopes,
defineKeyMoveMigration,
normalizeChannelConfigEntries,
stripRetiredChannelKeys,
} from "./runtime-doctor.js";
function cfgWith(entry: Record<string, unknown>): OpenClawConfig {
return { channels: { sample: entry } } as never;
}
describe("runtime-doctor channel helpers", () => {
it("collects the channel root and object-shaped accounts in config order", () => {
expect(
collectChannelAccountScopes({
cfg: cfgWith({ accounts: { work: { enabled: true }, invalid: "skip" } }),
channelId: "sample",
}),
).toEqual([
{
prefix: "channels.sample",
pathSegments: ["channels", "sample"],
account: { accounts: { work: { enabled: true }, invalid: "skip" } },
},
{
prefix: "channels.sample.accounts.work",
pathSegments: ["channels", "sample", "accounts", "work"],
account: { enabled: true },
},
]);
});
it("moves nested keys across wildcard entries and preserves canonical values", () => {
const migration = defineKeyMoveMigration({
scope: ["groups", "*"],
from: ["allow"],
to: ["enabled"],
});
const entry = {
groups: {
first: { allow: true },
second: { allow: true, enabled: false },
invalid: "skip",
},
};
const changes: string[] = [];
expect(migration.hasLegacy(entry)).toBe(true);
expect(migration.normalize({ entry, pathPrefix: "channels.sample", changes })).toEqual({
entry: {
groups: {
first: { enabled: true },
second: { enabled: false },
invalid: "skip",
},
},
changed: true,
});
expect(changes).toEqual([
"Moved channels.sample.groups.first.allow → channels.sample.groups.first.enabled.",
"Removed channels.sample.groups.second.allow (channels.sample.groups.second.enabled already set).",
]);
});
it("supports mapped values, invalid removal, and empty-parent pruning", () => {
const migration = defineKeyMoveMigration({
from: ["thread", "requireExplicitMention"],
to: ["implicitMentions", "threadParticipation"],
map: (value) => (typeof value === "boolean" ? { value: !value } : null),
pruneEmptySource: true,
});
const changes: string[] = [];
const moved = migration.normalize({
entry: { thread: { requireExplicitMention: true } },
pathPrefix: "channels.sample",
changes,
});
expect(moved.entry).toEqual({ implicitMentions: { threadParticipation: false } });
expect(
migration.normalize({
entry: { thread: { requireExplicitMention: "yes" } },
pathPrefix: "channels.sample",
changes,
}).entry,
).toEqual({});
expect(changes).toEqual([
"Moved channels.sample.thread.requireExplicitMention → channels.sample.implicitMentions.threadParticipation.",
"Removed invalid channels.sample.thread.requireExplicitMention value.",
]);
});
it("normalizes the root and every object-shaped account in order", () => {
const changeLog: string[] = [];
const result = normalizeChannelConfigEntries({
cfg: cfgWith({ legacy: true, accounts: { work: { legacy: false }, invalid: "skip" } }),
channelId: "sample",
changes: changeLog,
normalizeEntry: ({ entry, pathPrefix, changes }) => {
if (!Object.hasOwn(entry, "legacy")) {
return { entry, changed: false };
}
const { legacy: _legacy, ...rest } = entry;
changes.push(`Removed ${pathPrefix}.legacy.`);
return { entry: rest, changed: true };
},
});
expect(result.changes).toBe(changeLog);
expect(result.changes).toEqual([
"Removed channels.sample.legacy.",
"Removed channels.sample.accounts.work.legacy.",
]);
expect((result.config.channels as Record<string, unknown>).sample).toEqual({
accounts: { work: {}, invalid: "skip" },
});
});
it("supports recursive and root-account-only retired key scopes", () => {
const source = cfgWith({
retired: 1,
nested: { retired: 2 },
accounts: { work: { retired: 3, nested: { retired: 4 } } },
});
const keys = new Set(["retired"]);
const scoped = stripRetiredChannelKeys({
cfg: source,
channelId: "sample",
keys,
scope: "root-and-accounts",
});
expect((scoped.config.channels as Record<string, unknown>).sample).toEqual({
nested: { retired: 2 },
accounts: { work: { nested: { retired: 4 } } },
});
const recursive = stripRetiredChannelKeys({
cfg: source,
channelId: "sample",
keys,
scope: "recursive",
});
expect((recursive.config.channels as Record<string, unknown>).sample).toEqual({
nested: {},
accounts: { work: { nested: {} } },
});
});
});
-56
View File
@@ -1,56 +0,0 @@
/**
* Runtime SDK subpath for plugin doctor migrations, compat checks, and uninstall helpers.
*
* This barrel value-loads the state DB and plugin state store graphs. Doctor
* contract closures (`doctor-contract-api.ts` and its imports) must use the
* dependency-light `runtime-doctor-migrations` subpath instead so doctor
* enumeration stays cheap; enumeration cold-loads those closures per plugin.
*/
export {
archiveLegacyStateSource,
asObjectRecord,
collectChannelAccountScopes,
collectProviderDangerousNameMatchingScopes,
defineChannelAliasMigration,
defineKeyMoveMigration,
defineLegacyJsonStateMigration,
definePluginDoctorMigrationFromPlans,
buildLegacyMigrationPreview,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
legacyStateFileExists,
materializeInheritedAccountStreaming,
normalizeChannelAccounts,
normalizeChannelConfigEntries,
normalizeLegacyChannelAliases,
normalizeLegacyDmAliases,
normalizeLegacyStreamingAliases,
resolveLegacyAliasStreamingMode,
stripRetiredChannelKeys,
} from "./runtime-doctor-migrations.js";
export type {
ChannelAliasMigrationSpec,
CompatMutationResult,
DoctorSessionRouteStateOwner,
LegacyStreamingAliasOptions,
NormalizeChannelConfigEntryParams,
NormalizeLegacyChannelAccountParams,
OpenKeyedStoreOptions,
PluginDoctorStateMigration,
PluginDoctorStateMigrationContext,
PluginStateKeyedStore,
RetiredChannelKeyRemoval,
StreamingAliasMode,
} from "./runtime-doctor-migrations.js";
export {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../infra/plugin-install-path-warnings.js";
export { createPluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js";
export {
detectOpenClawStateDatabaseSchemaMigrations,
repairOpenClawStateDatabaseSchema,
} from "../state/openclaw-state-db.js";
export type { OpenClawStateDatabaseSchemaMigration } from "../state/openclaw-state-db.js";
export { removePluginFromConfig } from "../plugins/uninstall-config.js";
+7
View File
@@ -0,0 +1,7 @@
// Session store path resolution.
//
// Split from the `session-store-runtime` barrel, which also value-loads the
// session accessor/state-database graph. Doctor legacy-state closures only need
// to locate a store file, and doctor enumeration cold-loads those closures.
export { resolveStorePath } from "../config/sessions/paths.js";
+8 -146
View File
@@ -10,11 +10,6 @@ import {
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "./channel-contract.js";
import type { OpenClawConfig } from "./config-runtime.js";
export { isPrivateIpAddress, mergeSsrFPolicies };
export type { SsrFPolicy };
@@ -69,147 +64,14 @@ export function ssrfPolicyFromDangerouslyAllowPrivateNetwork(
return ssrfPolicyFromPrivateNetworkOptIn(dangerouslyAllowPrivateNetwork);
}
/** Detects the retired flat `allowPrivateNetwork` key before doctor migration. */
export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean {
const entry = asNullableRecord(value);
return Boolean(entry && Object.hasOwn(entry, "allowPrivateNetwork"));
}
/** Moves flat private-network config into `network.dangerouslyAllowPrivateNetwork`. */
export function migrateLegacyFlatAllowPrivateNetworkAlias(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
if (!hasLegacyFlatAllowPrivateNetworkAlias(params.entry)) {
return { entry: params.entry, changed: false };
}
const legacyAllowPrivateNetwork = params.entry.allowPrivateNetwork;
const currentNetworkRecord = asNullableRecord(params.entry.network);
const currentNetwork = currentNetworkRecord ? { ...currentNetworkRecord } : {};
const currentDangerousAllowPrivateNetwork = currentNetwork.dangerouslyAllowPrivateNetwork;
let resolvedDangerousAllowPrivateNetwork: unknown = currentDangerousAllowPrivateNetwork;
if (typeof currentDangerousAllowPrivateNetwork === "boolean") {
// The canonical key wins when both shapes are present.
resolvedDangerousAllowPrivateNetwork = currentDangerousAllowPrivateNetwork;
} else if (typeof legacyAllowPrivateNetwork === "boolean") {
resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork;
} else if (currentDangerousAllowPrivateNetwork === undefined) {
resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork;
}
delete currentNetwork.dangerouslyAllowPrivateNetwork;
if (resolvedDangerousAllowPrivateNetwork !== undefined) {
currentNetwork.dangerouslyAllowPrivateNetwork = resolvedDangerousAllowPrivateNetwork;
}
const nextEntry = { ...params.entry };
delete nextEntry.allowPrivateNetwork;
if (Object.keys(currentNetwork).length > 0) {
nextEntry.network = currentNetwork;
} else {
delete nextEntry.network;
}
params.changes.push(
`Moved ${params.pathPrefix}.allowPrivateNetwork → ${params.pathPrefix}.network.dangerouslyAllowPrivateNetwork (${String(resolvedDangerousAllowPrivateNetwork)}).`,
);
return { entry: nextEntry, changed: true };
}
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = asNullableRecord(value);
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(asNullableRecord(account) ?? {}),
),
);
}
/** Build doctor rules that migrate legacy private-network aliases for one channel config. */
export function createLegacyPrivateNetworkDoctorContract(params: { channelKey: string }): {
legacyConfigRules: ChannelDoctorLegacyConfigRule[];
normalizeCompatibilityConfig: (params: { cfg: OpenClawConfig }) => ChannelDoctorConfigMutation;
} {
const pathPrefix = `channels.${params.channelKey}`;
return {
legacyConfigRules: [
{
path: ["channels", params.channelKey],
message: `${pathPrefix}.allowPrivateNetwork is legacy; use ${pathPrefix}.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(asNullableRecord(value) ?? {}),
},
{
path: ["channels", params.channelKey, "accounts"],
message: `${pathPrefix}.accounts.<id>.allowPrivateNetwork is legacy; use ${pathPrefix}.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: hasLegacyAllowPrivateNetworkInAccounts,
},
],
normalizeCompatibilityConfig: ({ cfg }) => {
const channels = asNullableRecord(cfg.channels);
const channelEntry = asNullableRecord(channels?.[params.channelKey]);
if (!channelEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedChannel = channelEntry;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedChannel,
pathPrefix,
changes,
});
updatedChannel = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = asNullableRecord(updatedChannel.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asNullableRecord(accountValue);
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `${pathPrefix}.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedChannel = { ...updatedChannel, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
[params.channelKey]: updatedChannel,
} as OpenClawConfig["channels"],
},
changes,
};
},
};
}
// Config-shape doctor migration lives in a leaf module so doctor contract
// closures never load this file's SSRF runtime graph; re-exported here for
// existing callers.
export {
createLegacyPrivateNetworkDoctorContract,
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "../config/legacy-private-network-migration.js";
/** @deprecated Use `ssrfPolicyFromDangerouslyAllowPrivateNetwork`. */
export function ssrfPolicyFromAllowPrivateNetwork(
@@ -264,8 +264,8 @@ describe("opt-in extension package boundaries", () => {
expect(packageJson.exports?.["./provider-web-search-config-contract"]?.types).toBe(
"./dist/src/plugin-sdk/provider-web-search-config-contract.d.ts",
);
expect(packageJson.exports?.["./runtime-doctor"]?.types).toBe(
"./dist/src/plugin-sdk/runtime-doctor.d.ts",
expect(packageJson.exports?.["./runtime-env"]?.types).toBe(
"./dist/src/plugin-sdk/runtime-env.d.ts",
);
expect(packageJson.exports?.["./security-runtime"]?.types).toBe(
"./dist/src/plugin-sdk/security-runtime.d.ts",
@@ -26,14 +26,50 @@ const FORBIDDEN_SPECIFIER_RULES = new Map<string, { reason: string; kinds: Set<C
},
],
[
"openclaw/plugin-sdk/runtime-doctor",
"openclaw/plugin-sdk/doctor-repair-runtime",
{
reason:
"the heavy doctor barrel makes doctor enumeration cold-load the state-db/kysely graph; " +
"use openclaw/plugin-sdk/runtime-doctor-migrations, or defer heavy helpers behind a dynamic import",
"install-path, uninstall, and state-db schema repair cold-load the state-db/kysely graph; " +
"use openclaw/plugin-sdk/runtime-doctor-migrations, or defer the repair behind a dynamic import",
kinds: new Set(["doctor-contract", "legacy-setup"]),
},
],
[
"openclaw/plugin-sdk/ssrf-runtime",
{
reason:
"the SSRF runtime barrel cold-loads DNS, proxy state, and logging; " +
"legacy private-network config migration lives in openclaw/plugin-sdk/runtime-doctor-migrations",
kinds: new Set(["doctor-contract", "legacy-setup"]),
},
],
[
"openclaw/plugin-sdk/provider-model-shared",
{
reason:
"the provider-model barrel cold-loads replay/endpoint/catalog helpers; " +
"use openclaw/plugin-sdk/model-ref-parse for provider/model reference parsing",
kinds: new Set(["doctor-contract"]),
},
],
[
"openclaw/plugin-sdk/channel-secret-basic-runtime",
{
reason:
"the channel-secret barrel cold-loads secret-ref/account-routing modules; " +
"the canonical record guard is openclaw/plugin-sdk/string-coerce-runtime",
kinds: new Set(["doctor-contract"]),
},
],
[
"openclaw/plugin-sdk/plugin-state-store-runtime",
{
reason:
"opening a keyed plugin-state store cold-loads the state-db/kysely graph; " +
"keep the store behind the migration context or a dynamic import inside async bodies",
kinds: new Set(["doctor-contract"]),
},
],
[
"openclaw/plugin-sdk/session-store-runtime",
{
@@ -221,7 +257,13 @@ function collectClosureEntries(): ClosureEntry[] {
for (const record of loadBundledPluginManifestRegistry({ env }).plugins) {
const pluginRoot = path.resolve(record.rootDir);
const doctorContractPath = resolvePluginDoctorContractArtifactPath(pluginRoot);
if (doctorContractPath) {
// A declaration listing no surface gates the artifact off every enumeration
// path, exactly as `resolvePluginDoctorContracts` does, so its closure cost
// is never paid. Absent declarations still load eagerly and are enforced.
const declaresAnyDoctorSurface =
!record.doctorContract ||
Object.values(record.doctorContract).some((value) => value ?? false);
if (doctorContractPath && declaresAnyDoctorSurface) {
entries.push({
pluginId: record.id,
pluginRoot,