diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index ae9cfe6a754e..515646e4dac4 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -7,6 +7,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c 50893fb8eac4090735ecb677c96180690804fb7354fd4e88147f3f956cefab88 module/agent-harness-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload e82bf122ca0787ec0bcdd12b669cfe24f07da16be1f4090e84c7d645b07ac79f module/agent-runtime +770bcb35221a019c70eace0200233a3a7985b24a6df48625f3e67b478d071ed1 module/agent-scope-runtime 56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from 55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit 7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 293215aa1833..ec401410977e 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -149,7 +149,7 @@ are private-local. | `plugin-sdk/provider-auth-result` | Private-local after July 2026; Standard OAuth auth-result builder | | `plugin-sdk/provider-env-vars` | Private-local after July 2026; Provider auth env-var lookup helpers | | `plugin-sdk/provider-auth` | `createProviderApiKeyAuthMethod`, `ensureApiKeyFromOptionEnvOrPrompt`, `upsertAuthProfile`, `upsertApiKeyProfile`, `writeOAuthCredentials`, OpenAI Codex auth-import helpers, deprecated `resolveOpenClawAgentDir` compatibility export | - | `plugin-sdk/provider-model-shared` | Private-local after July 2026; `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `resolveFamilyForwardCompatModel`, `selectPreferredLocalModelId`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and shared model-id normalization helpers | + | `plugin-sdk/provider-model-shared` | Private-local after July 2026; `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `resolveFamilyForwardCompatModel`, `selectPreferredLocalModelId`, `normalizeModelCompat`, `parseModelRef`, shared replay-policy builders, provider-endpoint helpers, and shared model-id normalization helpers | | `plugin-sdk/provider-catalog-live-runtime` | Private-local after July 2026; Live provider model catalog helpers for guarded `/models`-style discovery: `buildLiveModelProviderConfig`, provider-owned `projectRows`, `fetchLiveProviderModelRows`, `getCachedLiveProviderModelRows`, `fetchLiveProviderModelIds`, `LiveModelCatalogHttpError`, `clearLiveCatalogCacheForTests`, TTL cache, and static fallback | | `plugin-sdk/provider-catalog-runtime` | Provider catalog augmentation runtime hook and plugin-provider registry seams for contract tests | | `plugin-sdk/provider-catalog-shared` | Private-local after July 2026; `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `buildManifestModelProviderConfig`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` | @@ -248,6 +248,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only | | `plugin-sdk/reply-reference` | Private-local after July 2026; `createReplyReferencePlanner` | | `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers | + | `plugin-sdk/agent-scope-runtime` | Focused agent ID, directory, default-agent, and session-agent scope resolution helpers for dependency-light control-plane and migration paths | | `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), repair/lifecycle helpers (`deleteSessionEntry`, `cleanupSessionLifecycleArtifacts`, `resolveSessionStoreBackupPaths`), marker helpers for transitional `sessionFile` values, bounded recent user/assistant transcript text reads by session identity, session store path/session-key helpers, and updated-at reads, without broad config writes/maintenance imports | | `plugin-sdk/session-catalog` | External session catalog contracts, projections, adoption helpers, and history import | | `plugin-sdk/session-transcript-runtime` | Private-local after July 2026; Transcript identity, bounded raw and visible cursors, scoped target/read/write helpers, visible message-entry projection, update publishing, write locks, and transcript memory hit keys | @@ -370,9 +371,12 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | Subpath | Key exports | | --- | --- | | `plugin-sdk/memory-core-host-embedding-registry` | Private-local after July 2026; Lightweight memory embedding provider registry helpers | + | `plugin-sdk/memory-core-host-engine-curated` | Private-local focused curated-memory annotation parsing for doctor and promotion paths | | `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports | + | `plugin-sdk/memory-core-host-engine-fs` | Private-local focused filesystem and user-path helpers for doctor migrations | | `plugin-sdk/memory-core-host-engine-embeddings` | Private-local after July 2026; Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers. `registerMemoryEmbeddingProvider` on this surface is deprecated; use the generic embedding provider API for new providers. | | `plugin-sdk/memory-core-host-engine-qmd` | Private-local after July 2026; Memory host QMD engine exports | + | `plugin-sdk/memory-core-host-engine-schema` | Private-local focused memory index schema and sqlite-vec helpers for doctor migrations | | `plugin-sdk/memory-core-host-engine-storage` | Private-local after July 2026; Memory host storage engine exports | | `plugin-sdk/memory-core-host-secret` | Private-local after July 2026; Memory host secret helpers | | `plugin-sdk/memory-core-host-status` | Private-local after July 2026; Memory host status helpers | diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json index 49111dc30874..77e816d27093 100644 --- a/extensions/acpx/openclaw.plugin.json +++ b/extensions/acpx/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "acpx", + "doctorContract": { + "stateMigrations": true + }, "activation": { "onStartup": true }, diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index 0884e231aa18..10d5369349f8 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "active-memory", + "doctorContract": { + "stateMigrations": true + }, "activation": { "onStartup": true }, diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index decd4c060e6b..dc810520d6cd 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "anthropic", + "doctorContract": { + "sessionRouteStateOwners": true + }, "name": "Anthropic", "description": "Anthropic models, Claude CLI, and native Claude session catalog.", "icon": "https://cdn.simpleicons.org/anthropic", diff --git a/extensions/canvas/openclaw.plugin.json b/extensions/canvas/openclaw.plugin.json index 72b3abe1b5a3..b071fc2155f1 100644 --- a/extensions/canvas/openclaw.plugin.json +++ b/extensions/canvas/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "canvas", + "doctorContract": { + "stateMigrations": true + }, "activation": { "onStartup": true }, diff --git a/extensions/clickclack/openclaw.plugin.json b/extensions/clickclack/openclaw.plugin.json index 8b50be9b81e5..77cb71446637 100644 --- a/extensions/clickclack/openclaw.plugin.json +++ b/extensions/clickclack/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "clickclack", + "doctorContract": { + "normalizeCompatibilityConfig": true + }, "activation": { "onStartup": false }, diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 729bba15625a..fd046c3cfdf4 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -1,5 +1,11 @@ { "id": "codex", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true, + "sessionRouteStateOwners": true, + "stateMigrations": true + }, "name": "Codex", "description": "Codex app-server harness and native session catalog.", "contracts": { diff --git a/extensions/codex/src/migration/session-binding-sidecars.ts b/extensions/codex/src/migration/session-binding-sidecars.ts index 76cf700579b4..1fb2e86552f8 100644 --- a/extensions/codex/src/migration/session-binding-sidecars.ts +++ b/extensions/codex/src/migration/session-binding-sidecars.ts @@ -5,7 +5,7 @@ import { listAgentIds, resolveAgentDir, resolveSessionAgentIds, -} from "openclaw/plugin-sdk/agent-runtime"; +} from "openclaw/plugin-sdk/agent-scope-runtime"; import { withFileLock, type FileLockOptions } from "openclaw/plugin-sdk/file-lock"; import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { diff --git a/extensions/copilot/openclaw.plugin.json b/extensions/copilot/openclaw.plugin.json index 7932b77336e8..437278296dfb 100644 --- a/extensions/copilot/openclaw.plugin.json +++ b/extensions/copilot/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "copilot", + "doctorContract": { + "normalizeCompatibilityConfig": true, + "sessionRouteStateOwners": true + }, "name": "GitHub Copilot agent runtime", "description": "Registers the GitHub Copilot agent runtime.", "icon": "https://cdn.simpleicons.org/githubcopilot", diff --git a/extensions/cua-computer/openclaw.plugin.json b/extensions/cua-computer/openclaw.plugin.json index 19362a865e3f..d853668201bd 100644 --- a/extensions/cua-computer/openclaw.plugin.json +++ b/extensions/cua-computer/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "cua-computer", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "activation": { "onStartup": true }, diff --git a/extensions/deepinfra/openclaw.plugin.json b/extensions/deepinfra/openclaw.plugin.json index a981f701a9c2..095bdeb75963 100644 --- a/extensions/deepinfra/openclaw.plugin.json +++ b/extensions/deepinfra/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "deepinfra", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "activation": { "onStartup": false }, diff --git a/extensions/device-pair/openclaw.plugin.json b/extensions/device-pair/openclaw.plugin.json index b4f077cd776f..746bdac900af 100644 --- a/extensions/device-pair/openclaw.plugin.json +++ b/extensions/device-pair/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "device-pair", + "doctorContract": { + "stateMigrations": true + }, "activation": { "onStartup": true }, diff --git a/extensions/discord/openclaw.plugin.json b/extensions/discord/openclaw.plugin.json index 5af6d37869f2..557c22113f9e 100644 --- a/extensions/discord/openclaw.plugin.json +++ b/extensions/discord/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "discord", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "Discord", "description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.", "icon": "https://cdn.simpleicons.org/discord", diff --git a/extensions/elevenlabs/openclaw.plugin.json b/extensions/elevenlabs/openclaw.plugin.json index 1fbb7f64a86b..261f01f56087 100644 --- a/extensions/elevenlabs/openclaw.plugin.json +++ b/extensions/elevenlabs/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "elevenlabs", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "icon": "https://cdn.simpleicons.org/elevenlabs", "activation": { "onStartup": false diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json index 3546a00c792d..02811bb66b91 100644 --- a/extensions/feishu/openclaw.plugin.json +++ b/extensions/feishu/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "feishu", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "Feishu/Lark", "description": "OpenClaw Feishu/Lark channel plugin for chats and workplace tools (community maintained by @m1heng).", "activation": { diff --git a/extensions/google-meet/openclaw.plugin.json b/extensions/google-meet/openclaw.plugin.json index 3365d45e6833..a7554a4e1800 100644 --- a/extensions/google-meet/openclaw.plugin.json +++ b/extensions/google-meet/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "google-meet", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "Google Meet", "description": "OpenClaw Google Meet participant plugin for joining calls through Chrome or Twilio transports.", "icon": "https://cdn.simpleicons.org/googlemeet", diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index a17c9ad7830e..0397bb5a07ad 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "google", + "doctorContract": { + "sessionRouteStateOwners": true + }, "icon": "https://cdn.simpleicons.org/google", "activation": { "onStartup": false diff --git a/extensions/googlechat/openclaw.plugin.json b/extensions/googlechat/openclaw.plugin.json index 6bbdc8d8038f..40502bc22fb1 100644 --- a/extensions/googlechat/openclaw.plugin.json +++ b/extensions/googlechat/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "googlechat", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "Google Chat", "description": "OpenClaw Google Chat channel plugin for spaces and direct messages.", "icon": "https://cdn.simpleicons.org/googlechat", diff --git a/extensions/imessage/openclaw.plugin.json b/extensions/imessage/openclaw.plugin.json index ca7115246e05..8aa9f85d734d 100644 --- a/extensions/imessage/openclaw.plugin.json +++ b/extensions/imessage/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "imessage", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "icon": "https://cdn.simpleicons.org/imessage", "activation": { "onStartup": false diff --git a/extensions/irc/openclaw.plugin.json b/extensions/irc/openclaw.plugin.json index 8d1911c6c5b0..b340dcedaaa3 100644 --- a/extensions/irc/openclaw.plugin.json +++ b/extensions/irc/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "irc", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "activation": { "onStartup": false }, diff --git a/extensions/line/openclaw.plugin.json b/extensions/line/openclaw.plugin.json index be058bd6488f..6354f1b3cd49 100644 --- a/extensions/line/openclaw.plugin.json +++ b/extensions/line/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "line", + "doctorContract": {}, "name": "LINE", "description": "OpenClaw LINE channel plugin for LINE Bot API chats.", "icon": "https://cdn.simpleicons.org/line", diff --git a/extensions/llm-task/doctor-contract-api.ts b/extensions/llm-task/doctor-contract-api.ts index 5c03213fbf9d..77a7c9abf0ca 100644 --- a/extensions/llm-task/doctor-contract-api.ts +++ b/extensions/llm-task/doctor-contract-api.ts @@ -1,6 +1,6 @@ // LLM Task doctor contract migrates shipped plugin-local completion policy. -import { parseModelRef } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { parseModelRef } from "openclaw/plugin-sdk/provider-model-shared"; import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor"; const ENTRY_PATH = "plugins.entries.llm-task"; diff --git a/extensions/llm-task/openclaw.plugin.json b/extensions/llm-task/openclaw.plugin.json index b91c7f5edb33..60da1a70593d 100644 --- a/extensions/llm-task/openclaw.plugin.json +++ b/extensions/llm-task/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "llm-task", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "activation": { "onStartup": true }, diff --git a/extensions/matrix/openclaw.plugin.json b/extensions/matrix/openclaw.plugin.json index c268c0972e53..7ec9d0c6bb43 100644 --- a/extensions/matrix/openclaw.plugin.json +++ b/extensions/matrix/openclaw.plugin.json @@ -1,5 +1,10 @@ { "id": "matrix", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true, + "stateMigrations": true + }, "name": "Matrix", "description": "OpenClaw Matrix channel plugin for rooms and direct messages.", "icon": "https://cdn.simpleicons.org/matrix", diff --git a/extensions/matrix/src/account-selection.ts b/extensions/matrix/src/account-selection.ts index 62449d690f75..52d6d7fa14ca 100644 --- a/extensions/matrix/src/account-selection.ts +++ b/extensions/matrix/src/account-selection.ts @@ -11,7 +11,7 @@ import { normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveMatrixAccountStringValues, diff --git a/extensions/mattermost/openclaw.plugin.json b/extensions/mattermost/openclaw.plugin.json index 19d31e680457..c43d20624db4 100644 --- a/extensions/mattermost/openclaw.plugin.json +++ b/extensions/mattermost/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "mattermost", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "icon": "https://cdn.simpleicons.org/mattermost", "activation": { "onStartup": false diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index ebde90d63fd6..1624c34e8292 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "memory-core", + "doctorContract": { + "stateMigrations": true + }, "name": "OpenClaw Memory", "activation": { "onStartup": false diff --git a/extensions/memory-core/src/migration/doctor-host-event-sources.ts b/extensions/memory-core/src/migration/doctor-host-event-sources.ts index b1f4bddb4074..cecc94dd1c11 100644 --- a/extensions/memory-core/src/migration/doctor-host-event-sources.ts +++ b/extensions/memory-core/src/migration/doctor-host-event-sources.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { root } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; +import { root } from "openclaw/plugin-sdk/memory-core-host-engine-fs"; import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-host-events"; import { resolveConfiguredWorkspaces } from "./doctor-workspaces.js"; diff --git a/extensions/memory-core/src/migration/doctor-memory-sidecar-import.ts b/extensions/memory-core/src/migration/doctor-memory-sidecar-import.ts index bae8c526da50..f97da8aa557e 100644 --- a/extensions/memory-core/src/migration/doctor-memory-sidecar-import.ts +++ b/extensions/memory-core/src/migration/doctor-memory-sidecar-import.ts @@ -7,7 +7,7 @@ import { MEMORY_INDEX_META_TABLE, MEMORY_INDEX_SOURCES_TABLE, MEMORY_INDEX_VECTOR_TABLE, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +} from "openclaw/plugin-sdk/memory-core-host-engine-schema"; import { CREATE_LEGACY_MEMORY_FTS_MATCH_TABLE_SQL, LEGACY_MEMORY_FTS_MATCH_TABLE, diff --git a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts index aa1a963c7667..cd3a8f64ffa1 100644 --- a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts +++ b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts @@ -3,11 +3,11 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { reclaimDefinitelyStaleFileLock } from "openclaw/plugin-sdk/file-lock"; -import { resolveUserPath } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; +import { resolveUserPath } from "openclaw/plugin-sdk/memory-core-host-engine-fs"; import { ensureMemoryIndexSchema, loadSqliteVecExtension, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +} from "openclaw/plugin-sdk/memory-core-host-engine-schema"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { legacyStateFileExists, diff --git a/extensions/memory-core/src/migration/legacy-memory-sidecar-fts.ts b/extensions/memory-core/src/migration/legacy-memory-sidecar-fts.ts index 0742dbf4292c..e9f81f3e7adf 100644 --- a/extensions/memory-core/src/migration/legacy-memory-sidecar-fts.ts +++ b/extensions/memory-core/src/migration/legacy-memory-sidecar-fts.ts @@ -1,7 +1,7 @@ import { MEMORY_INDEX_CHUNKS_TABLE, MEMORY_INDEX_FTS_TABLE, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +} from "openclaw/plugin-sdk/memory-core-host-engine-schema"; export const LEGACY_MEMORY_FTS_MATCH_TABLE = "memory_core_legacy_fts_matches"; diff --git a/extensions/memory-core/src/short-term-promotion-apply.ts b/extensions/memory-core/src/short-term-promotion-apply.ts index 826d63a9d7a3..dd27d60369cc 100644 --- a/extensions/memory-core/src/short-term-promotion-apply.ts +++ b/extensions/memory-core/src/short-term-promotion-apply.ts @@ -2,12 +2,12 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { withFileLock } from "openclaw/plugin-sdk/file-lock"; -import { resolveStateDir } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS, formatMemoryDreamingDay, } from "openclaw/plugin-sdk/memory-core-host-status"; import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { appendConsolidationSkippedSummary, diff --git a/extensions/memory-core/src/short-term-promotion-metadata.ts b/extensions/memory-core/src/short-term-promotion-metadata.ts index 257218c67cb5..c21ca5cc702e 100644 --- a/extensions/memory-core/src/short-term-promotion-metadata.ts +++ b/extensions/memory-core/src/short-term-promotion-metadata.ts @@ -1,5 +1,5 @@ // Memory Core plugin module formats deterministic recall metadata for promoted entries. -import { extractProjectKeysFromCuratedEntry } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { extractProjectKeysFromCuratedEntry } from "openclaw/plugin-sdk/memory-core-host-engine-curated"; import type { PromotionCandidate } from "./short-term-promotion-types.js"; const MAX_PROMOTION_TRIGGER_PHRASE_CHARS = 64; diff --git a/extensions/memory-lancedb/doctor-contract-api.ts b/extensions/memory-lancedb/doctor-contract-api.ts index 91eeb7852b14..16ead6c85521 100644 --- a/extensions/memory-lancedb/doctor-contract-api.ts +++ b/extensions/memory-lancedb/doctor-contract-api.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor"; import { diff --git a/extensions/memory-lancedb/openclaw.plugin.json b/extensions/memory-lancedb/openclaw.plugin.json index 8b26906bed04..ce2ebd412b03 100644 --- a/extensions/memory-lancedb/openclaw.plugin.json +++ b/extensions/memory-lancedb/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "memory-lancedb", + "doctorContract": { + "stateMigrations": true + }, "name": "Memory LanceDB", "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall, auto-capture, and vector search.", "catalog": { "featured": true, "order": 70 }, diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index 51988c61987d..0678cbaa2192 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -1,5 +1,10 @@ { "id": "memory-wiki", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true, + "stateMigrations": true + }, "activation": { "onStartup": true }, diff --git a/extensions/msteams/openclaw.plugin.json b/extensions/msteams/openclaw.plugin.json index b74f0ba94c3b..6d92382e7657 100644 --- a/extensions/msteams/openclaw.plugin.json +++ b/extensions/msteams/openclaw.plugin.json @@ -1,5 +1,10 @@ { "id": "msteams", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true, + "stateMigrations": true + }, "name": "Microsoft Teams", "description": "OpenClaw Microsoft Teams channel plugin for bot conversations.", "activation": { diff --git a/extensions/nextcloud-talk/openclaw.plugin.json b/extensions/nextcloud-talk/openclaw.plugin.json index 9d0b1e19281d..7d9e8ff56672 100644 --- a/extensions/nextcloud-talk/openclaw.plugin.json +++ b/extensions/nextcloud-talk/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "nextcloud-talk", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "Nextcloud Talk", "description": "OpenClaw Nextcloud Talk channel plugin for conversations.", "icon": "https://cdn.simpleicons.org/nextcloud", diff --git a/extensions/nostr/openclaw.plugin.json b/extensions/nostr/openclaw.plugin.json index d10df99181e4..0186eb2bd34b 100644 --- a/extensions/nostr/openclaw.plugin.json +++ b/extensions/nostr/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "nostr", + "doctorContract": { + "stateMigrations": true + }, "name": "Nostr", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages.", "activation": { diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 6afbf523e5de..6cea2025e968 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "ollama", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "icon": "https://cdn.simpleicons.org/ollama", "activation": { "onStartup": true diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json index f10c72993969..da1eaf3992ac 100644 --- a/extensions/qqbot/openclaw.plugin.json +++ b/extensions/qqbot/openclaw.plugin.json @@ -1,5 +1,10 @@ { "id": "qqbot", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true, + "stateMigrations": true + }, "name": "QQ Bot", "description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.", "icon": "https://cdn.simpleicons.org/qq", diff --git a/extensions/qqbot/src/doctor-contract.ts b/extensions/qqbot/src/doctor-contract.ts index 7e2bee2edfc1..23ca02db32e0 100644 --- a/extensions/qqbot/src/doctor-contract.ts +++ b/extensions/qqbot/src/doctor-contract.ts @@ -10,7 +10,7 @@ import { defineKeyMoveMigration, hasLegacyAccountStreamingAliases, normalizeChannelConfigEntries, -} from "openclaw/plugin-sdk/runtime-doctor"; +} from "openclaw/plugin-sdk/runtime-doctor-migrations"; const RESTRICTED_GROUP_TOOLS: GroupToolPolicyConfig = { deny: ["exec", "read", "write"], diff --git a/extensions/qqbot/src/state-migrations.ts b/extensions/qqbot/src/state-migrations.ts index 22baeb9ebeb4..6653a7cc1886 100644 --- a/extensions/qqbot/src/state-migrations.ts +++ b/extensions/qqbot/src/state-migrations.ts @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { - PluginDoctorStateMigration, - PluginStateKeyedStore, -} from "openclaw/plugin-sdk/runtime-doctor"; -import { fileExists } from "openclaw/plugin-sdk/security-runtime"; +import { + legacyStateFileExists, + type PluginDoctorStateMigration, + type PluginStateKeyedStore, +} from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { buildQQBotStateKey } from "./engine/utils/state-keys.js"; type CredentialBackup = { @@ -85,7 +85,9 @@ async function credentialBackupCandidates(stateDir: string): Promise left.sourcePath.localeCompare(right.sourcePath)); const singlePath = path.join(dataDir, "credential-backup.json"); - return fileExists(singlePath) ? [...accountFiles, { sourcePath: singlePath }] : accountFiles; + return (await legacyStateFileExists(singlePath)) + ? [...accountFiles, { sourcePath: singlePath }] + : accountFiles; } async function readLegacyCredentialBackups(stateDir: string): Promise { @@ -114,7 +116,7 @@ async function archiveLegacySource(params: { warnings: string[]; }): Promise { const archivedPath = `${params.sourcePath}.migrated`; - if (fileExists(archivedPath)) { + if (await legacyStateFileExists(archivedPath)) { params.warnings.push( `Left QQBot credential backup in place because ${archivedPath} already exists`, ); diff --git a/extensions/reef/openclaw.plugin.json b/extensions/reef/openclaw.plugin.json index a05dfd70da99..5396ed29c46b 100644 --- a/extensions/reef/openclaw.plugin.json +++ b/extensions/reef/openclaw.plugin.json @@ -1,5 +1,10 @@ { "id": "reef", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true, + "stateMigrations": true + }, "name": "Reef", "description": "Guarded end-to-end encrypted claw channel", "activation": { diff --git a/extensions/signal/openclaw.plugin.json b/extensions/signal/openclaw.plugin.json index e19e5a3c1372..b3732af40c0c 100644 --- a/extensions/signal/openclaw.plugin.json +++ b/extensions/signal/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "signal", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "icon": "https://cdn.simpleicons.org/signal", "activation": { "onStartup": false diff --git a/extensions/slack/openclaw.plugin.json b/extensions/slack/openclaw.plugin.json index 6744b176aa71..0fd596163cb1 100644 --- a/extensions/slack/openclaw.plugin.json +++ b/extensions/slack/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "slack", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "Slack", "description": "OpenClaw Slack channel plugin for channels, DMs, commands, and app events.", "skills": ["./skills"], diff --git a/extensions/synology-chat/openclaw.plugin.json b/extensions/synology-chat/openclaw.plugin.json index f6ada0ee80b9..b081edd0a10b 100644 --- a/extensions/synology-chat/openclaw.plugin.json +++ b/extensions/synology-chat/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "synology-chat", + "doctorContract": {}, "name": "Synology Chat", "description": "Synology Chat channel plugin for OpenClaw channels and direct messages.", "icon": "https://cdn.simpleicons.org/synology", diff --git a/extensions/telegram/openclaw.plugin.json b/extensions/telegram/openclaw.plugin.json index c9d164cb61db..dbf2fbfd7817 100644 --- a/extensions/telegram/openclaw.plugin.json +++ b/extensions/telegram/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "telegram", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "icon": "https://cdn.simpleicons.org/telegram", "activation": { "onStartup": false diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index fa4d2b43eb75..303acbb114f7 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -1,7 +1,7 @@ // Telegram plugin module implements sent message cache behavior. import { createHash } from "node:crypto"; import fs from "node:fs"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; +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"; diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index 471428829e0f..97cd32ecd235 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -1,7 +1,7 @@ // Telegram plugin module implements state migrations behavior. import fs from "node:fs"; import path from "node:path"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; +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"; diff --git a/extensions/tlon/openclaw.plugin.json b/extensions/tlon/openclaw.plugin.json index 87855c28d9b4..ceeefcd0c9d9 100644 --- a/extensions/tlon/openclaw.plugin.json +++ b/extensions/tlon/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "tlon", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "Tlon/Urbit", "description": "OpenClaw Tlon/Urbit channel plugin for chat workflows.", "activation": { diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index 52c2ab71731e..ba4a530b6bfd 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -14,6 +14,9 @@ "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" + ], "openclaw/plugin-sdk/approval-reference-runtime": [ "../packages/plugin-sdk/dist/src/plugin-sdk/approval-reference-runtime.d.ts" ], @@ -377,12 +380,21 @@ "openclaw/plugin-sdk/memory-core-host-embedding-registry": [ "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-embedding-registry.d.ts" ], + "openclaw/plugin-sdk/memory-core-host-engine-curated": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-curated.d.ts" + ], "openclaw/plugin-sdk/memory-core-host-engine-embeddings": [ "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-embeddings.d.ts" ], + "openclaw/plugin-sdk/memory-core-host-engine-fs": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-fs.d.ts" + ], "openclaw/plugin-sdk/memory-core-host-engine-qmd": [ "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-qmd.d.ts" ], + "openclaw/plugin-sdk/memory-core-host-engine-schema": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-schema.d.ts" + ], "openclaw/plugin-sdk/memory-core-host-engine-storage": [ "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-storage.d.ts" ], diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index 719ab5a23756..8b0469c72491 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "voice-call", + "doctorContract": { + "resolveSessionStoreAgentIds": true, + "stateMigrations": true + }, "name": "Voice Call", "description": "OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.", "skills": ["./skills"], diff --git a/extensions/whatsapp/openclaw.plugin.json b/extensions/whatsapp/openclaw.plugin.json index 6073d8a38e33..9f74202761c4 100644 --- a/extensions/whatsapp/openclaw.plugin.json +++ b/extensions/whatsapp/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "whatsapp", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "name": "WhatsApp", "description": "OpenClaw WhatsApp channel plugin for WhatsApp Web chats.", "icon": "https://cdn.simpleicons.org/whatsapp", diff --git a/extensions/workboard/openclaw.plugin.json b/extensions/workboard/openclaw.plugin.json index 0a6ef293b002..7c051e7157ce 100644 --- a/extensions/workboard/openclaw.plugin.json +++ b/extensions/workboard/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "workboard", + "doctorContract": { + "stateMigrations": true + }, "enabledByDefault": false, "activation": { "onStartup": true, diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 92ffb4ad1e1c..a1a8cd11adff 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -1,5 +1,9 @@ { "id": "xai", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true + }, "activation": { "onStartup": false }, diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 0fcdf320592b..24cd5c4b6c22 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -14,6 +14,9 @@ "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" + ], "openclaw/plugin-sdk/approval-reference-runtime": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/approval-reference-runtime.d.ts" ], @@ -374,12 +377,21 @@ "openclaw/plugin-sdk/memory-core-host-embedding-registry": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-embedding-registry.d.ts" ], + "openclaw/plugin-sdk/memory-core-host-engine-curated": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-curated.d.ts" + ], "openclaw/plugin-sdk/memory-core-host-engine-embeddings": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-embeddings.d.ts" ], + "openclaw/plugin-sdk/memory-core-host-engine-fs": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-fs.d.ts" + ], "openclaw/plugin-sdk/memory-core-host-engine-qmd": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-qmd.d.ts" ], + "openclaw/plugin-sdk/memory-core-host-engine-schema": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-schema.d.ts" + ], "openclaw/plugin-sdk/memory-core-host-engine-storage": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-storage.d.ts" ], diff --git a/extensions/zalo/openclaw.plugin.json b/extensions/zalo/openclaw.plugin.json index f29baab21eb4..87e099d54173 100644 --- a/extensions/zalo/openclaw.plugin.json +++ b/extensions/zalo/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "zalo", + "doctorContract": {}, "name": "Zalo", "description": "OpenClaw Zalo channel plugin for bot and webhook chats.", "icon": "https://cdn.simpleicons.org/zalo", diff --git a/extensions/zalouser/openclaw.plugin.json b/extensions/zalouser/openclaw.plugin.json index db07c154c064..6bc5010b981a 100644 --- a/extensions/zalouser/openclaw.plugin.json +++ b/extensions/zalouser/openclaw.plugin.json @@ -1,5 +1,10 @@ { "id": "zalouser", + "doctorContract": { + "legacyConfigRules": true, + "normalizeCompatibilityConfig": true, + "stateMigrations": true + }, "name": "Zalo Personal", "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration.", "icon": "https://cdn.simpleicons.org/zalo", diff --git a/package.json b/package.json index bafb94903058..d72a91d7ee5a 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,11 @@ "!dist/plugin-sdk/markdown-table-runtime.d.ts", "!dist/plugin-sdk/media-generation-runtime.d.ts", "!dist/plugin-sdk/memory-core-host-embedding-registry.d.ts", + "!dist/plugin-sdk/memory-core-host-engine-curated.d.ts", "!dist/plugin-sdk/memory-core-host-engine-embeddings.d.ts", + "!dist/plugin-sdk/memory-core-host-engine-fs.d.ts", "!dist/plugin-sdk/memory-core-host-engine-qmd.d.ts", + "!dist/plugin-sdk/memory-core-host-engine-schema.d.ts", "!dist/plugin-sdk/memory-core-host-engine-storage.d.ts", "!dist/plugin-sdk/memory-core-host-runtime-cli.d.ts", "!dist/plugin-sdk/memory-core-host-runtime-core.d.ts", @@ -178,6 +181,7 @@ "!dist/plugin-sdk/request-url.d.ts", "!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/runtime-doctor.d.ts", "!dist/plugin-sdk/runtime-fetch.d.ts", "!dist/plugin-sdk/sandbox.d.ts", @@ -400,6 +404,9 @@ "./plugin-sdk/runtime-doctor": { "default": "./dist/plugin-sdk/runtime-doctor.js" }, + "./plugin-sdk/runtime-doctor-migrations": { + "default": "./dist/plugin-sdk/runtime-doctor-migrations.js" + }, "./plugin-sdk/runtime-env": { "types": "./dist/plugin-sdk/runtime-env.d.ts", "default": "./dist/plugin-sdk/runtime-env.js" @@ -677,6 +684,10 @@ "types": "./dist/plugin-sdk/text-chunking.d.ts", "default": "./dist/plugin-sdk/text-chunking.js" }, + "./plugin-sdk/agent-scope-runtime": { + "types": "./dist/plugin-sdk/agent-scope-runtime.d.ts", + "default": "./dist/plugin-sdk/agent-scope-runtime.js" + }, "./plugin-sdk/agent-runtime": { "types": "./dist/plugin-sdk/agent-runtime.d.ts", "default": "./dist/plugin-sdk/agent-runtime.js" @@ -1183,13 +1194,22 @@ "./plugin-sdk/memory-core-host-engine-embeddings": { "default": "./dist/plugin-sdk/memory-core-host-engine-embeddings.js" }, + "./plugin-sdk/memory-core-host-engine-curated": { + "default": "./dist/plugin-sdk/memory-core-host-engine-curated.js" + }, "./plugin-sdk/memory-core-host-engine-foundation": { "types": "./dist/plugin-sdk/memory-core-host-engine-foundation.d.ts", "default": "./dist/plugin-sdk/memory-core-host-engine-foundation.js" }, + "./plugin-sdk/memory-core-host-engine-fs": { + "default": "./dist/plugin-sdk/memory-core-host-engine-fs.js" + }, "./plugin-sdk/memory-core-host-engine-qmd": { "default": "./dist/plugin-sdk/memory-core-host-engine-qmd.js" }, + "./plugin-sdk/memory-core-host-engine-schema": { + "default": "./dist/plugin-sdk/memory-core-host-engine-schema.js" + }, "./plugin-sdk/memory-core-host-engine-storage": { "default": "./dist/plugin-sdk/memory-core-host-engine-storage.js" }, diff --git a/packages/memory-host-sdk/src/host/config-utils.test.ts b/packages/memory-host-sdk/src/host/config-utils.test.ts index 510cf4a986ab..0f5d23798232 100644 --- a/packages/memory-host-sdk/src/host/config-utils.test.ts +++ b/packages/memory-host-sdk/src/host/config-utils.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "vitest"; -import { resolveRememberAcrossConversations } from "./config-utils.js"; +import { resolveRememberAcrossConversations, splitShellArgs } from "./config-utils.js"; + +describe("splitShellArgs", () => { + it("preserves quoted command arguments through the focused re-export", () => { + expect(splitShellArgs('qmd query --collection "Project Notes"')).toEqual([ + "qmd", + "query", + "--collection", + "Project Notes", + ]); + }); +}); describe("resolveRememberAcrossConversations", () => { it("honors keyed per-agent memory overrides", () => { diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index 75581d325740..dafc38bb62fe 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -12,7 +12,7 @@ import { uniqueStrings, } from "@openclaw/normalization-core/string-normalization"; export { normalizeAgentId }; -export { splitShellArgs } from "./openclaw-runtime-io.js"; +export { splitShellArgs } from "../../../../src/utils/shell-argv.js"; // Shared OpenClaw config helpers used by memory host, QMD, and agent context code. diff --git a/packages/memory-host-sdk/src/host/curated-annotations.ts b/packages/memory-host-sdk/src/host/curated-annotations.ts new file mode 100644 index 000000000000..24446fc3238c --- /dev/null +++ b/packages/memory-host-sdk/src/host/curated-annotations.ts @@ -0,0 +1,68 @@ +// Pure curated-memory annotation parsing shared by runtime and doctor paths. +export const INVALID_PROJECT_ANNOTATION_KEY = "!invalid-project-annotation"; + +// Carrier syntax is line-scoped like recall metadata parsing. Never cross a +// newline from an unterminated marker into the next entry's valid annotation. +const MEMORY_ANNOTATION_CARRIER_RE = //giu; + +export function stripMemoryAnnotationCarriers(text: string): string { + let stripped = false; + const withoutCarriers = text.replace(MEMORY_ANNOTATION_CARRIER_RE, () => { + stripped = true; + return ""; + }); + return stripped ? withoutCarriers.replace(/[ \t]+(?=\r?$)/gmu, "") : text; +} + +export type CuratedProjectAnnotations = { + annotated: boolean; + valid: boolean; + keys: string[]; + rawCount: number; + validCount: number; +}; + +export function normalizeProjectAnnotationKey(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed || /[\r\n<>]/u.test(trimmed)) { + return null; + } + if (trimmed.startsWith("path:")) { + return trimmed; + } + const separator = trimmed.indexOf("/"); + if (separator < 1) { + return trimmed; + } + // Preserve remote path case so case-sensitive hosts fail closed. Providers + // with case-insensitive slugs may miss boosts/digests across casing variants, + // but folding paths could cross-inject memory between distinct repositories. + return `${trimmed.slice(0, separator).toLowerCase()}${trimmed.slice(separator)}`; +} + +export function extractProjectKeysFromCuratedEntry(text: string): CuratedProjectAnnotations { + const keys = new Set(); + const markerCount = [...text.matchAll(//giu)) { + parsedCount += 1; + for (const rawKey of (match[1] ?? "").split(";")) { + rawCount += 1; + const key = normalizeProjectAnnotationKey(rawKey); + if (key) { + keys.add(key); + validCount += 1; + } + } + } + const annotated = markerCount > 0; + return { + annotated, + valid: !annotated || (parsedCount === markerCount && rawCount > 0 && rawCount === validCount), + keys: [...keys], + rawCount, + validCount, + }; +} diff --git a/packages/memory-host-sdk/src/host/internal.ts b/packages/memory-host-sdk/src/host/internal.ts index 2d855b74ec3a..b938989f6791 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -405,74 +405,13 @@ export type CuratedMarkdownEntry = { text: string; kind: "entry" | "section"; }; - -export const INVALID_PROJECT_ANNOTATION_KEY = "!invalid-project-annotation"; - -// Carrier syntax is line-scoped like recall metadata parsing. Never cross a -// newline from an unterminated marker into the next entry's valid annotation. -const MEMORY_ANNOTATION_CARRIER_RE = //giu; - -export function stripMemoryAnnotationCarriers(text: string): string { - let stripped = false; - const withoutCarriers = text.replace(MEMORY_ANNOTATION_CARRIER_RE, () => { - stripped = true; - return ""; - }); - return stripped ? withoutCarriers.replace(/[ \t]+(?=\r?$)/gmu, "") : text; -} - -export type CuratedProjectAnnotations = { - annotated: boolean; - valid: boolean; - keys: string[]; - rawCount: number; - validCount: number; -}; - -export function normalizeProjectAnnotationKey(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed || /[\r\n<>]/u.test(trimmed)) { - return null; - } - if (trimmed.startsWith("path:")) { - return trimmed; - } - const separator = trimmed.indexOf("/"); - if (separator < 1) { - return trimmed; - } - // Preserve remote path case so case-sensitive hosts fail closed. Providers - // with case-insensitive slugs may miss boosts/digests across casing variants, - // but folding paths could cross-inject memory between distinct repositories. - return `${trimmed.slice(0, separator).toLowerCase()}${trimmed.slice(separator)}`; -} - -export function extractProjectKeysFromCuratedEntry(text: string): CuratedProjectAnnotations { - const keys = new Set(); - const markerCount = [...text.matchAll(//giu)) { - parsedCount += 1; - for (const rawKey of (match[1] ?? "").split(";")) { - rawCount += 1; - const key = normalizeProjectAnnotationKey(rawKey); - if (key) { - keys.add(key); - validCount += 1; - } - } - } - const annotated = markerCount > 0; - return { - annotated, - valid: !annotated || (parsedCount === markerCount && rawCount > 0 && rawCount === validCount), - keys: [...keys], - rawCount, - validCount, - }; -} +export { + extractProjectKeysFromCuratedEntry, + INVALID_PROJECT_ANNOTATION_KEY, + normalizeProjectAnnotationKey, + stripMemoryAnnotationCarriers, + type CuratedProjectAnnotations, +} from "./curated-annotations.js"; export function splitCuratedMarkdownEntries(content: string): CuratedMarkdownEntry[] { const lines = content.split("\n"); diff --git a/scripts/check-built-plugin-control-plane-modules.d.mts b/scripts/check-built-plugin-control-plane-modules.d.mts new file mode 100644 index 000000000000..1236f43124be --- /dev/null +++ b/scripts/check-built-plugin-control-plane-modules.d.mts @@ -0,0 +1,23 @@ +export type BuiltPluginControlPlaneModule = { + pluginId: string; + kind: string; + relativePath: string; +}; + +export type BuiltPluginControlPlaneModuleFailure = BuiltPluginControlPlaneModule & { + error: string; +}; + +export function listBuiltPluginControlPlaneModules(params?: { + rootDir?: string; +}): BuiltPluginControlPlaneModule[]; + +export function probeBuiltPluginControlPlaneModules( + modules: BuiltPluginControlPlaneModule[], + params?: { rootDir?: string; timeoutMs?: number }, +): BuiltPluginControlPlaneModuleFailure[]; + +export function verifyBuiltPluginControlPlaneModules(params?: { + rootDir?: string; + timeoutMs?: number; +}): void; diff --git a/scripts/check-built-plugin-control-plane-modules.mjs b/scripts/check-built-plugin-control-plane-modules.mjs new file mode 100644 index 000000000000..034659549753 --- /dev/null +++ b/scripts/check-built-plugin-control-plane-modules.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +// Verifies built plugin control-plane artifacts through Node's native require(esm) path. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import ts from "typescript"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; + +const ROOT = resolveRepoRoot(import.meta.url); +const DIRECT_CONTRACT_FILES = ["contract-api.js", "doctor-contract-api.js"]; +const LEGACY_SETUP_PROPERTIES = new Map([ + ["legacyStateMigrations", "channel-legacy-state-migrations"], + ["legacySessionSurface", "channel-legacy-session-surface"], + ["legacySessionSurfaces", "channel-legacy-session-surface"], +]); +const PROBE_RESULT_MARKER = "__OPENCLAW_PLUGIN_CONTROL_PLANE_PROBE__"; +const DEFAULT_TIMEOUT_MS = 120_000; +const REQUIRE_PROBE_SOURCE = String.raw` +const { createRequire } = require("node:module"); +const path = require("node:path"); +const targets = JSON.parse(Buffer.from(process.argv[1], "base64url").toString("utf8")); +const requireFromRoot = createRequire(path.join(process.cwd(), "package.json")); +const failures = []; +for (const target of targets) { + try { + requireFromRoot(path.resolve(process.cwd(), target.relativePath)); + } catch (error) { + failures.push({ + ...target, + error: error instanceof Error ? (error.stack || error.message) : String(error), + }); + } +} +process.stdout.write("\n${PROBE_RESULT_MARKER}" + JSON.stringify({ failures })); +`; + +function propertyNameText(name) { + return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : null; +} + +function listLegacySetupModuleSpecifiers(setupEntryPath) { + const source = fs.readFileSync(setupEntryPath, "utf8"); + const sourceFile = ts.createSourceFile(setupEntryPath, source, ts.ScriptTarget.Latest, true); + const specifiers = []; + const visit = (node) => { + if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.initializer)) { + const kind = LEGACY_SETUP_PROPERTIES.get(propertyNameText(node.name)); + if (kind) { + const specifierProperty = node.initializer.properties.find( + (property) => + ts.isPropertyAssignment(property) && propertyNameText(property.name) === "specifier", + ); + if ( + specifierProperty && + ts.isPropertyAssignment(specifierProperty) && + ts.isStringLiteralLike(specifierProperty.initializer) + ) { + specifiers.push({ kind, specifier: specifierProperty.initializer.text }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return specifiers; +} + +/** Lists exact built doctor, contract, and channel legacy migration artifacts. */ +export function listBuiltPluginControlPlaneModules(params = {}) { + const rootDir = path.resolve(params.rootDir ?? ROOT); + const extensionsDir = path.join(rootDir, "dist", "extensions"); + if (!fs.existsSync(extensionsDir)) { + return []; + } + const modules = new Map(); + for (const entry of fs + .readdirSync(extensionsDir, { withFileTypes: true }) + .filter((candidate) => candidate.isDirectory()) + .toSorted((left, right) => left.name.localeCompare(right.name))) { + const pluginId = entry.name; + const pluginDir = path.join(extensionsDir, pluginId); + for (const fileName of DIRECT_CONTRACT_FILES) { + const modulePath = path.join(pluginDir, fileName); + if (fs.existsSync(modulePath)) { + const relativePath = path.relative(rootDir, modulePath).split(path.sep).join("/"); + modules.set(relativePath, { + pluginId, + kind: fileName === "doctor-contract-api.js" ? "doctor-contract" : "contract", + relativePath, + }); + } + } + const setupEntryPath = path.join(pluginDir, "setup-entry.js"); + if (!fs.existsSync(setupEntryPath)) { + continue; + } + for (const { kind, specifier } of listLegacySetupModuleSpecifiers(setupEntryPath)) { + const modulePath = path.resolve(pluginDir, specifier); + const pluginRelativePath = path.relative(pluginDir, modulePath); + if (pluginRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(pluginRelativePath)) { + throw new Error(`${pluginId} setup entry module escapes the plugin root: ${specifier}`); + } + const relativePath = path.relative(rootDir, modulePath).split(path.sep).join("/"); + modules.set(relativePath, { pluginId, kind, relativePath }); + } + } + return [...modules.values()].toSorted((left, right) => + left.relativePath.localeCompare(right.relativePath), + ); +} + +/** Loads every selected artifact in one timeout-bounded native-require child. */ +export function probeBuiltPluginControlPlaneModules(modules, params = {}) { + if (modules.length === 0) { + return []; + } + const rootDir = path.resolve(params.rootDir ?? ROOT); + const encodedTargets = Buffer.from(JSON.stringify(modules), "utf8").toString("base64url"); + const result = spawnSync(process.execPath, ["-e", REQUIRE_PROBE_SOURCE, encodedTargets], { + cwd: rootDir, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + timeout: params.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }); + if (result.error) { + throw new Error( + `built plugin control-plane native-require probe failed: ${result.error.message}`, + ); + } + const markerIndex = result.stdout.lastIndexOf(PROBE_RESULT_MARKER); + if (markerIndex < 0) { + throw new Error( + `built plugin control-plane native-require probe exited ${String(result.status)} without a result`, + ); + } + const payload = JSON.parse(result.stdout.slice(markerIndex + PROBE_RESULT_MARKER.length)); + return Array.isArray(payload.failures) ? payload.failures : []; +} + +/** Fails the build when a generated plugin control-plane module cannot be required natively. */ +export function verifyBuiltPluginControlPlaneModules(params = {}) { + const modules = listBuiltPluginControlPlaneModules(params); + const failures = probeBuiltPluginControlPlaneModules(modules, params); + if (failures.length > 0) { + const details = failures.map( + (failure) => + `- ${failure.pluginId} (${failure.kind}) ${failure.relativePath}: ${failure.error}`, + ); + throw new Error(`built plugin control-plane module load failures:\n${details.join("\n")}`); + } + console.error( + `[plugin-control-plane-loads] verified ${modules.length} built modules with native require`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + verifyBuiltPluginControlPlaneModules(); +} diff --git a/scripts/check-changed.d.mts b/scripts/check-changed.d.mts index 7282afeafc9b..f060db41d7c0 100644 --- a/scripts/check-changed.d.mts +++ b/scripts/check-changed.d.mts @@ -44,6 +44,7 @@ export function shouldRunPromptSnapshotCheck(paths: string[]): boolean; export function shouldRunPromptSnapshotOwnerTest(paths: string[]): boolean; export function shouldRunControlUiI18nVerify(paths: string[]): boolean; export function shouldRunRuntimeSidecarBaselineCheck(paths: string[]): boolean; +export function shouldRunDoctorContractOwnerTests(paths: string[]): boolean; export function shouldRunSqliteSessionSchemaBaselineCheck(paths: string[]): boolean; export function shouldRunPluginSdkApiBaselineCheck(paths: string[]): boolean; export function shouldRunPluginSdkSurfaceChecks(paths: string[]): boolean; diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index d6cedb1cd56d..732ceeae31fc 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -40,6 +40,12 @@ const PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE = /^(?:scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.ts|sync-codex-model-prompt-fixture\.ts)|test\/helpers\/agents\/(?:happy-path-prompt-snapshots|prompt-snapshot-paths)\.ts|test\/fixtures\/agents\/prompt-snapshots\/codex-model-catalog\/.+)$/u; const RUNTIME_SIDECAR_BASELINE_PATH_RE = /^(?:scripts\/generate-runtime-sidecar-paths-baseline\.ts|scripts\/lib\/bundled-runtime-sidecar-paths\.json|src\/plugins\/runtime-sidecar-paths(?:-baseline)?\.ts)$/u; +// Doctor-contract declaration truth depends on arbitrary plugin source (artifacts +// re-export from src), so any non-test extension module or plugin manifest change +// must re-prove the src/plugins-owned declaration and closure-guard tests that the +// extension lanes would otherwise never select. +const DOCTOR_CONTRACT_OWNER_TEST_PATH_RE = + /^extensions\/[^/]+\/(?:openclaw\.plugin\.json$|(?!.*\.test\.).*\.(?:c|m)?[jt]s$)/u; const SQLITE_SESSION_SCHEMA_BASELINE_PATH_RE = /^(?:src\/state\/openclaw-agent-schema\.sql|scripts\/(?:generate-sqlite-session-schema-baseline\.ts|lib\/sqlite-session-schema-baseline\.ts)|test\/scripts\/sqlite-session-schema-baseline\.test\.ts|docs\/\.generated\/sqlite-session-transcript-schema-baseline\.sha256)$/u; const PLUGIN_SDK_API_BASELINE_PATH_RE = @@ -280,6 +286,13 @@ export function shouldRunRuntimeSidecarBaselineCheck(paths) { return paths.some((changedPath) => RUNTIME_SIDECAR_BASELINE_PATH_RE.test(changedPath)); } +/** Returns whether changed files can drift bundled doctor-contract declarations or closures. */ +export function shouldRunDoctorContractOwnerTests(paths) { + return paths.some((changedPath) => + DOCTOR_CONTRACT_OWNER_TEST_PATH_RE.test(normalizeChangedPath(changedPath)), + ); +} + /** Returns whether changed files can affect the sessions/transcripts SQLite schema baseline. */ export function shouldRunSqliteSessionSchemaBaselineCheck(paths) { return paths.some((changedPath) => @@ -560,6 +573,17 @@ export function createChangedCheckPlan(result, options = {}) { baseEnv, ); } + if (shouldRunDoctorContractOwnerTests(result.paths)) { + add( + "doctor contract declaration + closure guard tests", + [ + "test:serial", + "src/plugins/doctor-contract-declarations.test.ts", + "src/plugins/doctor-contract-closure-guard.test.ts", + ], + baseEnv, + ); + } if (result.lanes.all || result.lanes.bundledChannelConfigMetadata) { add("bundled channel config metadata", ["check:bundled-channel-config-metadata"]); } diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 7a045d9b7c2a..628e75355ddb 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -87,6 +87,9 @@ export const pluginSdkDocMetadata = { "session-store-runtime": { category: "runtime", }, + "agent-scope-runtime": { + category: "runtime", + }, "agent-runtime": { category: "runtime", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 9cf93e1ecaa5..697a4ff042c3 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -6,6 +6,7 @@ "runtime", "health", "runtime-doctor", + "runtime-doctor-migrations", "runtime-env", "proxy-capture", "setup", @@ -84,6 +85,7 @@ "thread-bindings-session-runtime", "text-runtime", "text-chunking", + "agent-scope-runtime", "agent-runtime", "simple-completion-runtime", "speech-core", @@ -248,8 +250,11 @@ "qa-runner-runtime", "memory-core-host-embedding-registry", "memory-core-host-engine-embeddings", + "memory-core-host-engine-curated", "memory-core-host-engine-foundation", + "memory-core-host-engine-fs", "memory-core-host-engine-qmd", + "memory-core-host-engine-schema", "memory-core-host-engine-storage", "memory-core-host-secret", "memory-core-host-status", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 3a552e30a7dc..460048dbd217 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -63,8 +63,11 @@ "markdown-table-runtime", "media-generation-runtime", "memory-core-host-embedding-registry", + "memory-core-host-engine-curated", "memory-core-host-engine-embeddings", + "memory-core-host-engine-fs", "memory-core-host-engine-qmd", + "memory-core-host-engine-schema", "memory-core-host-engine-storage", "memory-core-host-runtime-cli", "memory-core-host-runtime-core", @@ -132,6 +135,7 @@ "response-limit-runtime", "retry-runtime", "runtime-doctor", + "runtime-doctor-migrations", "runtime-fetch", "sandbox", "secret-file-runtime", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index ddc36e2a4085..f81910e26258 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -164,7 +164,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: pinned secret reads and first-writer-wins creation. // +2: restore the documented session-catalog and tool-results plugin contracts. // +1: focused inbound-event delivery correlation for channel plugins. - 149, + // +1: dependency-light agent scope helpers for doctor migration enumeration. + 150, env, ), publicExports: readPluginSdkSurfaceBudgetEnv( @@ -223,7 +224,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: shared channel question-reaction store and preflight-audio factories. // +1: shared channel interactive dispatcher with canonical binding authorization. // +1: simple channel secret contract factory replacing repeated collectors. - 4835, + // +4: focused agent scope functions for doctor migration enumeration. + 4839, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -274,7 +276,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: shared channel question-reaction store and preflight-audio factories. // +1: shared channel interactive dispatcher with canonical binding authorization. // +1: simple channel secret contract factory replacing repeated collectors. - 2912, + // +4: focused agent scope functions for doctor migration enumeration. + 2916, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index a9ce1dafd58b..f963401bef48 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; import { buildSync } from "esbuild"; +import { verifyBuiltPluginControlPlaneModules } from "./check-built-plugin-control-plane-modules.mjs"; import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { escapeRegExp } from "./lib/regexp.mjs"; @@ -703,6 +704,9 @@ export function runRuntimePostBuild(params = {}) { writeLegacyRootRuntimeCompatAliases(phaseParams), ); runPhase("legacy CLI exit compat chunks", () => writeLegacyCliExitCompatChunks(phaseParams)); + runPhase("built plugin control-plane loads", () => + verifyBuiltPluginControlPlaneModules(phaseParams), + ); logSummary(); } diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 88c6a1cc7e49..a8c5b9be215d 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -138,10 +138,13 @@ vi.mock("../daemon/inspect.js", () => ({ renderGatewayServiceCleanupHints: () => [], })); -vi.mock("../infra/ports.js", () => ({ +vi.mock("../infra/ports-inspect.js", () => ({ inspectPortConnections: (port: number) => inspectPortConnections(port), inspectPortUsage: (port: number) => inspectPortUsage(port), inspectPortUsages: (ports: readonly number[]) => inspectPortUsages(ports), +})); + +vi.mock("../infra/ports-format.js", () => ({ formatPortDiagnostics: () => ["Port 18789 is already in use."], })); diff --git a/src/cli/daemon-cli/restart-health-probe.ts b/src/cli/daemon-cli/restart-health-probe.ts index 670ac24dd056..3a5772d9219e 100644 --- a/src/cli/daemon-cli/restart-health-probe.ts +++ b/src/cli/daemon-cli/restart-health-probe.ts @@ -7,7 +7,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginHealthErrorSummary } from "../../gateway/health/types.js"; import { resolveGatewayProbeAuthSafeWithSecretInputs } from "../../gateway/probe-auth.js"; import { probeGateway } from "../../gateway/probe.js"; -import { inspectPortUsage, LOOPBACK_PORT_PROBE_HOSTS, type PortUsage } from "../../infra/ports.js"; +import { inspectPortUsage } from "../../infra/ports-inspect.js"; +import { LOOPBACK_PORT_PROBE_HOSTS } from "../../infra/ports-probe.js"; +import type { PortUsage } from "../../infra/ports-types.js"; import type { GatewayPortHealthSnapshot } from "./restart-health.types.js"; import { allListenersOwnedByRuntimePid } from "./restart-port-ownership.js"; diff --git a/src/cli/daemon-cli/restart-health.test-helpers.ts b/src/cli/daemon-cli/restart-health.test-helpers.ts index 838d5ba5f7b8..5e1c3e085e4c 100644 --- a/src/cli/daemon-cli/restart-health.test-helpers.ts +++ b/src/cli/daemon-cli/restart-health.test-helpers.ts @@ -1,9 +1,11 @@ import { vi } from "vitest"; import type { GatewayService } from "../../daemon/service.js"; import type { GatewayLockIdentity } from "../../infra/gateway-lock.js"; -import type { PortUsage } from "../../infra/ports.js"; +import type { PortUsage } from "../../infra/ports-types.js"; -type PortListenerKind = ReturnType; +type PortListenerKind = ReturnType< + typeof import("../../infra/ports-format.js").classifyPortListener +>; export const inspectPortUsage = vi.fn<(port: number, options?: { probeHosts?: readonly string[] }) => Promise>(); @@ -26,11 +28,17 @@ export const resolveGatewayServiceProbeHosts = vi.fn< (_params?: unknown) => Promise >(async () => ["127.0.0.1"]); -vi.mock("../../infra/ports.js", () => ({ +vi.mock("../../infra/ports-format.js", () => ({ classifyPortListener: (listener: unknown, port: number) => classifyPortListener(listener, port), formatPortDiagnostics: vi.fn(() => []), +})); + +vi.mock("../../infra/ports-inspect.js", () => ({ inspectPortUsage: (port: number, options?: { probeHosts?: readonly string[] }) => inspectPortUsage(port, options), +})); + +vi.mock("../../infra/ports-probe.js", () => ({ LOOPBACK_PORT_PROBE_HOSTS: ["127.0.0.1"], })); diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index 5ddeb7649349..9f4bf63965d6 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -4,7 +4,9 @@ import { resolveGatewayServiceProbeHosts } from "../../daemon/gateway-service-pr import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import type { GatewayService } from "../../daemon/service.js"; import type { PluginHealthErrorSummary } from "../../gateway/health/types.js"; -import { classifyPortListener, inspectPortUsage, type PortUsage } from "../../infra/ports.js"; +import { classifyPortListener } from "../../infra/ports-format.js"; +import { inspectPortUsage } from "../../infra/ports-inspect.js"; +import type { PortUsage } from "../../infra/ports-types.js"; import { hasActiveStartupMigrationLease, STARTUP_MIGRATION_LEASE_TTL_MS, diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 7d4c4d8fe84b..a3b44b19e5ab 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js"; import { createMockGatewayService } from "../../daemon/service.test-helpers.js"; -import type { PortListener, PortUsageStatus } from "../../infra/ports.js"; +import type { PortListener, PortUsageStatus } from "../../infra/ports-types.js"; import type { GatewayRestartHandoff } from "../../infra/restart-handoff.js"; import { defaultRuntime } from "../../runtime.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; @@ -15,7 +15,7 @@ import { gatherDaemonStatus } from "./status.gather.js"; import { printDaemonStatus } from "./status.print.js"; type PortConnections = Awaited< - ReturnType + ReturnType >; const callGatewayStatusProbe = vi.fn< @@ -270,7 +270,7 @@ vi.mock("../../gateway/probe-auth.js", async (importOriginal) => { }; }); -vi.mock("../../infra/ports.js", () => ({ +vi.mock("../../infra/ports-inspect.js", () => ({ inspectPortConnections: (port: number) => inspectPortConnections(port), inspectPortUsage: (port: number, options?: PortUsageInspectionOptions) => inspectPortUsage(port, options), @@ -278,6 +278,9 @@ vi.mock("../../infra/ports.js", () => ({ ports: readonly number[], options?: { probeHostsByPort?: ReadonlyMap }, ) => inspectPortUsages(ports, options), +})); + +vi.mock("../../infra/ports-format.js", () => ({ formatPortDiagnostics: () => [], })); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 6f53f1b5d529..9233350b2811 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -39,15 +39,13 @@ import { resolveBestEffortGatewayBindHostForDisplay, } from "../../infra/network-discovery-display.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; +import { formatPortDiagnostics } from "../../infra/ports-format.js"; import { - formatPortDiagnostics, inspectPortConnections, inspectPortUsage, inspectPortUsages, - type PortConnection, - type PortListener, - type PortUsageStatus, -} from "../../infra/ports.js"; +} from "../../infra/ports-inspect.js"; +import type { PortConnection, PortListener, PortUsageStatus } from "../../infra/ports-types.js"; import { readGatewayRestartHandoffSync, type GatewayRestartHandoff, diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index d3d911b7a84b..15e4003c7281 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -294,11 +294,12 @@ vi.mock("../../infra/gateway-lock.js", () => ({ GatewayLockError: class GatewayLockError extends Error {}, })); -vi.mock("../../infra/ports.js", () => ({ - formatPortDiagnostics: () => [], +vi.mock("../../infra/ports-inspect.js", () => ({ inspectPortUsage: async () => ({ status: "free" }), })); +vi.mock("../../infra/ports-format.js", () => ({ formatPortDiagnostics: () => [] })); + vi.mock("../../infra/supervisor-markers.js", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 1028761026c6..9b1bbeea4f28 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -1239,7 +1239,10 @@ async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRunt `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("openclaw gateway stop")}`, ); try { - const { formatPortDiagnostics, inspectPortUsage } = await import("../../infra/ports.js"); + const [{ formatPortDiagnostics }, { inspectPortUsage }] = await Promise.all([ + import("../../infra/ports-format.js"), + import("../../infra/ports-inspect.js"), + ]); const diagnostics = await inspectPortUsage(port); if (diagnostics.status === "busy") { for (const line of formatPortDiagnostics(diagnostics)) { diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index f1d7d5f628d6..9b8a30c505a8 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -407,8 +407,11 @@ vi.mock("../config/paths.js", async (importOriginal) => ({ ) => isDefaultInstallIdentity(env, homedir, platform), })); -vi.mock("../infra/ports.js", () => ({ +vi.mock("../infra/ports-inspect.js", () => ({ inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args), +})); + +vi.mock("../infra/ports-format.js", () => ({ classifyPortListener: (...args: unknown[]) => classifyPortListener(...args), formatPortDiagnostics: (...args: unknown[]) => formatPortDiagnostics(...args), })); diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 0f68db732ced..336edc1cb210 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -103,9 +103,12 @@ vi.mock("../gateway/net.js", () => ({ bindHost === "100.64.0.40" ? [bindHost, "127.0.0.1"] : [bindHost], })); -vi.mock("../infra/ports.js", () => ({ +vi.mock("../infra/ports-inspect.js", () => ({ inspectPortConnections, inspectPortUsage, +})); + +vi.mock("../infra/ports-format.js", () => ({ formatPortDiagnostics, isExpectedGatewayListeners, })); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 582f3102829f..3a461c0706e8 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -21,13 +21,9 @@ import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { resolveGatewayBindHost, resolveGatewayRequiredListenHosts } from "../gateway/net.js"; import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js"; -import { - formatPortDiagnostics, - inspectPortConnections, - inspectPortUsage, - isExpectedGatewayListeners, - type PortConnection, -} from "../infra/ports.js"; +import { formatPortDiagnostics, isExpectedGatewayListeners } from "../infra/ports-format.js"; +import { inspectPortConnections, inspectPortUsage } from "../infra/ports-inspect.js"; +import type { PortConnection } from "../infra/ports-types.js"; import { formatGatewayRestartHandoffDiagnostic, readGatewayRestartHandoffSync, diff --git a/src/commands/status-all/report-data.test.ts b/src/commands/status-all/report-data.test.ts index bac32f1c7027..40227b13caf3 100644 --- a/src/commands/status-all/report-data.test.ts +++ b/src/commands/status-all/report-data.test.ts @@ -24,7 +24,7 @@ vi.mock("../../gateway/net.js", () => ({ resolveGatewayRequiredListenHosts: (bindHost: string) => bindHost === "100.64.0.40" ? [bindHost, "127.0.0.1"] : [bindHost], })); -vi.mock("../../infra/ports.js", () => ({ inspectPortUsage: mocks.inspectPortUsage })); +vi.mock("../../infra/ports-inspect.js", () => ({ inspectPortUsage: mocks.inspectPortUsage })); vi.mock("../../infra/restart-sentinel.js", () => ({ readRestartSentinel: async () => null })); vi.mock("../../plugins/status.js", () => ({ buildPluginCompatibilityNotices: () => [] })); vi.mock("../../skills/discovery/status.js", () => ({ buildWorkspaceSkillStatus: () => null })); diff --git a/src/commands/status-all/report-data.ts b/src/commands/status-all/report-data.ts index 49715376526b..df1e415adf91 100644 --- a/src/commands/status-all/report-data.ts +++ b/src/commands/status-all/report-data.ts @@ -5,7 +5,7 @@ import { resolveNodeExecEligibility } from "../../agents/exec-defaults.js"; import { readConfigFileSnapshot, resolveGatewayPort } from "../../config/config.js"; import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js"; import { resolveGatewayBindHost, resolveGatewayRequiredListenHosts } from "../../gateway/net.js"; -import { inspectPortUsage } from "../../infra/ports.js"; +import { inspectPortUsage } from "../../infra/ports-inspect.js"; import { readRestartSentinel } from "../../infra/restart-sentinel.js"; import { buildPluginCompatibilityNotices } from "../../plugins/status.js"; import { buildWorkspaceSkillStatus } from "../../skills/discovery/status.js"; diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 8cc661256e57..f31de0f72d99 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -429,11 +429,12 @@ vi.mock("../infra/restart-stale-pids.js", () => ({ : cleanStaleGatewayProcessesSync(port, options), })); -vi.mock("../infra/ports.js", () => ({ - inspectPortUsage, +vi.mock("../infra/ports-format.js", () => ({ formatPortDiagnostics, })); +vi.mock("../infra/ports-inspect.js", () => ({ inspectPortUsage })); + vi.mock("../infra/ports-probe.js", () => ({ LOOPBACK_PORT_PROBE_HOSTS: ["127.0.0.1"], probePortUsage, diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index b7799c28371d..43d4cb6d2391 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -6,8 +6,9 @@ import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { normalizeEnvVarKey } from "../infra/host-env-security.js"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +import { formatPortDiagnostics } from "../infra/ports-format.js"; +import { inspectPortUsage } from "../infra/ports-inspect.js"; import { probePortUsage } from "../infra/ports-probe.js"; -import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js"; import { sleep } from "../utils.js"; diff --git a/src/daemon/schtasks-process.ts b/src/daemon/schtasks-process.ts index 361eb2d8d629..c0b3c0144ca9 100644 --- a/src/daemon/schtasks-process.ts +++ b/src/daemon/schtasks-process.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { isGatewayArgv } from "../infra/gateway-process-argv.js"; -import { inspectPortUsage, type PortListener } from "../infra/ports.js"; +import { inspectPortUsage } from "../infra/ports-inspect.js"; +import type { PortListener } from "../infra/ports-types.js"; import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js"; import { getWindowsPowerShellExePath, diff --git a/src/daemon/schtasks-runtime.ts b/src/daemon/schtasks-runtime.ts index b73640a01c06..24ab5cfab071 100644 --- a/src/daemon/schtasks-runtime.ts +++ b/src/daemon/schtasks-runtime.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js"; -import { inspectPortUsage } from "../infra/ports.js"; +import { inspectPortUsage } from "../infra/ports-inspect.js"; import { getWindowsCmdExePath, getWindowsPowerShellExePath, diff --git a/src/daemon/test-helpers/schtasks-base-mocks.ts b/src/daemon/test-helpers/schtasks-base-mocks.ts index 04889822660c..bc5659d478b2 100644 --- a/src/daemon/test-helpers/schtasks-base-mocks.ts +++ b/src/daemon/test-helpers/schtasks-base-mocks.ts @@ -16,7 +16,7 @@ vi.mock("../schtasks-exec.js", () => ({ }, })); -vi.mock("../../infra/ports.js", () => ({ +vi.mock("../../infra/ports-inspect.js", () => ({ inspectPortUsage: (port: number, options?: { probeHosts?: readonly string[] }) => inspectPortUsage(port, options), })); diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 61c619fea450..a4e435ec3cdd 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -1,4 +1,5 @@ // Covers gateway port availability and diagnostics behavior. +import { readFileSync } from "node:fs"; import net from "node:net"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; @@ -155,6 +156,13 @@ afterEach(() => { }); describe("ports helpers", () => { + it("keeps process inspection behind the busy-port diagnostics boundary", () => { + const source = readFileSync(new URL("./ports.ts", import.meta.url), "utf8"); + + expect(source).not.toMatch(/(?:import|export)[^;]+from "\.\/ports-inspect\.js"/u); + expect(source).toContain('await import("./ports-inspect.js")'); + }); + it("ensurePortAvailable rejects when port busy", async () => { const server = net.createServer(); const address = await listenServer(server, 0); diff --git a/src/infra/ports.ts b/src/infra/ports.ts index 744d9981b0a2..50c6113c7af2 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -5,9 +5,8 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { isErrno } from "./errors.js"; import { formatPortDiagnostics } from "./ports-format.js"; -import { inspectPortUsage } from "./ports-inspect.js"; -import { LOOPBACK_PORT_PROBE_HOSTS, tryListenOnPort } from "./ports-probe.js"; -import type { PortConnection, PortListener, PortUsage, PortUsageStatus } from "./ports-types.js"; +import { tryListenOnPort } from "./ports-probe.js"; +import type { PortUsage } from "./ports-types.js"; class PortInUseError extends Error { port: number; @@ -22,6 +21,7 @@ class PortInUseError extends Error { } export async function describePortOwner(port: number): Promise { + const { inspectPortUsage } = await import("./ports-inspect.js"); const diagnostics = await inspectPortUsage(port); if (diagnostics.listeners.length === 0) { return undefined; @@ -88,12 +88,10 @@ export async function handlePortError( } export { PortInUseError }; -export type { PortConnection, PortListener, PortUsage, PortUsageStatus }; +export type { PortUsage }; export { classifyPortListener, formatPortDiagnostics, isDualStackLoopbackGatewayListeners, isExpectedGatewayListeners, } from "./ports-format.js"; -export { LOOPBACK_PORT_PROBE_HOSTS }; -export { inspectPortConnections, inspectPortUsage, inspectPortUsages } from "./ports-inspect.js"; diff --git a/src/plugin-sdk/agent-scope-runtime.ts b/src/plugin-sdk/agent-scope-runtime.ts new file mode 100644 index 000000000000..df17d20c42fd --- /dev/null +++ b/src/plugin-sdk/agent-scope-runtime.ts @@ -0,0 +1,8 @@ +// Narrow agent-scope helpers for control-plane and migration paths. + +export { + listAgentIds, + resolveAgentDir, + resolveDefaultAgentId, + resolveSessionAgentIds, +} from "../agents/agent-scope.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-curated.ts b/src/plugin-sdk/memory-core-host-engine-curated.ts new file mode 100644 index 000000000000..a1f228006bd9 --- /dev/null +++ b/src/plugin-sdk/memory-core-host-engine-curated.ts @@ -0,0 +1,5 @@ +// Focused curated-memory annotation helpers for doctor and promotion paths. +export { + extractProjectKeysFromCuratedEntry, + type CuratedProjectAnnotations, +} from "../../packages/memory-host-sdk/src/host/curated-annotations.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-foundation.ts b/src/plugin-sdk/memory-core-host-engine-foundation.ts index de75fa909fa8..7db4add133af 100644 --- a/src/plugin-sdk/memory-core-host-engine-foundation.ts +++ b/src/plugin-sdk/memory-core-host-engine-foundation.ts @@ -2,7 +2,7 @@ * Public SDK foundation surface for memory host engine config, paths, and shared helpers. */ -export { isPathInside } from "../../packages/memory-host-sdk/src/engine-foundation.js"; +export { isPathInside } from "../../packages/memory-host-sdk/src/host/fs-utils.js"; export { resolveAgentContextLimits, resolveAgentDir, @@ -13,7 +13,6 @@ export { resolveMemorySearchSyncConfig, } from "../agents/memory-search.js"; export type { ResolvedMemorySearchConfig } from "../agents/memory-search.js"; - export type { OpenClawConfig } from "../config/config.js"; export { resolveStateDir } from "../config/paths.js"; export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-fs.ts b/src/plugin-sdk/memory-core-host-engine-fs.ts new file mode 100644 index 000000000000..01589ff83242 --- /dev/null +++ b/src/plugin-sdk/memory-core-host-engine-fs.ts @@ -0,0 +1,3 @@ +// Focused memory host filesystem helpers for doctor and migration control-plane paths. +export { root } from "../infra/fs-safe.js"; +export { resolveUserPath } from "../utils.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-schema.ts b/src/plugin-sdk/memory-core-host-engine-schema.ts new file mode 100644 index 000000000000..2611e386ce6e --- /dev/null +++ b/src/plugin-sdk/memory-core-host-engine-schema.ts @@ -0,0 +1,11 @@ +// Focused memory host schema helpers for doctor and migration control-plane paths. +export { + ensureMemoryIndexSchema, + MEMORY_EMBEDDING_CACHE_TABLE, + MEMORY_INDEX_CHUNKS_TABLE, + MEMORY_INDEX_FTS_TABLE, + MEMORY_INDEX_META_TABLE, + MEMORY_INDEX_SOURCES_TABLE, + MEMORY_INDEX_VECTOR_TABLE, +} from "../../packages/memory-host-sdk/src/host/memory-schema.js"; +export { loadSqliteVecExtension } from "../../packages/memory-host-sdk/src/host/sqlite-vec.js"; diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index 4cd7fa5320a1..ac7aaa8d892f 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -156,6 +156,7 @@ export type { UnifiedModelCatalogSource, } from "@openclaw/model-catalog-core/model-catalog-types"; export { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs"; +export { parseModelRef } from "../agents/model-selection-normalize.js"; export type { BedrockDiscoveryConfig, ModelCompatConfig, diff --git a/src/plugin-sdk/runtime-doctor-migrations.ts b/src/plugin-sdk/runtime-doctor-migrations.ts new file mode 100644 index 000000000000..7a2007e85053 --- /dev/null +++ b/src/plugin-sdk/runtime-doctor-migrations.ts @@ -0,0 +1,393 @@ +/** + * Dependency-light doctor migration helpers for plugin doctor contracts. + * + * Doctor contract enumeration cold-loads plugin `doctor-contract-api` closures, so + * this subpath must stay off heavy runtime graphs (state DB, plugin state stores, + * uninstall flows). Those stay on `runtime-doctor`. + */ +import fs from "node:fs/promises"; +import { asObjectRecord } from "../config/channel-compat-normalization.js"; +import type { CompatMutationResult } from "../config/channel-compat-normalization.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { OpenKeyedStoreOptions } from "../plugin-state/plugin-state-store.js"; +import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js"; +import { archiveLegacyStateSource } from "../plugins/doctor-state-migration-fs.js"; + +export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; +export { defineChannelAliasMigration } from "../config/channel-alias-migration.js"; +export type { + ChannelAliasMigrationSpec, + StreamingAliasMode, +} from "../config/channel-alias-migration.js"; +export { + asObjectRecord, + hasLegacyAccountStreamingAliases, + hasLegacyStreamingAliases, + normalizeLegacyChannelAliases, + normalizeLegacyDmAliases, + normalizeLegacyStreamingAliases, + resolveLegacyAliasStreamingMode, +} from "../config/channel-compat-normalization.js"; +export { + materializeInheritedAccountStreaming, + normalizeChannelAccounts, + normalizeChannelConfigEntries, + stripRetiredChannelKeys, +} from "../config/channel-doctor-helpers.js"; +export type { + CompatMutationResult, + LegacyStreamingAliasOptions, + NormalizeChannelConfigEntryParams, + NormalizeLegacyChannelAccountParams, + RetiredChannelKeyRemoval, +} from "../config/channel-compat-normalization.js"; +export type { + OpenKeyedStoreOptions, + PluginStateKeyedStore, +} from "../plugin-state/plugin-state-store.js"; +export type { + PluginDoctorStateMigration, + PluginDoctorStateMigrationContext, +} from "../plugins/doctor-contract-registry.js"; +export { + archiveLegacyStateSource, + legacyStateFileExists, +} from "../plugins/doctor-state-migration-fs.js"; +export type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; + +type KeyMoveValue = { value: unknown }; +type KeyMoveChangeContext = { + sourcePath: string; + targetPath: string; + sourceValue: unknown; + targetValue: unknown; + mappedValue: unknown; +}; + +/** Collects a channel's root config and object-shaped account overrides in config order. */ +export function collectChannelAccountScopes(params: { + cfg: OpenClawConfig; + channelId: string; +}): Array<{ + prefix: string; + pathSegments: string[]; + account: Record; +}> { + const scopes: Array<{ + prefix: string; + pathSegments: string[]; + account: Record; + }> = []; + const pathSegments = ["channels", params.channelId]; + const channels = asObjectRecord(params.cfg.channels); + const channel = asObjectRecord(channels?.[params.channelId]); + if (!channel) { + return scopes; + } + scopes.push({ prefix: pathSegments.join("."), pathSegments, account: channel }); + const accounts = asObjectRecord(channel.accounts); + if (!accounts) { + return scopes; + } + for (const [accountId, value] of Object.entries(accounts)) { + const account = asObjectRecord(value); + if (account) { + const accountPathSegments = [...pathSegments, "accounts", accountId]; + scopes.push({ + prefix: accountPathSegments.join("."), + pathSegments: accountPathSegments, + account, + }); + } + } + return scopes; +} + +function readKeyMovePath(entry: Record, path: readonly string[], own = true) { + let current = entry; + for (const segment of path.slice(0, -1)) { + const next = asObjectRecord(current[segment]); + if (!next) { + return null; + } + current = next; + } + const key = path.at(-1); + return key && (own ? Object.hasOwn(current, key) : key in current) + ? { value: current[key] } + : null; +} + +function setKeyMovePath( + entry: Record, + path: readonly string[], + value: unknown, +): Record { + const [key, ...rest] = path; + if (!key) { + return entry; + } + if (rest.length === 0) { + return { ...entry, [key]: value }; + } + return { + ...entry, + [key]: setKeyMovePath(asObjectRecord(entry[key]) ?? {}, rest, value), + }; +} + +function deleteKeyMovePath( + entry: Record, + path: readonly string[], + pruneEmpty: boolean, +): Record { + const [key, ...rest] = path; + if (!key) { + return entry; + } + const next = { ...entry }; + if (rest.length === 0) { + delete next[key]; + return next; + } + const child = asObjectRecord(entry[key]); + if (!child) { + return entry; + } + const updatedChild = deleteKeyMovePath(child, rest, pruneEmpty); + if (pruneEmpty && Object.keys(updatedChild).length === 0) { + delete next[key]; + } else { + next[key] = updatedChild; + } + return next; +} + +/** Defines an immutable legacy-key move across fixed or `*`-mapped object paths. */ +export function defineKeyMoveMigration(params: { + scope?: readonly string[]; + from: readonly string[]; + to: readonly string[]; + match?: (value: unknown) => boolean; + sourceOwn?: boolean; + map?: (value: unknown) => KeyMoveValue | null; + targetIsSet?: (value: unknown) => boolean; + pruneEmptySource?: boolean; + movedMessage?: (context: KeyMoveChangeContext) => string; + existingMessage?: (context: KeyMoveChangeContext) => string; + invalidMessage?: (context: KeyMoveChangeContext) => string; +}): { + hasLegacy: (value: unknown) => boolean; + normalize: (params: { + entry: Record; + pathPrefix: string; + changes: string[]; + }) => CompatMutationResult; +} { + const visitScopes = ( + entry: Record, + scope: readonly string[], + visit: (scopeEntry: Record, scopePath: readonly string[]) => boolean, + scopePath: readonly string[] = [], + ): boolean => { + const [segment, ...rest] = scope; + if (!segment) { + return visit(entry, scopePath); + } + if (segment === "*") { + return Object.entries(entry).some(([key, value]) => { + const child = asObjectRecord(value); + return child ? visitScopes(child, rest, visit, [...scopePath, key]) : false; + }); + } + const child = asObjectRecord(entry[segment]); + return child ? visitScopes(child, rest, visit, [...scopePath, segment]) : false; + }; + + const hasLegacy = (value: unknown): boolean => { + const entry = asObjectRecord(value); + return entry + ? visitScopes(entry, params.scope ?? [], (scopeEntry) => { + const source = readKeyMovePath(scopeEntry, params.from, params.sourceOwn); + return Boolean(source && (params.match?.(source.value) ?? true)); + }) + : false; + }; + + const normalizeScope = ( + scopeEntry: Record, + scopePath: readonly string[], + pathPrefix: string, + changes: string[], + ): CompatMutationResult => { + const source = readKeyMovePath(scopeEntry, params.from, params.sourceOwn); + if (!source || !(params.match?.(source.value) ?? true)) { + return { entry: scopeEntry, changed: false }; + } + const target = readKeyMovePath(scopeEntry, params.to); + const mapped = params.map ? params.map(source.value) : { value: source.value }; + const context: KeyMoveChangeContext = { + sourcePath: [pathPrefix, ...scopePath, ...params.from].join("."), + targetPath: [pathPrefix, ...scopePath, ...params.to].join("."), + sourceValue: source.value, + targetValue: target?.value, + mappedValue: mapped?.value, + }; + const targetSet = params.targetIsSet?.(target?.value) ?? target?.value !== undefined; + let updated = scopeEntry; + if (targetSet) { + changes.push( + params.existingMessage?.(context) ?? + `Removed ${context.sourcePath} (${context.targetPath} already set).`, + ); + } else if (mapped) { + updated = setKeyMovePath(updated, params.to, mapped.value); + changes.push( + params.movedMessage?.(context) ?? `Moved ${context.sourcePath} → ${context.targetPath}.`, + ); + } else { + changes.push( + params.invalidMessage?.(context) ?? `Removed invalid ${context.sourcePath} value.`, + ); + } + return { + entry: deleteKeyMovePath(updated, params.from, params.pruneEmptySource ?? false), + changed: true, + }; + }; + + const normalizeScopes = ( + entry: Record, + scope: readonly string[], + pathPrefix: string, + changes: string[], + scopePath: readonly string[] = [], + ): CompatMutationResult => { + const [segment, ...rest] = scope; + if (!segment) { + return normalizeScope(entry, scopePath, pathPrefix, changes); + } + let changed = false; + const updated = { ...entry }; + const keys = segment === "*" ? Object.keys(entry) : [segment]; + for (const key of keys) { + const child = asObjectRecord(entry[key]); + if (!child) { + continue; + } + const normalized = normalizeScopes(child, rest, pathPrefix, changes, [...scopePath, key]); + if (normalized.changed) { + updated[key] = normalized.entry; + changed = true; + } + } + return changed ? { entry: updated, changed: true } : { entry, changed: false }; + }; + + return { + hasLegacy, + normalize: ({ entry, pathPrefix, changes }) => + normalizeScopes(entry, params.scope ?? [], pathPrefix, changes), + }; +} + +/** Defines a single-file legacy JSON import into one keyed plugin-state namespace. */ +export function defineLegacyJsonStateMigration(params: { + id: string; + label: string; + resolvePath: (stateDir: string) => string; + parse: (value: unknown) => TSource | null; + namespace: string; + maxEntries: number; + overflowPolicy?: OpenKeyedStoreOptions["overflowPolicy"]; + archiveLabel?: string; + capacityPrecheck?: { + warning: (stats: { available: number; missing: number }) => string; + }; + describeEntries: ( + source: TSource, + context: { filePath: string; namespace: string }, + ) => { + preview: string[]; + change: (stats: { imported: number; alreadyPresent: number }) => string | null; + }; + toRows: (source: TSource) => readonly { key: string; value: unknown }[]; +}): PluginDoctorStateMigration { + const readSource = async (filePath: string): Promise => { + try { + return params.parse(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown); + } catch { + return null; + } + }; + const describe = (source: TSource, filePath: string) => + params.describeEntries(source, { filePath, namespace: params.namespace }); + + return { + id: params.id, + label: params.label, + async detectLegacyState({ stateDir }) { + const filePath = params.resolvePath(stateDir); + const source = await readSource(filePath); + if (!source) { + return null; + } + const rows = params.toRows(source); + if (rows.length === 0) { + return null; + } + const description = describe(source, filePath); + return { preview: description.preview }; + }, + async migrateLegacyState({ stateDir, context }) { + const changes: string[] = []; + const warnings: string[] = []; + const filePath = params.resolvePath(stateDir); + const source = await readSource(filePath); + if (!source) { + return { changes, warnings }; + } + const rows = params.toRows(source); + if (rows.length === 0) { + return { changes, warnings }; + } + const description = describe(source, filePath); + const store = context.openPluginStateKeyedStore({ + namespace: params.namespace, + maxEntries: params.maxEntries, + ...(params.overflowPolicy ? { overflowPolicy: params.overflowPolicy } : {}), + }); + if (params.capacityPrecheck) { + const existingKeys = new Set((await store.entries()).map((entry) => entry.key)); + const missingKeys = new Set( + rows.map((row) => row.key).filter((key) => !existingKeys.has(key)), + ); + const available = params.maxEntries - existingKeys.size; + if (missingKeys.size > available) { + warnings.push(params.capacityPrecheck.warning({ available, missing: missingKeys.size })); + return { changes, warnings }; + } + } + let imported = 0; + for (const row of rows) { + if (await store.registerIfAbsent(row.key, row.value)) { + imported++; + } + } + const change = description.change({ + imported, + alreadyPresent: rows.length - imported, + }); + if (change) { + changes.push(change); + } + await archiveLegacyStateSource({ + filePath, + label: params.archiveLabel ?? params.label, + changes, + warnings, + }); + return { changes, warnings }; + }, + }; +} diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index 987567ef7b78..f15f84c9f3bf 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -1,400 +1,54 @@ /** * 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. */ -import fs from "node:fs/promises"; -import { asObjectRecord } from "../config/channel-compat-normalization.js"; -import type { CompatMutationResult } from "../config/channel-compat-normalization.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { OpenKeyedStoreOptions } from "../plugin-state/plugin-state-store.js"; -import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js"; -import { archiveLegacyStateSource } from "../plugins/doctor-state-migration-fs.js"; - -export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; -export { defineChannelAliasMigration } from "../config/channel-alias-migration.js"; -export type { - ChannelAliasMigrationSpec, - StreamingAliasMode, -} from "../config/channel-alias-migration.js"; export { + archiveLegacyStateSource, asObjectRecord, + collectChannelAccountScopes, + collectProviderDangerousNameMatchingScopes, + defineChannelAliasMigration, + defineKeyMoveMigration, + defineLegacyJsonStateMigration, hasLegacyAccountStreamingAliases, hasLegacyStreamingAliases, + legacyStateFileExists, + materializeInheritedAccountStreaming, + normalizeChannelAccounts, + normalizeChannelConfigEntries, normalizeLegacyChannelAliases, normalizeLegacyDmAliases, normalizeLegacyStreamingAliases, resolveLegacyAliasStreamingMode, -} from "../config/channel-compat-normalization.js"; -export { - materializeInheritedAccountStreaming, - normalizeChannelAccounts, - normalizeChannelConfigEntries, stripRetiredChannelKeys, -} from "../config/channel-doctor-helpers.js"; +} from "./runtime-doctor-migrations.js"; export type { + ChannelAliasMigrationSpec, CompatMutationResult, + DoctorSessionRouteStateOwner, LegacyStreamingAliasOptions, NormalizeChannelConfigEntryParams, NormalizeLegacyChannelAccountParams, + OpenKeyedStoreOptions, + PluginDoctorStateMigration, + PluginDoctorStateMigrationContext, + PluginStateKeyedStore, RetiredChannelKeyRemoval, -} from "../config/channel-compat-normalization.js"; + StreamingAliasMode, +} from "./runtime-doctor-migrations.js"; + export { detectPluginInstallPathIssue, formatPluginInstallPathIssue, } from "../infra/plugin-install-path-warnings.js"; -export type { - OpenKeyedStoreOptions, - PluginStateKeyedStore, -} from "../plugin-state/plugin-state-store.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.js"; -export type { - PluginDoctorStateMigration, - PluginDoctorStateMigrationContext, -} from "../plugins/doctor-contract-registry.js"; -export { - archiveLegacyStateSource, - legacyStateFileExists, -} from "../plugins/doctor-state-migration-fs.js"; -export type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; - -type KeyMoveValue = { value: unknown }; -type KeyMoveChangeContext = { - sourcePath: string; - targetPath: string; - sourceValue: unknown; - targetValue: unknown; - mappedValue: unknown; -}; - -/** Collects a channel's root config and object-shaped account overrides in config order. */ -export function collectChannelAccountScopes(params: { - cfg: OpenClawConfig; - channelId: string; -}): Array<{ - prefix: string; - pathSegments: string[]; - account: Record; -}> { - const scopes: Array<{ - prefix: string; - pathSegments: string[]; - account: Record; - }> = []; - const pathSegments = ["channels", params.channelId]; - const channels = asObjectRecord(params.cfg.channels); - const channel = asObjectRecord(channels?.[params.channelId]); - if (!channel) { - return scopes; - } - scopes.push({ prefix: pathSegments.join("."), pathSegments, account: channel }); - const accounts = asObjectRecord(channel.accounts); - if (!accounts) { - return scopes; - } - for (const [accountId, value] of Object.entries(accounts)) { - const account = asObjectRecord(value); - if (account) { - const accountPathSegments = [...pathSegments, "accounts", accountId]; - scopes.push({ - prefix: accountPathSegments.join("."), - pathSegments: accountPathSegments, - account, - }); - } - } - return scopes; -} - -function readKeyMovePath(entry: Record, path: readonly string[], own = true) { - let current = entry; - for (const segment of path.slice(0, -1)) { - const next = asObjectRecord(current[segment]); - if (!next) { - return null; - } - current = next; - } - const key = path.at(-1); - return key && (own ? Object.hasOwn(current, key) : key in current) - ? { value: current[key] } - : null; -} - -function setKeyMovePath( - entry: Record, - path: readonly string[], - value: unknown, -): Record { - const [key, ...rest] = path; - if (!key) { - return entry; - } - if (rest.length === 0) { - return { ...entry, [key]: value }; - } - return { - ...entry, - [key]: setKeyMovePath(asObjectRecord(entry[key]) ?? {}, rest, value), - }; -} - -function deleteKeyMovePath( - entry: Record, - path: readonly string[], - pruneEmpty: boolean, -): Record { - const [key, ...rest] = path; - if (!key) { - return entry; - } - const next = { ...entry }; - if (rest.length === 0) { - delete next[key]; - return next; - } - const child = asObjectRecord(entry[key]); - if (!child) { - return entry; - } - const updatedChild = deleteKeyMovePath(child, rest, pruneEmpty); - if (pruneEmpty && Object.keys(updatedChild).length === 0) { - delete next[key]; - } else { - next[key] = updatedChild; - } - return next; -} - -/** Defines an immutable legacy-key move across fixed or `*`-mapped object paths. */ -export function defineKeyMoveMigration(params: { - scope?: readonly string[]; - from: readonly string[]; - to: readonly string[]; - match?: (value: unknown) => boolean; - sourceOwn?: boolean; - map?: (value: unknown) => KeyMoveValue | null; - targetIsSet?: (value: unknown) => boolean; - pruneEmptySource?: boolean; - movedMessage?: (context: KeyMoveChangeContext) => string; - existingMessage?: (context: KeyMoveChangeContext) => string; - invalidMessage?: (context: KeyMoveChangeContext) => string; -}): { - hasLegacy: (value: unknown) => boolean; - normalize: (params: { - entry: Record; - pathPrefix: string; - changes: string[]; - }) => CompatMutationResult; -} { - const visitScopes = ( - entry: Record, - scope: readonly string[], - visit: (scopeEntry: Record, scopePath: readonly string[]) => boolean, - scopePath: readonly string[] = [], - ): boolean => { - const [segment, ...rest] = scope; - if (!segment) { - return visit(entry, scopePath); - } - if (segment === "*") { - return Object.entries(entry).some(([key, value]) => { - const child = asObjectRecord(value); - return child ? visitScopes(child, rest, visit, [...scopePath, key]) : false; - }); - } - const child = asObjectRecord(entry[segment]); - return child ? visitScopes(child, rest, visit, [...scopePath, segment]) : false; - }; - - const hasLegacy = (value: unknown): boolean => { - const entry = asObjectRecord(value); - return entry - ? visitScopes(entry, params.scope ?? [], (scopeEntry) => { - const source = readKeyMovePath(scopeEntry, params.from, params.sourceOwn); - return Boolean(source && (params.match?.(source.value) ?? true)); - }) - : false; - }; - - const normalizeScope = ( - scopeEntry: Record, - scopePath: readonly string[], - pathPrefix: string, - changes: string[], - ): CompatMutationResult => { - const source = readKeyMovePath(scopeEntry, params.from, params.sourceOwn); - if (!source || !(params.match?.(source.value) ?? true)) { - return { entry: scopeEntry, changed: false }; - } - const target = readKeyMovePath(scopeEntry, params.to); - const mapped = params.map ? params.map(source.value) : { value: source.value }; - const context: KeyMoveChangeContext = { - sourcePath: [pathPrefix, ...scopePath, ...params.from].join("."), - targetPath: [pathPrefix, ...scopePath, ...params.to].join("."), - sourceValue: source.value, - targetValue: target?.value, - mappedValue: mapped?.value, - }; - const targetSet = params.targetIsSet?.(target?.value) ?? target?.value !== undefined; - let updated = scopeEntry; - if (targetSet) { - changes.push( - params.existingMessage?.(context) ?? - `Removed ${context.sourcePath} (${context.targetPath} already set).`, - ); - } else if (mapped) { - updated = setKeyMovePath(updated, params.to, mapped.value); - changes.push( - params.movedMessage?.(context) ?? `Moved ${context.sourcePath} → ${context.targetPath}.`, - ); - } else { - changes.push( - params.invalidMessage?.(context) ?? `Removed invalid ${context.sourcePath} value.`, - ); - } - return { - entry: deleteKeyMovePath(updated, params.from, params.pruneEmptySource ?? false), - changed: true, - }; - }; - - const normalizeScopes = ( - entry: Record, - scope: readonly string[], - pathPrefix: string, - changes: string[], - scopePath: readonly string[] = [], - ): CompatMutationResult => { - const [segment, ...rest] = scope; - if (!segment) { - return normalizeScope(entry, scopePath, pathPrefix, changes); - } - let changed = false; - const updated = { ...entry }; - const keys = segment === "*" ? Object.keys(entry) : [segment]; - for (const key of keys) { - const child = asObjectRecord(entry[key]); - if (!child) { - continue; - } - const normalized = normalizeScopes(child, rest, pathPrefix, changes, [...scopePath, key]); - if (normalized.changed) { - updated[key] = normalized.entry; - changed = true; - } - } - return changed ? { entry: updated, changed: true } : { entry, changed: false }; - }; - - return { - hasLegacy, - normalize: ({ entry, pathPrefix, changes }) => - normalizeScopes(entry, params.scope ?? [], pathPrefix, changes), - }; -} - -/** Defines a single-file legacy JSON import into one keyed plugin-state namespace. */ -export function defineLegacyJsonStateMigration(params: { - id: string; - label: string; - resolvePath: (stateDir: string) => string; - parse: (value: unknown) => TSource | null; - namespace: string; - maxEntries: number; - overflowPolicy?: OpenKeyedStoreOptions["overflowPolicy"]; - archiveLabel?: string; - capacityPrecheck?: { - warning: (stats: { available: number; missing: number }) => string; - }; - describeEntries: ( - source: TSource, - context: { filePath: string; namespace: string }, - ) => { - preview: string[]; - change: (stats: { imported: number; alreadyPresent: number }) => string | null; - }; - toRows: (source: TSource) => readonly { key: string; value: unknown }[]; -}): PluginDoctorStateMigration { - const readSource = async (filePath: string): Promise => { - try { - return params.parse(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown); - } catch { - return null; - } - }; - const describe = (source: TSource, filePath: string) => - params.describeEntries(source, { filePath, namespace: params.namespace }); - - return { - id: params.id, - label: params.label, - async detectLegacyState({ stateDir }) { - const filePath = params.resolvePath(stateDir); - const source = await readSource(filePath); - if (!source) { - return null; - } - const rows = params.toRows(source); - if (rows.length === 0) { - return null; - } - const description = describe(source, filePath); - return { preview: description.preview }; - }, - async migrateLegacyState({ stateDir, context }) { - const changes: string[] = []; - const warnings: string[] = []; - const filePath = params.resolvePath(stateDir); - const source = await readSource(filePath); - if (!source) { - return { changes, warnings }; - } - const rows = params.toRows(source); - if (rows.length === 0) { - return { changes, warnings }; - } - const description = describe(source, filePath); - const store = context.openPluginStateKeyedStore({ - namespace: params.namespace, - maxEntries: params.maxEntries, - ...(params.overflowPolicy ? { overflowPolicy: params.overflowPolicy } : {}), - }); - if (params.capacityPrecheck) { - const existingKeys = new Set((await store.entries()).map((entry) => entry.key)); - const missingKeys = new Set( - rows.map((row) => row.key).filter((key) => !existingKeys.has(key)), - ); - const available = params.maxEntries - existingKeys.size; - if (missingKeys.size > available) { - warnings.push(params.capacityPrecheck.warning({ available, missing: missingKeys.size })); - return { changes, warnings }; - } - } - let imported = 0; - for (const row of rows) { - if (await store.registerIfAbsent(row.key, row.value)) { - imported++; - } - } - const change = description.change({ - imported, - alreadyPresent: rows.length - imported, - }); - if (change) { - changes.push(change); - } - await archiveLegacyStateSource({ - filePath, - label: params.archiveLabel ?? params.label, - changes, - warnings, - }); - return { changes, warnings }; - }, - }; -} +export { removePluginFromConfig } from "../plugins/uninstall-config.js"; diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index e0c56d753ea1..3e04082fd9e5 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -57,6 +57,7 @@ const MEMORY_HOST_SDK_EXPORTS = [ "./status", ] as const; const MEMORY_HOST_SDK_ALLOWED_CORE_BRIDGE_FILES = [ + "packages/memory-host-sdk/src/host/config-utils.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-auth.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-network.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-sqlite.ts", diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 453f0b8568bc..eb90526191b2 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -791,6 +791,19 @@ describe("plugin-sdk subpath exports", () => { "listDirectoryEntriesFromSources", "listResolvedDirectoryEntriesFromSources", ]); + expectSourceContract("memory-core-host-engine-foundation", { + mentions: ['from "../../packages/memory-host-sdk/src/host/fs-utils.js"'], + omits: ['from "../../packages/memory-host-sdk/src/engine-foundation.js"'], + }); + expectSourceContract("memory-core-host-engine-curated", { + mentions: ["extractProjectKeysFromCuratedEntry"], + }); + expectSourceContract("memory-core-host-engine-fs", { + mentions: ["resolveUserPath", 'from "../infra/fs-safe.js"'], + }); + expectSourceContract("memory-core-host-engine-schema", { + mentions: ["ensureMemoryIndexSchema", "loadSqliteVecExtension"], + }); expectSourceContract("memory-core-host-runtime-core", { mentions: ["SILENT_REPLY_TOKEN", "resolveMemorySearchConfig", "MemoryPluginRuntime"], omits: ['export * from "../../packages/memory-host-sdk/src/runtime-core.js";'], diff --git a/src/plugins/doctor-contract-closure-guard.test.ts b/src/plugins/doctor-contract-closure-guard.test.ts new file mode 100644 index 000000000000..bfcde6b63b27 --- /dev/null +++ b/src/plugins/doctor-contract-closure-guard.test.ts @@ -0,0 +1,235 @@ +// Doctor contract closure guard tests keep enumeration paths dependency-light. +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { collectModuleReferencesFromSource } from "../../scripts/lib/guard-inventory-utils.mjs"; +import { resolvePluginDoctorContractArtifactPath } from "./doctor-contract-artifact.js"; +import { loadBundledPluginManifestRegistry } from "./manifest-registry.js"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); +const SOURCE_MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"] as const; +const FORBIDDEN_SPECIFIER = "openclaw/plugin-sdk/agent-runtime"; +const LEGACY_SETUP_PROPERTIES = new Set([ + "legacyStateMigrations", + "legacySessionSurface", + "legacySessionSurfaces", +]); + +type ClosureEntry = { + pluginId: string; + pluginRoot: string; + entryPath: string; +}; + +type ModuleReference = ReturnType[number]; + +function formatRepoPath(filePath: string): string { + return path.relative(REPO_ROOT, filePath).split(path.sep).join("/"); +} + +function isInsideRoot(rootPath: string, filePath: string): boolean { + const relativePath = path.relative(rootPath, filePath); + return !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath); +} + +function resolveRelativeSourceModule(importerPath: string, specifier: string): string | null { + const targetPath = path.resolve(path.dirname(importerPath), specifier); + const targetExtension = path.extname(targetPath); + const candidates: string[] = []; + if ( + SOURCE_MODULE_EXTENSIONS.includes(targetExtension as (typeof SOURCE_MODULE_EXTENSIONS)[number]) + ) { + const stem = targetPath.slice(0, -targetExtension.length); + candidates.push(...SOURCE_MODULE_EXTENSIONS.map((extension) => `${stem}${extension}`)); + } else if (!targetExtension) { + for (const extension of SOURCE_MODULE_EXTENSIONS) { + candidates.push(`${targetPath}${extension}`, path.join(targetPath, `index${extension}`)); + } + } + return candidates.find((candidate) => fs.existsSync(candidate)) ?? null; +} + +function propertyNameText(name: ts.PropertyName): string | null { + return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : null; +} + +function collectLegacySetupSpecifiers(setupEntryPath: string): string[] { + const source = fs.readFileSync(setupEntryPath, "utf8"); + const sourceFile = ts.createSourceFile(setupEntryPath, source, ts.ScriptTarget.Latest, true); + const specifiers = new Set(); + + const visit = (node: ts.Node) => { + if ( + ts.isPropertyAssignment(node) && + LEGACY_SETUP_PROPERTIES.has(propertyNameText(node.name) ?? "") && + ts.isObjectLiteralExpression(node.initializer) + ) { + for (const property of node.initializer.properties) { + if ( + ts.isPropertyAssignment(property) && + propertyNameText(property.name) === "specifier" && + ts.isStringLiteralLike(property.initializer) + ) { + specifiers.add(property.initializer.text); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return [...specifiers].toSorted(); +} + +function collectStaticValueReferenceKeys(sourceFile: ts.SourceFile): Set { + const keys = new Set(); + const add = (kind: "commonjs-require" | "import" | "export", specifier: ts.StringLiteralLike) => { + const line = sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1; + keys.add(`${kind}\0${line}\0${specifier.text}`); + }; + const visit = (node: ts.Node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) { + const clause = node.importClause; + const hasValueBinding = + !clause || + (!clause.isTypeOnly && + (Boolean(clause.name) || + (clause.namedBindings !== undefined && + (ts.isNamespaceImport(clause.namedBindings) || + clause.namedBindings.elements.some((element) => !element.isTypeOnly))))); + if (hasValueBinding) { + add("import", node.moduleSpecifier); + } + } else if ( + ts.isExportDeclaration(node) && + !node.isTypeOnly && + node.moduleSpecifier && + ts.isStringLiteralLike(node.moduleSpecifier) + ) { + const clause = node.exportClause; + if ( + !clause || + ts.isNamespaceExport(clause) || + clause.elements.some((element) => !element.isTypeOnly) + ) { + add("export", node.moduleSpecifier); + } + } else if ( + ts.isImportEqualsDeclaration(node) && + !node.isTypeOnly && + ts.isExternalModuleReference(node.moduleReference) && + node.moduleReference.expression && + ts.isStringLiteralLike(node.moduleReference.expression) + ) { + add("commonjs-require", node.moduleReference.expression); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return keys; +} + +function collectStaticValueReferences(filePath: string, source: string): ModuleReference[] { + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const staticValueReferenceKeys = collectStaticValueReferenceKeys(sourceFile); + return collectModuleReferencesFromSource(source, { + fileName: filePath, + acceptSpecifier: (specifier) => specifier === FORBIDDEN_SPECIFIER || specifier.startsWith("."), + }).filter((reference) => + staticValueReferenceKeys.has(`${reference.kind}\0${reference.line}\0${reference.specifier}`), + ); +} + +function collectClosureEntries(): ClosureEntry[] { + const entries: ClosureEntry[] = []; + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(REPO_ROOT, "extensions"), + }; + for (const record of loadBundledPluginManifestRegistry({ env }).plugins) { + const pluginRoot = path.resolve(record.rootDir); + const doctorContractPath = resolvePluginDoctorContractArtifactPath(pluginRoot); + if (doctorContractPath) { + entries.push({ pluginId: record.id, pluginRoot, entryPath: doctorContractPath }); + } + + if (record.channels.length === 0) { + continue; + } + const packageJsonPath = path.join(pluginRoot, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + openclaw?: { setupEntry?: unknown }; + }; + const setupEntry = packageJson.openclaw?.setupEntry; + if (typeof setupEntry !== "string") { + continue; + } + const setupEntryPath = path.resolve(pluginRoot, setupEntry); + for (const specifier of collectLegacySetupSpecifiers(setupEntryPath)) { + const entryPath = resolveRelativeSourceModule(setupEntryPath, specifier); + if (entryPath && isInsideRoot(pluginRoot, entryPath)) { + entries.push({ pluginId: record.id, pluginRoot, entryPath }); + } + } + } + return entries; +} + +function collectBroadAgentRuntimeImports(entry: ClosureEntry): string[] { + const violations: string[] = []; + const visited = new Set(); + const pending = [entry.entryPath]; + + while (pending.length > 0) { + const filePath = pending.pop(); + if (!filePath || visited.has(filePath)) { + continue; + } + visited.add(filePath); + const source = fs.readFileSync(filePath, "utf8"); + for (const reference of collectStaticValueReferences(filePath, source)) { + if (reference.specifier === FORBIDDEN_SPECIFIER) { + violations.push( + `${entry.pluginId}: ${formatRepoPath(filePath)}:${reference.line} imports ${FORBIDDEN_SPECIFIER}; ` + + "the deprecated broad barrel makes doctor enumeration cold-load the core agents graph; " + + "use openclaw/plugin-sdk/agent-scope-runtime or another focused subpath", + ); + continue; + } + const resolvedPath = resolveRelativeSourceModule(filePath, reference.specifier); + if (resolvedPath && isInsideRoot(entry.pluginRoot, resolvedPath)) { + pending.push(resolvedPath); + } + } + } + + return violations; +} + +describe("doctor contract import closures", () => { + it("classifies only static value module edges", () => { + const source = [ + `import type { A } from "${FORBIDDEN_SPECIFIER}";`, + `import { type B } from "${FORBIDDEN_SPECIFIER}";`, + `export type { C } from "${FORBIDDEN_SPECIFIER}";`, + `export { type D } from "${FORBIDDEN_SPECIFIER}";`, + `type E = import("${FORBIDDEN_SPECIFIER}").E;`, + `const lazy = () => import("${FORBIDDEN_SPECIFIER}");`, + `import { type F, value } from "${FORBIDDEN_SPECIFIER}";`, + `export { type G, otherValue } from "${FORBIDDEN_SPECIFIER}";`, + ].join("\n"); + + expect(collectStaticValueReferences("fixture.ts", source)).toEqual([ + { kind: "import", line: 7, specifier: FORBIDDEN_SPECIFIER }, + { kind: "export", line: 8, specifier: FORBIDDEN_SPECIFIER }, + ]); + }); + + it("keep the broad agent runtime barrel off doctor enumeration paths", () => { + const violations = collectClosureEntries().flatMap(collectBroadAgentRuntimeImports).toSorted(); + expect(violations).toStrictEqual([]); + }); +}); diff --git a/src/plugins/doctor-contract-declarations.test.ts b/src/plugins/doctor-contract-declarations.test.ts new file mode 100644 index 000000000000..f6f4443f8caa --- /dev/null +++ b/src/plugins/doctor-contract-declarations.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { resolvePluginDoctorContractArtifactPath } from "./doctor-contract-artifact.js"; +import { coercePluginDoctorContractModule } from "./doctor-contract-module.js"; +import { loadBundledPluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginManifestDoctorContract } from "./manifest-types.js"; +import { + createPluginModuleLoaderCache, + getCachedPluginModuleLoader, +} from "./plugin-module-loader-cache.js"; + +const DOCTOR_CONTRACT_SURFACES = [ + "legacyConfigRules", + "normalizeCompatibilityConfig", + "resolveSessionStoreAgentIds", + "sessionRouteStateOwners", + "stateMigrations", +] as const satisfies readonly (keyof PluginManifestDoctorContract)[]; + +describe("bundled plugin doctor contract declarations", () => { + it("matches every resolvable artifact's coerced doctor surfaces", () => { + const moduleLoaders = createPluginModuleLoaderCache(); + const mismatches: string[] = []; + + for (const record of loadBundledPluginManifestRegistry().plugins) { + const artifactPath = resolvePluginDoctorContractArtifactPath(record.rootDir); + if (!artifactPath) { + continue; + } + const declaration = record.doctorContract; + if (!declaration) { + mismatches.push(`${record.id}: missing doctorContract declaration`); + continue; + } + const mod = getCachedPluginModuleLoader({ + cache: moduleLoaders, + modulePath: artifactPath, + importerUrl: import.meta.url, + })(artifactPath) as Parameters[0]; + const { summary } = coercePluginDoctorContractModule(mod); + for (const surface of DOCTOR_CONTRACT_SURFACES) { + const declared = declaration[surface] === true; + if (declared !== summary[surface]) { + mismatches.push( + `${record.id}:${surface} declared=${String(declared)} actual=${String(summary[surface])}`, + ); + } + } + } + + expect(mismatches).toStrictEqual([]); + }, 600_000); +}); diff --git a/src/plugins/doctor-contract-module.ts b/src/plugins/doctor-contract-module.ts new file mode 100644 index 000000000000..8c4bcb4fb976 --- /dev/null +++ b/src/plugins/doctor-contract-module.ts @@ -0,0 +1,202 @@ +import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +import type { LegacyConfigRule } from "../config/legacy.shared.js"; +import type { OpenClawConfig } from "../config/types.js"; +import type { + OpenKeyedStoreOptions, + PluginStateKeyedStore, +} from "../plugin-state/plugin-state-store.js"; +import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-owner-types.js"; +import type { PluginManifestDoctorContract } from "./manifest-types.js"; + +export type PluginDoctorStateMigrationDetection = { + preview: string[]; +}; + +export type PluginDoctorStateMigrationContext = { + openPluginStateKeyedStore: (options: OpenKeyedStoreOptions) => PluginStateKeyedStore; + /** Doctor-only batch import preserving source age and remaining retention. */ + importPluginStateEntries?: ( + options: OpenKeyedStoreOptions, + entries: readonly { key: string; value: unknown; createdAt: number; ttlMs?: number }[], + ) => void; + /** Plugin-wide live-row capacity for import preflight. Older test hosts may omit it. */ + getPluginStateCapacity?: () => { liveEntries: number; maxEntries: number }; +}; + +export type PluginDoctorStateMigration = { + id: string; + label: string; + /** Import retired file state only during explicit `doctor --fix` repair. */ + doctorOnly?: boolean; + detectLegacyState: (params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; + context: PluginDoctorStateMigrationContext; + }) => + | Promise + | PluginDoctorStateMigrationDetection + | null; + migrateLegacyState: (params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; + context: PluginDoctorStateMigrationContext; + }) => + | Promise<{ changes: string[]; warnings: string[]; notices?: string[] }> + | { changes: string[]; warnings: string[]; notices?: string[] }; +}; + +export type PluginDoctorContractModule = { + legacyConfigRules?: unknown; + normalizeCompatibilityConfig?: unknown; + resolveSessionStoreAgentIds?: unknown; + sessionRouteStateOwners?: unknown; + stateMigrations?: unknown; +}; + +type PluginDoctorCompatibilityNormalizer = (params: { cfg: OpenClawConfig }) => { + config: OpenClawConfig; + changes: string[]; +}; + +type PluginDoctorSessionStoreAgentIdsResolver = (params: { + cfg: OpenClawConfig; +}) => readonly string[]; + +function coerceLegacyConfigRules(value: unknown): LegacyConfigRule[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry) => { + if (!entry || typeof entry !== "object") { + return false; + } + const candidate = entry as { path?: unknown; message?: unknown }; + return Array.isArray(candidate.path) && typeof candidate.message === "string"; + }) as LegacyConfigRule[]; +} + +function coerceNormalizeCompatibilityConfig( + value: unknown, +): PluginDoctorCompatibilityNormalizer | undefined { + return typeof value === "function" ? (value as PluginDoctorCompatibilityNormalizer) : undefined; +} + +function coerceSessionStoreAgentIdsResolver( + value: unknown, +): PluginDoctorSessionStoreAgentIdsResolver | undefined { + return typeof value === "function" + ? (value as PluginDoctorSessionStoreAgentIdsResolver) + : undefined; +} + +function isDoctorSessionRouteStateOwner(value: unknown): value is DoctorSessionRouteStateOwner { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + id?: unknown; + label?: unknown; + providerIds?: unknown; + runtimeIds?: unknown; + cliSessionKeys?: unknown; + authProfilePrefixes?: unknown; + }; + return ( + typeof candidate.id === "string" && + typeof candidate.label === "string" && + candidate.id.trim().length > 0 && + candidate.label.trim().length > 0 && + (candidate.providerIds === undefined || + normalizeTrimmedStringList(candidate.providerIds).length > 0) && + (candidate.runtimeIds === undefined || + normalizeTrimmedStringList(candidate.runtimeIds).length > 0) && + (candidate.cliSessionKeys === undefined || + normalizeTrimmedStringList(candidate.cliSessionKeys).length > 0) && + (candidate.authProfilePrefixes === undefined || + normalizeTrimmedStringList(candidate.authProfilePrefixes).length > 0) + ); +} + +function coerceDoctorSessionRouteStateOwners(value: unknown): DoctorSessionRouteStateOwner[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isDoctorSessionRouteStateOwner).map((owner) => ({ + id: owner.id.trim(), + label: owner.label.trim(), + providerIds: normalizeTrimmedStringList(owner.providerIds), + runtimeIds: normalizeTrimmedStringList(owner.runtimeIds), + cliSessionKeys: normalizeTrimmedStringList(owner.cliSessionKeys), + authProfilePrefixes: normalizeTrimmedStringList(owner.authProfilePrefixes), + })); +} + +function isPluginDoctorStateMigration(value: unknown): value is PluginDoctorStateMigration { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + id?: unknown; + label?: unknown; + detectLegacyState?: unknown; + migrateLegacyState?: unknown; + }; + return ( + typeof candidate.id === "string" && + candidate.id.trim().length > 0 && + typeof candidate.label === "string" && + candidate.label.trim().length > 0 && + typeof candidate.detectLegacyState === "function" && + typeof candidate.migrateLegacyState === "function" + ); +} + +function coercePluginDoctorStateMigrations(value: unknown): PluginDoctorStateMigration[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isPluginDoctorStateMigration).map((migration) => ({ + id: migration.id.trim(), + label: migration.label.trim(), + doctorOnly: migration.doctorOnly === true ? true : undefined, + detectLegacyState: migration.detectLegacyState, + migrateLegacyState: migration.migrateLegacyState, + })); +} + +/** Coerce a loaded doctor contract once for both registry use and declaration validation. */ +export function coercePluginDoctorContractModule(mod: PluginDoctorContractModule) { + const defaultExport = (mod as { default?: PluginDoctorContractModule }).default; + const rules = coerceLegacyConfigRules(defaultExport?.legacyConfigRules ?? mod.legacyConfigRules); + const normalizeCompatibilityConfig = coerceNormalizeCompatibilityConfig( + mod.normalizeCompatibilityConfig ?? defaultExport?.normalizeCompatibilityConfig, + ); + const resolveSessionStoreAgentIds = coerceSessionStoreAgentIdsResolver( + mod.resolveSessionStoreAgentIds ?? defaultExport?.resolveSessionStoreAgentIds, + ); + const sessionRouteStateOwners = coerceDoctorSessionRouteStateOwners( + mod.sessionRouteStateOwners ?? defaultExport?.sessionRouteStateOwners, + ); + const stateMigrations = coercePluginDoctorStateMigrations( + mod.stateMigrations ?? defaultExport?.stateMigrations, + ); + const summary: Record = { + legacyConfigRules: rules.length > 0, + normalizeCompatibilityConfig: Boolean(normalizeCompatibilityConfig), + resolveSessionStoreAgentIds: Boolean(resolveSessionStoreAgentIds), + sessionRouteStateOwners: sessionRouteStateOwners.length > 0, + stateMigrations: stateMigrations.length > 0, + }; + return { + rules, + normalizeCompatibilityConfig, + resolveSessionStoreAgentIds, + sessionRouteStateOwners, + stateMigrations, + summary, + }; +} diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index 32a4a2f01c9c..98d88f6138f5 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -13,6 +13,18 @@ import { const tempDirs: string[] = []; const mocks = getRegistryJitiMocks(); +const doctorContractWarnMock = vi.hoisted(() => vi.fn()); + +vi.mock("../logging/subsystem.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSubsystemLogger: (subsystem: string) => ({ + ...actual.createSubsystemLogger(subsystem), + warn: doctorContractWarnMock, + }), + }; +}); let applyPluginDoctorCompatibilityMigrations: typeof import("./doctor-contract-registry.js").applyPluginDoctorCompatibilityMigrations; let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.test-fixtures.js").clearPluginDoctorContractRegistryCache; @@ -45,6 +57,7 @@ afterEach(() => { describe("doctor-contract-registry module loader", () => { beforeEach(async () => { resetRegistryJitiMocks(); + doctorContractWarnMock.mockReset(); vi.resetModules(); ({ applyPluginDoctorCompatibilityMigrations, @@ -88,6 +101,74 @@ describe("doctor-contract-registry module loader", () => { expect(resolvePluginDoctorContractArtifactPath(pluginRoot)).toBe(rootContractTypeScript); }); + it.each([ + { + name: "declared false skips loading", + doctorContract: { legacyConfigRules: false }, + expectedRuleCount: 0, + expectedLoadCount: 0, + }, + { + name: "absent declaration preserves loading", + doctorContract: undefined, + expectedRuleCount: 1, + expectedLoadCount: 1, + }, + { + name: "declared true loads the authoritative module", + doctorContract: { legacyConfigRules: true }, + expectedRuleCount: 1, + expectedLoadCount: 1, + }, + ])("gates doctor contract artifacts by surface: $name", (testCase) => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "doctor-contract-api.ts"), "export {};\n", "utf-8"); + mocks.createJiti.mockImplementation(() => () => ({ + legacyConfigRules: [{ path: ["plugins", "entries", "demo"], message: "demo rule" }], + })); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "test-plugin", + rootDir: pluginRoot, + ...(testCase.doctorContract ? { doctorContract: testCase.doctorContract } : {}), + }, + ], + diagnostics: [], + }); + + expect(listPluginDoctorLegacyConfigRules({ workspaceDir: pluginRoot, env: {} })).toHaveLength( + testCase.expectedRuleCount, + ); + expect(mocks.createJiti).toHaveBeenCalledTimes(testCase.expectedLoadCount); + }); + + it("records doctor contract load failures with plugin and artifact context", () => { + const pluginRoot = makeTempDir(); + const contractSource = path.join(pluginRoot, "doctor-contract-api.ts"); + fs.writeFileSync(contractSource, "export {};\n", "utf-8"); + mocks.createJiti.mockImplementation(() => () => { + throw new Error("fixture module load failed"); + }); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "broken-doctor-plugin", + rootDir: pluginRoot, + doctorContract: { legacyConfigRules: true }, + }, + ], + diagnostics: [], + }); + + expect(listPluginDoctorLegacyConfigRules({ workspaceDir: pluginRoot, env: {} })).toEqual([]); + expect(doctorContractWarnMock).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining( + `failed to load doctor contract for broken-doctor-plugin from ${contractSource}: fixture module load failed`, + ), + ); + }); + it("uses native require on Windows for compatible JavaScript contract-api modules", () => { const pluginRoot = makeTempDir(); fs.writeFileSync( diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index df3ec1f50b80..156a35431b43 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -4,88 +4,44 @@ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import type { LegacyConfigRule } from "../config/legacy.shared.js"; import type { OpenClawConfig } from "../config/types.js"; -import type { - OpenKeyedStoreOptions, - PluginStateKeyedStore, -} from "../plugin-state/plugin-state-store.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolvePluginDoctorContractArtifactPath } from "./doctor-contract-artifact.js"; +import { + coercePluginDoctorContractModule, + type PluginDoctorContractModule, + type PluginDoctorStateMigration, +} from "./doctor-contract-module.js"; import { pluginDoctorContractRegistryLoaderState } from "./doctor-contract-registry-loader-state.js"; import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-owner-types.js"; import type { PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginManifestDoctorContract } from "./manifest-types.js"; import { getCachedPluginModuleLoader } from "./plugin-module-loader-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; -type PluginDoctorContractModule = { - legacyConfigRules?: unknown; - normalizeCompatibilityConfig?: unknown; - resolveSessionStoreAgentIds?: unknown; - sessionRouteStateOwners?: unknown; - stateMigrations?: unknown; -}; +const log = createSubsystemLogger("plugins/doctor-contracts"); -type PluginDoctorCompatibilityMutation = { - config: OpenClawConfig; - changes: string[]; -}; +type PluginDoctorContractSurface = keyof PluginManifestDoctorContract; -type PluginDoctorCompatibilityNormalizer = (params: { - cfg: OpenClawConfig; -}) => PluginDoctorCompatibilityMutation; - -type PluginDoctorSessionStoreAgentIdsResolver = (params: { - cfg: OpenClawConfig; -}) => readonly string[]; +export type { + PluginDoctorStateMigration, + PluginDoctorStateMigrationContext, + PluginDoctorStateMigrationDetection, +} from "./doctor-contract-module.js"; type PluginDoctorContractEntry = { pluginId: string; rules: LegacyConfigRule[]; - normalizeCompatibilityConfig?: PluginDoctorCompatibilityNormalizer; - resolveSessionStoreAgentIds?: PluginDoctorSessionStoreAgentIdsResolver; + normalizeCompatibilityConfig?: ReturnType< + typeof coercePluginDoctorContractModule + >["normalizeCompatibilityConfig"]; + resolveSessionStoreAgentIds?: ReturnType< + typeof coercePluginDoctorContractModule + >["resolveSessionStoreAgentIds"]; sessionRouteStateOwners: DoctorSessionRouteStateOwner[]; stateMigrations: PluginDoctorStateMigration[]; }; -export type PluginDoctorStateMigrationDetection = { - preview: string[]; -}; - -export type PluginDoctorStateMigrationContext = { - openPluginStateKeyedStore: (options: OpenKeyedStoreOptions) => PluginStateKeyedStore; - /** Doctor-only batch import preserving source age and remaining retention. */ - importPluginStateEntries?: ( - options: OpenKeyedStoreOptions, - entries: readonly { key: string; value: unknown; createdAt: number; ttlMs?: number }[], - ) => void; - /** Plugin-wide live-row capacity for import preflight. Older test hosts may omit it. */ - getPluginStateCapacity?: () => { liveEntries: number; maxEntries: number }; -}; - -export type PluginDoctorStateMigration = { - id: string; - label: string; - /** Import retired file state only during explicit `doctor --fix` repair. */ - doctorOnly?: boolean; - detectLegacyState: (params: { - config: OpenClawConfig; - env: NodeJS.ProcessEnv; - stateDir: string; - oauthDir: string; - context: PluginDoctorStateMigrationContext; - }) => - | Promise - | PluginDoctorStateMigrationDetection - | null; - migrateLegacyState: (params: { - config: OpenClawConfig; - env: NodeJS.ProcessEnv; - stateDir: string; - oauthDir: string; - context: PluginDoctorStateMigrationContext; - }) => - | Promise<{ changes: string[]; warnings: string[]; notices?: string[] }> - | { changes: string[]; warnings: string[]; notices?: string[] }; -}; - type PluginDoctorStateMigrationEntry = { pluginId: string; migration: PluginDoctorStateMigration; @@ -104,108 +60,6 @@ function loadPluginDoctorContractModule(modulePath: string): PluginDoctorContrac })(modulePath) as PluginDoctorContractModule; } -function coerceLegacyConfigRules(value: unknown): LegacyConfigRule[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter((entry) => { - if (!entry || typeof entry !== "object") { - return false; - } - const candidate = entry as { path?: unknown; message?: unknown }; - return Array.isArray(candidate.path) && typeof candidate.message === "string"; - }) as LegacyConfigRule[]; -} - -function coerceNormalizeCompatibilityConfig( - value: unknown, -): PluginDoctorCompatibilityNormalizer | undefined { - return typeof value === "function" ? (value as PluginDoctorCompatibilityNormalizer) : undefined; -} - -function coerceSessionStoreAgentIdsResolver( - value: unknown, -): PluginDoctorSessionStoreAgentIdsResolver | undefined { - return typeof value === "function" - ? (value as PluginDoctorSessionStoreAgentIdsResolver) - : undefined; -} - -function isDoctorSessionRouteStateOwner(value: unknown): value is DoctorSessionRouteStateOwner { - if (!value || typeof value !== "object") { - return false; - } - const candidate = value as { - id?: unknown; - label?: unknown; - providerIds?: unknown; - runtimeIds?: unknown; - cliSessionKeys?: unknown; - authProfilePrefixes?: unknown; - }; - return ( - typeof candidate.id === "string" && - typeof candidate.label === "string" && - candidate.id.trim().length > 0 && - candidate.label.trim().length > 0 && - (candidate.providerIds === undefined || - normalizeTrimmedStringList(candidate.providerIds).length > 0) && - (candidate.runtimeIds === undefined || - normalizeTrimmedStringList(candidate.runtimeIds).length > 0) && - (candidate.cliSessionKeys === undefined || - normalizeTrimmedStringList(candidate.cliSessionKeys).length > 0) && - (candidate.authProfilePrefixes === undefined || - normalizeTrimmedStringList(candidate.authProfilePrefixes).length > 0) - ); -} - -function coerceDoctorSessionRouteStateOwners(value: unknown): DoctorSessionRouteStateOwner[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter(isDoctorSessionRouteStateOwner).map((owner) => ({ - id: owner.id.trim(), - label: owner.label.trim(), - providerIds: normalizeTrimmedStringList(owner.providerIds), - runtimeIds: normalizeTrimmedStringList(owner.runtimeIds), - cliSessionKeys: normalizeTrimmedStringList(owner.cliSessionKeys), - authProfilePrefixes: normalizeTrimmedStringList(owner.authProfilePrefixes), - })); -} - -function isPluginDoctorStateMigration(value: unknown): value is PluginDoctorStateMigration { - if (!value || typeof value !== "object") { - return false; - } - const candidate = value as { - id?: unknown; - label?: unknown; - detectLegacyState?: unknown; - migrateLegacyState?: unknown; - }; - return ( - typeof candidate.id === "string" && - candidate.id.trim().length > 0 && - typeof candidate.label === "string" && - candidate.label.trim().length > 0 && - typeof candidate.detectLegacyState === "function" && - typeof candidate.migrateLegacyState === "function" - ); -} - -function coercePluginDoctorStateMigrations(value: unknown): PluginDoctorStateMigration[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter(isPluginDoctorStateMigration).map((migration) => ({ - id: migration.id.trim(), - label: migration.label.trim(), - doctorOnly: migration.doctorOnly === true ? true : undefined, - detectLegacyState: migration.detectLegacyState, - migrateLegacyState: migration.migrateLegacyState, - })); -} - function hasLegacyElevenLabsTalkFields(raw: unknown): boolean { const talk = asNullableRecord(asNullableRecord(raw)?.talk); if (!talk) { @@ -338,49 +192,24 @@ function loadPluginDoctorContractEntry( let mod: PluginDoctorContractModule; try { mod = loadPluginDoctorContractModule(contractSource); - } catch { + } catch (error) { + log.warn( + `failed to load doctor contract for ${record.id} from ${contractSource}: ${formatErrorMessage(error)}`, + ); return null; } - const rules = coerceLegacyConfigRules( - (mod as { default?: PluginDoctorContractModule }).default?.legacyConfigRules ?? - mod.legacyConfigRules, - ); - const normalizeCompatibilityConfig = coerceNormalizeCompatibilityConfig( - mod.normalizeCompatibilityConfig ?? - (mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig, - ); - const resolveSessionStoreAgentIds = coerceSessionStoreAgentIdsResolver( - mod.resolveSessionStoreAgentIds ?? - (mod as { default?: PluginDoctorContractModule }).default?.resolveSessionStoreAgentIds, - ); - const sessionRouteStateOwners = coerceDoctorSessionRouteStateOwners( - mod.sessionRouteStateOwners ?? - (mod as { default?: PluginDoctorContractModule }).default?.sessionRouteStateOwners, - ); - const stateMigrations = coercePluginDoctorStateMigrations( - mod.stateMigrations ?? - (mod as { default?: PluginDoctorContractModule }).default?.stateMigrations, - ); - if ( - rules.length === 0 && - !normalizeCompatibilityConfig && - !resolveSessionStoreAgentIds && - sessionRouteStateOwners.length === 0 && - stateMigrations.length === 0 - ) { + const { summary, ...contract } = coercePluginDoctorContractModule(mod); + if (!Object.values(summary).some(Boolean)) { return null; } return { pluginId: record.id, - rules, - normalizeCompatibilityConfig, - resolveSessionStoreAgentIds, - sessionRouteStateOwners, - stateMigrations, + ...contract, }; } -function resolvePluginDoctorContracts(params?: { +function resolvePluginDoctorContracts(params: { + surface: PluginDoctorContractSurface; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; @@ -411,6 +240,11 @@ function resolvePluginDoctorContracts(params?: { ) { continue; } + const declaration = record.doctorContract; + // Declarations gate loading only; modules remain authoritative, while absence preserves loading. + if (declaration && declaration[params.surface] !== true) { + continue; + } const entry = loadPluginDoctorContractEntry(record); if (entry) { entries.push(entry); @@ -425,7 +259,10 @@ export function listPluginDoctorLegacyConfigRules(params?: { env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): LegacyConfigRule[] { - return resolvePluginDoctorContracts(params).flatMap((entry) => entry.rules); + return resolvePluginDoctorContracts({ + ...params, + surface: "legacyConfigRules", + }).flatMap((entry) => entry.rules); } export function listPluginDoctorSessionRouteStateOwners(params?: { @@ -435,9 +272,10 @@ export function listPluginDoctorSessionRouteStateOwners(params?: { pluginIds?: readonly string[]; }): DoctorSessionRouteStateOwner[] { const owners = new Map(); - for (const owner of resolvePluginDoctorContracts(params).flatMap( - (entry) => entry.sessionRouteStateOwners, - )) { + for (const owner of resolvePluginDoctorContracts({ + ...params, + surface: "sessionRouteStateOwners", + }).flatMap((entry) => entry.sessionRouteStateOwners)) { if (!owners.has(owner.id)) { owners.set(owner.id, owner); } @@ -454,7 +292,10 @@ export function listPluginDoctorSessionStoreAgentIds(params?: { }): string[] { const cfg = params?.config ?? {}; const agentIds = new Set(); - for (const entry of resolvePluginDoctorContracts(params)) { + for (const entry of resolvePluginDoctorContracts({ + ...params, + surface: "resolveSessionStoreAgentIds", + })) { let resolved: readonly string[] | undefined; try { resolved = entry.resolveSessionStoreAgentIds?.({ cfg }); @@ -475,7 +316,7 @@ export function listPluginDoctorStateMigrationEntries(params?: { env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): PluginDoctorStateMigrationEntry[] { - return resolvePluginDoctorContracts(params).flatMap((entry) => + return resolvePluginDoctorContracts({ ...params, surface: "stateMigrations" }).flatMap((entry) => entry.stateMigrations.map((migration) => ({ pluginId: entry.pluginId, migration, @@ -497,7 +338,10 @@ export function applyPluginDoctorCompatibilityMigrations( } { let nextCfg = cfg; const changes: string[] = []; - for (const entry of resolvePluginDoctorContracts(params)) { + for (const entry of resolvePluginDoctorContracts({ + ...params, + surface: "normalizeCompatibilityConfig", + })) { const mutation = entry.normalizeCompatibilityConfig?.({ cfg: nextCfg }); if (!mutation || mutation.changes.length === 0) { continue; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 1ecfc4f4033f..1fc88cdffe22 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -29,6 +29,7 @@ import type { PluginConfigUiHint, PluginDiagnostic, PluginFormat, + PluginManifestDoctorContract, } from "./manifest-types.js"; import { isCoreReservedPluginId, @@ -247,6 +248,7 @@ export type PluginManifestRecord = { providerAuthChoices?: PluginManifest["providerAuthChoices"]; activation?: PluginManifestActivation; setup?: PluginManifestSetup; + doctorContract?: PluginManifestDoctorContract; packageManifest?: OpenClawPackageManifest; packageDependencies?: PluginDependencySpecMap; packageOptionalDependencies?: PluginDependencySpecMap; @@ -555,6 +557,7 @@ function buildRecord(params: { ); return { id: pluginId, + doctorContract: params.manifest.doctorContract, name: normalizeOptionalString(params.manifest.name) ?? params.candidate.packageName, description: normalizeOptionalString(params.manifest.description) ?? params.candidate.packageDescription, diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index f4c50eca00fe..9b040f3a152a 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -225,6 +225,14 @@ export type PluginManifestSetup = { requiresRuntime?: boolean; }; +export type PluginManifestDoctorContract = { + legacyConfigRules?: boolean; + normalizeCompatibilityConfig?: boolean; + resolveSessionStoreAgentIds?: boolean; + sessionRouteStateOwners?: boolean; + stateMigrations?: boolean; +}; + export type PluginManifestQaRunner = { /** Subcommand mounted beneath `openclaw qa`, for example `matrix`. */ commandName: string; @@ -384,6 +392,8 @@ export type PluginManifest = { activation?: PluginManifestActivation; /** Cheap setup/onboarding metadata exposed before plugin runtime loads. */ setup?: PluginManifestSetup; + /** Doctor contract surfaces available without loading the plugin artifact. */ + doctorContract?: PluginManifestDoctorContract; /** Cheap QA runner metadata exposed before plugin runtime loads. */ qaRunners?: PluginManifestQaRunner[]; /** Widget data and action capabilities validated against runtime registrations. */ diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 0b8d259c3748..c06544495cfe 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -11,7 +11,7 @@ import * as capabilityNormalizers from "./manifest-capability-normalizers.js"; import { normalizeManifestCommandAliases } from "./manifest-command-aliases.js"; import * as modelProviderNormalizers from "./manifest-model-provider-normalizers.js"; import * as setupNormalizers from "./manifest-setup-normalizers.js"; -import type { PluginManifest } from "./manifest-types.js"; +import type { PluginManifest, PluginManifestDoctorContract } from "./manifest-types.js"; import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js"; import type { PluginKind } from "./plugin-kind.types.js"; import { normalizePluginPolicyId } from "./plugin-policy-id.js"; @@ -209,6 +209,20 @@ export function loadPluginManifest( ); const providers = normalizeTrimmedStringList(raw.providers); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); + const rawDoctorContract = isRecord(raw.doctorContract) ? raw.doctorContract : undefined; + const doctorContract = rawDoctorContract + ? (Object.fromEntries( + [ + "legacyConfigRules", + "normalizeCompatibilityConfig", + "resolveSessionStoreAgentIds", + "sessionRouteStateOwners", + "stateMigrations", + ].flatMap((key) => + typeof rawDoctorContract[key] === "boolean" ? [[key, rawDoctorContract[key]]] : [], + ), + ) as PluginManifestDoctorContract) + : undefined; const manifestBeforeDashboard = { id, configSchema, @@ -254,6 +268,7 @@ export function loadPluginManifest( providerAuthChoices: setupNormalizers.normalizeProviderAuthChoices(raw.providerAuthChoices), activation: setupNormalizers.normalizeManifestActivation(raw.activation), setup: setupNormalizers.normalizeManifestSetup(raw.setup), + doctorContract, qaRunners: setupNormalizers.normalizeManifestQaRunners(raw.qaRunners), }; const dashboardResult = setupNormalizers.normalizeManifestDashboard(raw.dashboard); diff --git a/src/plugins/uninstall-config.ts b/src/plugins/uninstall-config.ts new file mode 100644 index 000000000000..672dfe16a0a2 --- /dev/null +++ b/src/plugins/uninstall-config.ts @@ -0,0 +1,192 @@ +// Pure plugin config cleanup shared by doctor repair and full uninstall flows. +import { realpathSync } from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resetPluginSlotsToDefaults } from "./slots.js"; + +export type PluginConfigUninstallActions = { + entry: boolean; + install: boolean; + allowlist: boolean; + denylist: boolean; + loadPath: boolean; + memorySlot: boolean; + contextEngineSlot: boolean; + channelConfig: boolean; +}; + +const SHARED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); + +function createEmptyConfigUninstallActions(): PluginConfigUninstallActions { + return { + entry: false, + install: false, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + channelConfig: false, + }; +} + +/** Resolve a path through existing ancestors while preserving missing targets. */ +export function resolveComparableUninstallPath(value: string): string { + const resolved = path.resolve(value); + try { + return realpathSync(resolved); + } catch { + return resolved; + } +} + +/** Check whether a managed uninstall target stays inside its owning root. */ +export function isUninstallPathInsideOrEqual(parent: string, child: string): boolean { + const relative = path.relative( + resolveComparableUninstallPath(parent), + resolveComparableUninstallPath(child), + ); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** Resolve channel config keys owned by a plugin during uninstall. */ +export function resolveUninstallChannelConfigKeys( + pluginId: string, + opts?: { channelIds?: string[] }, +): string[] { + const rawKeys = opts?.channelIds ?? [pluginId]; + const seen = new Set(); + const keys: string[] = []; + for (const key of rawKeys) { + if (SHARED_CHANNEL_CONFIG_KEYS.has(key) || seen.has(key)) { + continue; + } + seen.add(key); + keys.push(key); + } + return keys; +} + +function loadPathMatchesInstallPath(loadPath: string, installPath: string): boolean { + return ( + loadPath === installPath || + resolveComparableUninstallPath(loadPath) === resolveComparableUninstallPath(installPath) + ); +} + +/** Remove plugin references from config without loading uninstall process/runtime dependencies. */ +export function removePluginFromConfig( + cfg: OpenClawConfig, + pluginId: string, + opts?: { channelIds?: string[] }, +): { config: OpenClawConfig; actions: PluginConfigUninstallActions } { + const actions = createEmptyConfigUninstallActions(); + const pluginsConfig = cfg.plugins ?? {}; + + let entries = pluginsConfig.entries; + if (entries && Object.hasOwn(entries, pluginId)) { + const { [pluginId]: _, ...rest } = entries; + entries = Object.keys(rest).length > 0 ? rest : undefined; + actions.entry = true; + } + + let installs = pluginsConfig.installs; + const hasInstallRecord = Object.hasOwn(installs ?? {}, pluginId); + const installRecord = hasInstallRecord ? installs?.[pluginId] : undefined; + if (installs && hasInstallRecord) { + const { [pluginId]: _, ...rest } = installs; + installs = Object.keys(rest).length > 0 ? rest : undefined; + actions.install = true; + } + + let allow = pluginsConfig.allow; + if (Array.isArray(allow) && allow.includes(pluginId)) { + allow = allow.filter((id) => id !== pluginId); + allow = allow.length > 0 ? allow : undefined; + actions.allowlist = true; + } + + let deny = pluginsConfig.deny; + if (Array.isArray(deny) && deny.includes(pluginId)) { + deny = deny.filter((id) => id !== pluginId); + deny = deny.length > 0 ? deny : undefined; + actions.denylist = true; + } + + let load = pluginsConfig.load; + const trackedInstallPaths = [ + installRecord?.installPath, + installRecord?.source === "path" ? installRecord.sourcePath : undefined, + ].filter((value): value is string => Boolean(value)); + if (trackedInstallPaths.length > 0) { + const loadPaths = load?.paths; + if ( + Array.isArray(loadPaths) && + loadPaths.some((candidate) => + trackedInstallPaths.some((installPath) => + loadPathMatchesInstallPath(candidate, installPath), + ), + ) + ) { + const nextLoadPaths = loadPaths.filter( + (candidate) => + !trackedInstallPaths.some((installPath) => + loadPathMatchesInstallPath(candidate, installPath), + ), + ); + load = nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined; + actions.loadPath = true; + } + } + + let slots = pluginsConfig.slots; + if (slots?.memory === pluginId) { + actions.memorySlot = true; + } + if (slots?.contextEngine === pluginId) { + actions.contextEngineSlot = true; + } + slots = resetPluginSlotsToDefaults(slots, pluginId); + if (slots && Object.keys(slots).length === 0) { + slots = undefined; + } + + const cleanedPlugins = { + ...pluginsConfig, + entries, + installs, + allow, + deny, + load, + slots, + }; + for (const key of ["entries", "installs", "allow", "deny", "load", "slots"] as const) { + if (cleanedPlugins[key] === undefined) { + delete cleanedPlugins[key]; + } + } + + let channels = cfg.channels as Record | undefined; + if (hasInstallRecord && channels) { + for (const key of resolveUninstallChannelConfigKeys(pluginId, opts)) { + if (!Object.hasOwn(channels, key)) { + continue; + } + const { [key]: _removed, ...rest } = channels; + channels = Object.keys(rest).length > 0 ? rest : undefined; + actions.channelConfig = true; + if (!channels) { + break; + } + } + } + + return { + config: { + ...cfg, + plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined, + channels: channels as OpenClawConfig["channels"], + }, + actions, + }; +} diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 1a5a9512fdab..2065ab145033 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -11,10 +11,10 @@ import { cleanupTrackedTempDirsAsync, makeTrackedTempDirAsync, } from "./test-helpers/fs-fixtures.js"; +import { removePluginFromConfig } from "./uninstall-config.js"; import { pruneManagedNpmPeerDependenciesAfterUninstall } from "./uninstall-managed-npm.js"; import { applyPluginUninstallDirectoryRemoval, - removePluginFromConfig, planPluginUninstall, resolveUninstallChannelConfigKeys, } from "./uninstall.js"; diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 223a661236fc..fb7d3318c012 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -1,5 +1,5 @@ // Removes installed plugins and updates plugin index records. -import { lstatSync, realpathSync } from "node:fs"; +import { lstatSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -15,18 +15,18 @@ import { resolvePluginNpmProjectsDir, } from "./install-paths.js"; import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-link.js"; -import { defaultSlotIdForKey, resetPluginSlotsToDefaults } from "./slots.js"; +import { defaultSlotIdForKey } from "./slots.js"; +import { + isUninstallPathInsideOrEqual, + removePluginFromConfig, + resolveComparableUninstallPath, + type PluginConfigUninstallActions, +} from "./uninstall-config.js"; import { pruneManagedNpmPeerDependenciesAfterUninstall } from "./uninstall-managed-npm.js"; -type UninstallActions = { - entry: boolean; - install: boolean; - allowlist: boolean; - denylist: boolean; - loadPath: boolean; - memorySlot: boolean; - contextEngineSlot: boolean; - channelConfig: boolean; +export { resolveUninstallChannelConfigKeys } from "./uninstall-config.js"; + +type UninstallActions = PluginConfigUninstallActions & { directory: boolean; }; @@ -54,26 +54,6 @@ const UNINSTALL_ACTION_ORDER = [ "directory", ] as const satisfies ReadonlyArray; -function createEmptyUninstallActions(overrides: Partial = {}): UninstallActions { - return { - entry: false, - install: false, - allowlist: false, - denylist: false, - loadPath: false, - memorySlot: false, - contextEngineSlot: false, - channelConfig: false, - directory: false, - ...overrides, - }; -} - -function createEmptyConfigUninstallActions(): Omit { - const { directory: _directory, ...actions } = createEmptyUninstallActions(); - return actions; -} - export function formatUninstallActionLabels(actions: UninstallActions): string[] { return UNINSTALL_ACTION_ORDER.flatMap((key) => actions[key] ? [UNINSTALL_ACTION_LABELS[key]] : [], @@ -100,7 +80,7 @@ export function prepareConfigForPendingPluginDirectoryRemoval( }; } -function hasUninstallAction(actions: Omit): boolean { +function hasUninstallAction(actions: PluginConfigUninstallActions): boolean { return Object.values(actions).some(Boolean); } @@ -178,7 +158,7 @@ function resolveUninstallDirectoryTarget(params: { return configuredPath; } - if (params.extensionsDir && isPathInsideOrEqual(params.extensionsDir, configuredPath)) { + if (params.extensionsDir && isUninstallPathInsideOrEqual(params.extensionsDir, configuredPath)) { return configuredPath; } @@ -213,8 +193,9 @@ function resolveNpmManagedInstall(params: { for (const npmRoot of npmRoots) { const nodeModulesRoot = path.join(npmRoot, "node_modules"); if ( - isPathInsideOrEqual(nodeModulesRoot, installPath) && - resolveComparablePath(nodeModulesRoot) !== resolveComparablePath(installPath) + isUninstallPathInsideOrEqual(nodeModulesRoot, installPath) && + resolveComparableUninstallPath(nodeModulesRoot) !== + resolveComparableUninstallPath(installPath) ) { const packageName = resolveNpmPackageNameFromInstallPath({ installPath, nodeModulesRoot }); return packageName ? { installPath, npmRoot, packageName } : null; @@ -235,8 +216,9 @@ function resolveNpmManagedProjectInstall(params: { projectsDir: string; }): { installPath: string; npmRoot: string; packageName: string } | null { if ( - !isPathInsideOrEqual(params.projectsDir, params.installPath) || - resolveComparablePath(params.projectsDir) === resolveComparablePath(params.installPath) + !isUninstallPathInsideOrEqual(params.projectsDir, params.installPath) || + resolveComparableUninstallPath(params.projectsDir) === + resolveComparableUninstallPath(params.installPath) ) { return null; } @@ -295,8 +277,8 @@ function resolveGitManagedInstall(params: { for (const gitRoot of gitRoots) { if ( - isPathInsideOrEqual(gitRoot, installPath) && - resolveComparablePath(gitRoot) !== resolveComparablePath(installPath) + isUninstallPathInsideOrEqual(gitRoot, installPath) && + resolveComparableUninstallPath(gitRoot) !== resolveComparableUninstallPath(installPath) ) { return { installPath, parentDir: path.dirname(installPath) }; } @@ -332,202 +314,11 @@ function isLinkedPathInstallRecord(installRecord: PluginInstallRecord | undefine return true; } return ( - resolveComparablePath(installRecord.sourcePath) === - resolveComparablePath(installRecord.installPath) + resolveComparableUninstallPath(installRecord.sourcePath) === + resolveComparableUninstallPath(installRecord.installPath) ); } -const SHARED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); - -/** - * Resolve the channel config keys owned by a plugin during uninstall. - * - `channelIds === undefined`: fall back to the plugin id for backward compatibility. - * - `channelIds === []`: explicit "owns no channels" signal; remove nothing. - */ -export function resolveUninstallChannelConfigKeys( - pluginId: string, - opts?: { channelIds?: string[] }, -): string[] { - const rawKeys = opts?.channelIds ?? [pluginId]; - const seen = new Set(); - const keys: string[] = []; - for (const key of rawKeys) { - if (SHARED_CHANNEL_CONFIG_KEYS.has(key) || seen.has(key)) { - continue; - } - seen.add(key); - keys.push(key); - } - return keys; -} - -function loadPathMatchesInstallPath(loadPath: string, installPath: string): boolean { - if (loadPath === installPath) { - return true; - } - return resolveComparablePath(loadPath) === resolveComparablePath(installPath); -} - -function resolveComparablePath(value: string): string { - const resolved = path.resolve(value); - try { - return realpathSync(resolved); - } catch { - return resolved; - } -} - -function isPathInsideOrEqual(parent: string, child: string): boolean { - const relative = path.relative(resolveComparablePath(parent), resolveComparablePath(child)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -/** - * Remove plugin references from config (pure config mutation). - * Returns a new config with the plugin removed from entries, installs, allow, load.paths, slots, - * and owned channel config. - */ -export function removePluginFromConfig( - cfg: OpenClawConfig, - pluginId: string, - opts?: { channelIds?: string[] }, -): { config: OpenClawConfig; actions: Omit } { - const actions = createEmptyConfigUninstallActions(); - - const pluginsConfig = cfg.plugins ?? {}; - - // Remove from entries - let entries = pluginsConfig.entries; - if (entries && Object.hasOwn(entries, pluginId)) { - const { [pluginId]: _, ...rest } = entries; - entries = Object.keys(rest).length > 0 ? rest : undefined; - actions.entry = true; - } - - // Remove from installs - let installs = pluginsConfig.installs; - const hasInstallRecord = Object.hasOwn(installs ?? {}, pluginId); - const installRecord = hasInstallRecord ? installs?.[pluginId] : undefined; - if (installs && hasInstallRecord) { - const { [pluginId]: _, ...rest } = installs; - installs = Object.keys(rest).length > 0 ? rest : undefined; - actions.install = true; - } - - // Remove from allowlist - let allow = pluginsConfig.allow; - if (Array.isArray(allow) && allow.includes(pluginId)) { - allow = allow.filter((id) => id !== pluginId); - if (allow.length === 0) { - allow = undefined; - } - actions.allowlist = true; - } - - // Remove from denylist. An explicit uninstall should clear stale policy so a - // later reinstall can enable the plugin deterministically. - let deny = pluginsConfig.deny; - if (Array.isArray(deny) && deny.includes(pluginId)) { - deny = deny.filter((id) => id !== pluginId); - if (deny.length === 0) { - deny = undefined; - } - actions.denylist = true; - } - - // Remove exact tracked install paths without consuming parent, child, or prefix matches. - let load = pluginsConfig.load; - const trackedInstallPaths = [ - installRecord?.installPath, - installRecord?.source === "path" ? installRecord.sourcePath : undefined, - ].filter((value): value is string => Boolean(value)); - if (trackedInstallPaths.length > 0) { - const loadPaths = load?.paths; - if ( - Array.isArray(loadPaths) && - loadPaths.some((p) => - trackedInstallPaths.some((installPath) => loadPathMatchesInstallPath(p, installPath)), - ) - ) { - const nextLoadPaths = loadPaths.filter( - (p) => - !trackedInstallPaths.some((installPath) => loadPathMatchesInstallPath(p, installPath)), - ); - load = nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined; - actions.loadPath = true; - } - } - - // Reset slots if this plugin was selected. - let slots = pluginsConfig.slots; - if (slots?.memory === pluginId) { - actions.memorySlot = true; - } - if (slots?.contextEngine === pluginId) { - actions.contextEngineSlot = true; - } - slots = resetPluginSlotsToDefaults(slots, pluginId); - if (slots && Object.keys(slots).length === 0) { - slots = undefined; - } - - const newPlugins = { - ...pluginsConfig, - entries, - installs, - allow, - deny, - load, - slots, - }; - - // Clean up undefined properties from newPlugins - const cleanedPlugins: typeof newPlugins = { ...newPlugins }; - if (cleanedPlugins.entries === undefined) { - delete cleanedPlugins.entries; - } - if (cleanedPlugins.installs === undefined) { - delete cleanedPlugins.installs; - } - if (cleanedPlugins.allow === undefined) { - delete cleanedPlugins.allow; - } - if (cleanedPlugins.deny === undefined) { - delete cleanedPlugins.deny; - } - if (cleanedPlugins.load === undefined) { - delete cleanedPlugins.load; - } - if (cleanedPlugins.slots === undefined) { - delete cleanedPlugins.slots; - } - - // Remove channel config owned by this installed plugin. - // Built-in channels have no install record, so keep their config untouched. - let channels = cfg.channels as Record | undefined; - if (hasInstallRecord && channels) { - for (const key of resolveUninstallChannelConfigKeys(pluginId, opts)) { - if (!Object.hasOwn(channels, key)) { - continue; - } - const { [key]: _removed, ...rest } = channels; - channels = Object.keys(rest).length > 0 ? rest : undefined; - actions.channelConfig = true; - if (!channels) { - break; - } - } - } - - const config: OpenClawConfig = { - ...cfg, - plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined, - channels: channels as OpenClawConfig["channels"], - }; - - return { config, actions }; -} - type UninstallPluginParams = { config: OpenClawConfig; pluginId: string; diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 0c61a3efd0f4..68143c746e10 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -31,6 +31,7 @@ import { shouldRunControlUiI18nVerify, shouldRunPromptSnapshotCheck, shouldRunPromptSnapshotOwnerTest, + shouldRunDoctorContractOwnerTests, shouldRunRuntimeSidecarBaselineCheck, shouldRunNpmLockGuard, shouldRunPluginSdkApiBaselineCheck, @@ -1758,6 +1759,29 @@ describe("scripts/changed-lanes", () => { ], }, }, + { + name: "runs doctor contract owner tests for extension module and manifest changes", + predicate: shouldRunDoctorContractOwnerTests, + predicatePaths: [ + "extensions/telegram/doctor-contract-api.ts", + "extensions/telegram/openclaw.plugin.json", + "extensions/codex/src/migration/session-binding-sidecars.ts", + ], + changedPath: "extensions/telegram/doctor-contract-api.ts", + expected: { + exact: [], + partial: [ + { + name: "doctor contract declaration + closure guard tests", + args: [ + "test:serial", + "src/plugins/doctor-contract-declarations.test.ts", + "src/plugins/doctor-contract-closure-guard.test.ts", + ], + }, + ], + }, + }, { name: "runs SQLite sessions/transcripts schema baseline checks for baseline owner surfaces", predicate: shouldRunSqliteSessionSchemaBaselineCheck, diff --git a/test/scripts/check-built-plugin-control-plane-modules.test.ts b/test/scripts/check-built-plugin-control-plane-modules.test.ts new file mode 100644 index 000000000000..78428ba752ec --- /dev/null +++ b/test/scripts/check-built-plugin-control-plane-modules.test.ts @@ -0,0 +1,111 @@ +// Built plugin control-plane module checks cover native require(esm) acceptance. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + listBuiltPluginControlPlaneModules, + probeBuiltPluginControlPlaneModules, + verifyBuiltPluginControlPlaneModules, +} from "../../scripts/check-built-plugin-control-plane-modules.mjs"; + +const roots: string[] = []; + +function makeRoot(): string { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-control-plane-")); + roots.push(rootDir); + fs.writeFileSync(path.join(rootDir, "package.json"), '{"type":"module"}\n'); + return rootDir; +} + +function write(rootDir: string, relativePath: string, source: string): void { + const filePath = path.join(rootDir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const rootDir of roots.splice(0)) { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +describe("built plugin control-plane module loads", () => { + it("lists exact contract files and channel legacy setup references", () => { + const rootDir = makeRoot(); + write(rootDir, "dist/extensions/demo/doctor-contract-api.js", "export const ok = true;\n"); + write(rootDir, "dist/extensions/demo/contract-api.js", "export const ok = true;\n"); + write( + rootDir, + "dist/extensions/demo/provider-contract-api.js", + "export const ignored = true;\n", + ); + write( + rootDir, + "dist/extensions/demo/setup-entry.js", + [ + "const setup = {", + ' legacyStateMigrations: { specifier: "./legacy-state-migrations-api.js" },', + ' legacySessionSurface: { specifier: "./legacy-session-surface-api.js" },', + "};", + "export default setup;", + ].join("\n"), + ); + write(rootDir, "dist/extensions/demo/legacy-state-migrations-api.js", "export {};\n"); + write(rootDir, "dist/extensions/demo/legacy-session-surface-api.js", "export {};\n"); + + expect(listBuiltPluginControlPlaneModules({ rootDir })).toEqual([ + { + pluginId: "demo", + kind: "contract", + relativePath: "dist/extensions/demo/contract-api.js", + }, + { + pluginId: "demo", + kind: "doctor-contract", + relativePath: "dist/extensions/demo/doctor-contract-api.js", + }, + { + pluginId: "demo", + kind: "channel-legacy-session-surface", + relativePath: "dist/extensions/demo/legacy-session-surface-api.js", + }, + { + pluginId: "demo", + kind: "channel-legacy-state-migrations", + relativePath: "dist/extensions/demo/legacy-state-migrations-api.js", + }, + ]); + }); + + it("accepts synchronously requireable ESM artifacts", () => { + const rootDir = makeRoot(); + write(rootDir, "dist/extensions/demo/doctor-contract-api.js", "export const ok = true;\n"); + + expect(() => verifyBuiltPluginControlPlaneModules({ rootDir })).not.toThrow(); + }); + + it("reports plugin, kind, path, and native require error", () => { + const rootDir = makeRoot(); + write( + rootDir, + "dist/extensions/demo/doctor-contract-api.js", + "await Promise.resolve();\nexport const ok = true;\n", + ); + + expect(() => verifyBuiltPluginControlPlaneModules({ rootDir })).toThrow( + /demo \(doctor-contract\) dist\/extensions\/demo\/doctor-contract-api\.js:.*ERR_REQUIRE_ASYNC_MODULE/s, + ); + }); + + it("bounds a stalled native require child", () => { + const rootDir = makeRoot(); + write(rootDir, "dist/extensions/demo/doctor-contract-api.js", "while (true) {}\n"); + const modules = listBuiltPluginControlPlaneModules({ rootDir }); + + expect(() => probeBuiltPluginControlPlaneModules(modules, { rootDir, timeoutMs: 100 })).toThrow( + /timed out|ETIMEDOUT/u, + ); + }); +});