From bcd61f0a382d1693d14e9856c57fdcca4c2859f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Mar 2026 20:39:45 +0000 Subject: [PATCH] refactor: dedupe helpers and source seams --- extensions/bluebubbles/src/channel-shared.ts | 69 +++ extensions/bluebubbles/src/channel.setup.ts | 80 +-- extensions/bluebubbles/src/channel.ts | 70 +-- extensions/line/src/account-helpers.ts | 16 + extensions/line/src/channel-shared.ts | 18 +- extensions/line/src/setup-core.ts | 12 +- .../src/matrix/sdk/recovery-key-store.ts | 67 +-- src/agents/configured-provider-fallback.ts | 31 ++ src/agents/model-selection.ts | 24 +- src/agents/payload-redaction.ts | 61 +-- src/agents/pi-bundle-lsp-runtime.ts | 102 ++-- src/agents/pi-tools.params.ts | 95 ++-- src/agents/tools/image-generate-tool.ts | 18 +- src/auto-reply/command-control.test.ts | 252 +++------ src/auto-reply/reply.media-note.test.ts | 65 +-- src/auto-reply/reply.raw-body.test.ts | 66 +-- src/auto-reply/reply.test-harness.ts | 72 ++- .../reply/commands-plugins.toggle.test.ts | 88 +-- .../reply/directive-handling.model.ts | 157 +----- src/auto-reply/reply/reply-state.test.ts | 102 ++-- .../reply/session-reset-model.test.ts | 117 ++-- src/auto-reply/reply/session-updates.ts | 113 +--- src/auto-reply/skill-commands.test.ts | 192 +++---- .../outbound-payload.contract.test.ts | 27 +- src/channels/plugins/contracts/suites.ts | 30 +- .../outbound/slack.sendpayload.test.ts | 26 +- src/cli/container-target.ts | 26 +- src/cli/memory-cli.runtime.ts | 60 +-- src/cli/profile.ts | 26 +- src/cli/root-option-value.ts | 18 + src/commands/config-validation.test.ts | 9 +- src/commands/doctor-workspace-status.test.ts | 101 +--- src/commands/onboard-channels.e2e.test.ts | 74 +-- src/commands/onboard-helpers.ts | 43 +- src/commands/status.scan.fast-json.test.ts | 53 +- src/commands/status.scan.fast-json.ts | 137 +---- src/commands/status.scan.json-core.ts | 161 ++++++ src/commands/status.scan.test-helpers.ts | 51 ++ src/commands/status.scan.test.ts | 55 +- src/commands/status.scan.ts | 131 +---- src/commands/status.summary.runtime.ts | 24 +- src/commands/status.summary.test.ts | 4 + src/commands/status.summary.ts | 81 +-- src/commands/status.test.ts | 115 ++-- src/config/version.ts | 63 +-- src/gateway/session-archive.fs.ts | 159 +----- src/gateway/session-kill-http.ts | 20 +- src/gateway/session-transcript-files.fs.ts | 206 ++++++++ src/gateway/session-utils.fs.ts | 223 +------- src/gateway/sessions-history-http.ts | 16 +- src/gateway/test-helpers.mocks.ts | 88 ++- src/gateway/tools-invoke-http.ts | 16 +- src/image-generation/model-ref.ts | 16 + src/image-generation/runtime.ts | 18 +- src/infra/clawhub.ts | 92 +--- src/infra/detect-binary.ts | 36 ++ src/infra/semver-compare.ts | 111 ++++ src/infra/update-check.ts | 106 +--- src/plugin-sdk/channel-config-helpers.ts | 323 +++++++----- src/plugins/bundle-config-shared.ts | 139 +++++ src/plugins/bundle-lsp.ts | 97 +--- src/plugins/bundle-mcp.ts | 119 +---- src/plugins/cache-controls.ts | 68 +++ src/plugins/contracts/auth.contract.test.ts | 19 +- .../contracts/runtime.contract.test.ts | 21 +- src/plugins/conversation-binding.test.ts | 292 +++++----- src/plugins/interactive.test.ts | 388 +++++--------- src/plugins/marketplace.test.ts | 100 ++-- src/plugins/marketplace.ts | 87 ++- src/plugins/provider-wizard.test.ts | 145 ++--- src/plugins/provider-wizard.ts | 73 +-- src/plugins/runtime/index.ts | 21 +- src/plugins/runtime/runtime-agent.ts | 21 +- src/plugins/runtime/runtime-cache.ts | 19 + src/plugins/runtime/runtime-channel.ts | 21 +- .../runtime/runtime-matrix-boundary.ts | 77 +-- .../runtime/runtime-plugin-boundary.ts | 106 ++++ .../runtime/runtime-whatsapp-boundary.ts | 95 +--- src/plugins/setup-binary.ts | 37 +- src/plugins/status.test-helpers.ts | 135 +++++ src/plugins/status.test.ts | 499 +++--------------- .../web-search-providers.runtime.test.ts | 1 + src/plugins/web-search-providers.runtime.ts | 78 +-- 83 files changed, 2795 insertions(+), 4495 deletions(-) create mode 100644 extensions/bluebubbles/src/channel-shared.ts create mode 100644 extensions/line/src/account-helpers.ts create mode 100644 src/agents/configured-provider-fallback.ts create mode 100644 src/cli/root-option-value.ts create mode 100644 src/commands/status.scan.json-core.ts create mode 100644 src/gateway/session-transcript-files.fs.ts create mode 100644 src/image-generation/model-ref.ts create mode 100644 src/infra/detect-binary.ts create mode 100644 src/infra/semver-compare.ts create mode 100644 src/plugins/bundle-config-shared.ts create mode 100644 src/plugins/cache-controls.ts create mode 100644 src/plugins/runtime/runtime-cache.ts create mode 100644 src/plugins/runtime/runtime-plugin-boundary.ts create mode 100644 src/plugins/status.test-helpers.ts diff --git a/extensions/bluebubbles/src/channel-shared.ts b/extensions/bluebubbles/src/channel-shared.ts new file mode 100644 index 00000000000..f5da2a5ff4d --- /dev/null +++ b/extensions/bluebubbles/src/channel-shared.ts @@ -0,0 +1,69 @@ +import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; +import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; +import { + adaptScopedAccountAccessor, + createScopedChannelConfigAdapter, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { + listBlueBubblesAccountIds, + type ResolvedBlueBubblesAccount, + resolveBlueBubblesAccount, + resolveDefaultBlueBubblesAccountId, +} from "./accounts.js"; +import { BlueBubblesConfigSchema } from "./config-schema.js"; +import type { ChannelPlugin } from "./runtime-api.js"; +import { normalizeBlueBubblesHandle } from "./targets.js"; + +export const bluebubblesMeta = { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + detailLabel: "BlueBubbles", + docsPath: "/channels/bluebubbles", + docsLabel: "bluebubbles", + blurb: "iMessage via the BlueBubbles mac app + REST API.", + systemImage: "bubble.left.and.text.bubble.right", + aliases: ["bb"], + order: 75, + preferOver: ["imessage"], +}; + +export const bluebubblesCapabilities: ChannelPlugin["capabilities"] = { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + edit: true, + unsend: true, + reply: true, + effects: true, + groupManagement: true, +}; + +export const bluebubblesReload = { configPrefixes: ["channels.bluebubbles"] }; +export const bluebubblesConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema); + +export const bluebubblesConfigAdapter = + createScopedChannelConfigAdapter({ + sectionKey: "bluebubbles", + listAccountIds: listBlueBubblesAccountIds, + resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount), + defaultAccountId: resolveDefaultBlueBubblesAccountId, + clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], + resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatNormalizedAllowFromEntries({ + allowFrom, + normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), + }), + }); + +export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) { + return describeAccountSnapshot({ + account, + configured: account.configured, + extra: { + baseUrl: account.baseUrl, + }, + }); +} diff --git a/extensions/bluebubbles/src/channel.setup.ts b/extensions/bluebubbles/src/channel.setup.ts index 99c1ecb3724..73b8a79c487 100644 --- a/extensions/bluebubbles/src/channel.setup.ts +++ b/extensions/bluebubbles/src/channel.setup.ts @@ -1,81 +1,31 @@ -import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; -import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { - adaptScopedAccountAccessor, - createScopedChannelConfigAdapter, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { type ResolvedBlueBubblesAccount } from "./accounts.js"; import { - listBlueBubblesAccountIds, - type ResolvedBlueBubblesAccount, - resolveBlueBubblesAccount, - resolveDefaultBlueBubblesAccountId, -} from "./accounts.js"; -import { BlueBubblesConfigSchema } from "./config-schema.js"; + bluebubblesCapabilities, + bluebubblesConfigAdapter, + bluebubblesConfigSchema, + bluebubblesMeta, + bluebubblesReload, + describeBlueBubblesAccount, +} from "./channel-shared.js"; import { blueBubblesSetupAdapter } from "./setup-core.js"; import { blueBubblesSetupWizard } from "./setup-surface.js"; -import { normalizeBlueBubblesHandle } from "./targets.js"; - -const meta = { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles (macOS app)", - detailLabel: "BlueBubbles", - docsPath: "/channels/bluebubbles", - docsLabel: "bluebubbles", - blurb: "iMessage via the BlueBubbles mac app + REST API.", - systemImage: "bubble.left.and.text.bubble.right", - aliases: ["bb"], - order: 75, - preferOver: ["imessage"], -} as const; - -const bluebubblesConfigAdapter = createScopedChannelConfigAdapter({ - sectionKey: "bluebubbles", - listAccountIds: listBlueBubblesAccountIds, - resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount), - defaultAccountId: resolveDefaultBlueBubblesAccountId, - clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], - resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - }), -}); export const bluebubblesSetupPlugin: ChannelPlugin = { id: "bluebubbles", meta: { - ...meta, - aliases: [...meta.aliases], - preferOver: [...meta.preferOver], + ...bluebubblesMeta, + aliases: [...bluebubblesMeta.aliases], + preferOver: [...bluebubblesMeta.preferOver], }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - edit: true, - unsend: true, - reply: true, - effects: true, - groupManagement: true, - }, - reload: { configPrefixes: ["channels.bluebubbles"] }, - configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), + capabilities: bluebubblesCapabilities, + reload: bluebubblesReload, + configSchema: bluebubblesConfigSchema, setupWizard: blueBubblesSetupWizard, config: { ...bluebubblesConfigAdapter, isConfigured: (account) => account.configured, - describeAccount: (account) => - describeAccountSnapshot({ - account, - configured: account.configured, - extra: { - baseUrl: account.baseUrl, - }, - }), + describeAccount: (account) => describeBlueBubblesAccount(account), }, setup: blueBubblesSetupAdapter, }; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index aeb9fa2c78d..b47502a7cd6 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,10 +1,4 @@ -import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; -import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { - adaptScopedAccountAccessor, - createScopedChannelConfigAdapter, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { @@ -25,15 +19,21 @@ import { resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; import { bluebubblesMessageActions } from "./actions.js"; +import { + bluebubblesCapabilities, + bluebubblesConfigAdapter, + bluebubblesConfigSchema, + bluebubblesMeta as meta, + bluebubblesReload, + describeBlueBubblesAccount, +} from "./channel-shared.js"; import type { BlueBubblesProbe } from "./channel.runtime.js"; -import { BlueBubblesConfigSchema } from "./config-schema.js"; import { resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, } from "./group-policy.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js"; import { - buildChannelConfigSchema, buildProbeChannelStatusSummary, collectBlueBubblesStatusIssues, DEFAULT_ACCOUNT_ID, @@ -57,20 +57,6 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( "blueBubblesChannelRuntime", ); -const bluebubblesConfigAdapter = createScopedChannelConfigAdapter({ - sectionKey: "bluebubbles", - listAccountIds: listBlueBubblesAccountIds, - resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount), - defaultAccountId: resolveDefaultBlueBubblesAccountId, - clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], - resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - }), -}); - const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver({ channelKey: "bluebubbles", resolvePolicy: (account) => account.config.dmPolicy, @@ -90,53 +76,23 @@ const collectBlueBubblesSecurityWarnings = mentionGated: false, }); -const meta = { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles (macOS app)", - detailLabel: "BlueBubbles", - docsPath: "/channels/bluebubbles", - docsLabel: "bluebubbles", - blurb: "iMessage via the BlueBubbles mac app + REST API.", - systemImage: "bubble.left.and.text.bubble.right", - aliases: ["bb"], - order: 75, - preferOver: ["imessage"], -}; - export const bluebubblesPlugin: ChannelPlugin = createChatChannelPlugin({ base: { id: "bluebubbles", meta, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - edit: true, - unsend: true, - reply: true, - effects: true, - groupManagement: true, - }, + capabilities: bluebubblesCapabilities, groups: { resolveRequireMention: resolveBlueBubblesGroupRequireMention, resolveToolPolicy: resolveBlueBubblesGroupToolPolicy, }, - reload: { configPrefixes: ["channels.bluebubbles"] }, - configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), + reload: bluebubblesReload, + configSchema: bluebubblesConfigSchema, setupWizard: blueBubblesSetupWizard, config: { ...bluebubblesConfigAdapter, isConfigured: (account) => account.configured, - describeAccount: (account): ChannelAccountSnapshot => - describeAccountSnapshot({ - account, - configured: account.configured, - extra: { - baseUrl: account.baseUrl, - }, - }), + describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account), }, actions: bluebubblesMessageActions, messaging: { diff --git a/extensions/line/src/account-helpers.ts b/extensions/line/src/account-helpers.ts new file mode 100644 index 00000000000..1f4fd66c79d --- /dev/null +++ b/extensions/line/src/account-helpers.ts @@ -0,0 +1,16 @@ +type LineCredentialAccount = { + channelAccessToken?: string; + channelSecret?: string; +}; + +export function hasLineCredentials(account: LineCredentialAccount): boolean { + return Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()); +} + +export function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} diff --git a/extensions/line/src/channel-shared.ts b/extensions/line/src/channel-shared.ts index 593824f3070..ce0f455ae4d 100644 --- a/extensions/line/src/channel-shared.ts +++ b/extensions/line/src/channel-shared.ts @@ -4,6 +4,7 @@ import { type OpenClawConfig, type ResolvedLineAccount, } from "../runtime-api.js"; +import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js"; import { lineConfigAdapter } from "./config-adapter.js"; import { LineChannelConfigSchema } from "./config-schema.js"; @@ -35,13 +36,12 @@ export const lineChannelPluginCommon = { configSchema: LineChannelConfigSchema, config: { ...lineConfigAdapter, - isConfigured: (account: ResolvedLineAccount) => - Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + isConfigured: (account: ResolvedLineAccount) => hasLineCredentials(account), describeAccount: (account: ResolvedLineAccount) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + configured: hasLineCredentials(account), tokenSource: account.tokenSource ?? undefined, }), }, @@ -51,16 +51,8 @@ export const lineChannelPluginCommon = { >; export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { - const resolved = resolveLineAccount({ cfg, accountId }); - return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); -} - -export function parseLineAllowFromId(raw: string): string | null { - const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); - if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { - return null; - } - return trimmed; + return hasLineCredentials(resolveLineAccount({ cfg, accountId })); } export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../runtime-api.js"; +export { parseLineAllowFromId }; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 894f35867ab..33f320d077a 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,4 +1,5 @@ import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; +import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js"; import { DEFAULT_ACCOUNT_ID, listLineAccountIds, @@ -66,17 +67,10 @@ export function patchLineAccountConfig(params: { } export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { - const resolved = resolveLineAccount({ cfg, accountId }); - return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); + return hasLineCredentials(resolveLineAccount({ cfg, accountId })); } -export function parseLineAllowFromId(raw: string): string | null { - const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); - if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { - return null; - } - return trimmed; -} +export { parseLineAllowFromId }; export const lineSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts index f12a4a0ae29..1b774495773 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -124,14 +124,15 @@ export class MatrixRecoveryKeyStore { }; } - storeEncodedRecoveryKey(params: { + private resolveEncodedRecoveryKeyInput(params: { encodedPrivateKey: string; keyId?: string | null; keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; }): { - encodedPrivateKey?: string; - keyId?: string | null; - createdAt?: string; + encodedPrivateKey: string; + privateKey: Uint8Array; + keyId: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; } { const encodedPrivateKey = params.encodedPrivateKey.trim(); if (!encodedPrivateKey) { @@ -145,18 +146,34 @@ export class MatrixRecoveryKeyStore { `Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`, ); } - - const normalizedKeyId = + const keyId = typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null; - const keyInfo = params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo; - this.saveRecoveryKeyToDisk({ - keyId: normalizedKeyId, - keyInfo, - privateKey, + return { encodedPrivateKey, + privateKey, + keyId, + keyInfo: params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo, + }; + } + + storeEncodedRecoveryKey(params: { + encodedPrivateKey: string; + keyId?: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; + }): { + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } { + const prepared = this.resolveEncodedRecoveryKeyInput(params); + this.saveRecoveryKeyToDisk({ + keyId: prepared.keyId, + keyInfo: prepared.keyInfo, + privateKey: prepared.privateKey, + encodedPrivateKey: prepared.encodedPrivateKey, }); - if (normalizedKeyId) { - this.rememberSecretStorageKey(normalizedKeyId, privateKey, keyInfo); + if (prepared.keyId) { + this.rememberSecretStorageKey(prepared.keyId, prepared.privateKey, prepared.keyInfo); } return this.getRecoveryKeySummary() ?? {}; } @@ -166,29 +183,15 @@ export class MatrixRecoveryKeyStore { keyId?: string | null; keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; }): void { - const encodedPrivateKey = params.encodedPrivateKey.trim(); - if (!encodedPrivateKey) { - throw new Error("Matrix recovery key is required"); - } - let privateKey: Uint8Array; - try { - privateKey = decodeRecoveryKey(encodedPrivateKey); - } catch (err) { - throw new Error( - `Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - const normalizedKeyId = - typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null; + const prepared = this.resolveEncodedRecoveryKeyInput(params); this.discardStagedRecoveryKey(); this.stagedRecoveryKey = { version: 1, createdAt: new Date().toISOString(), - keyId: normalizedKeyId, - encodedPrivateKey, - privateKeyBase64: Buffer.from(privateKey).toString("base64"), - keyInfo: params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo, + keyId: prepared.keyId, + encodedPrivateKey: prepared.encodedPrivateKey, + privateKeyBase64: Buffer.from(prepared.privateKey).toString("base64"), + keyInfo: prepared.keyInfo, }; } diff --git a/src/agents/configured-provider-fallback.ts b/src/agents/configured-provider-fallback.ts new file mode 100644 index 00000000000..48dece88b4b --- /dev/null +++ b/src/agents/configured-provider-fallback.ts @@ -0,0 +1,31 @@ +import type { OpenClawConfig } from "../config/types.js"; + +export type ProviderModelRef = { + provider: string; + model: string; +}; + +export function resolveConfiguredProviderFallback(params: { + cfg: Pick; + defaultProvider: string; +}): ProviderModelRef | null { + const configuredProviders = params.cfg.models?.providers; + if (!configuredProviders || typeof configuredProviders !== "object") { + return null; + } + if (configuredProviders[params.defaultProvider]) { + return null; + } + const availableProvider = Object.entries(configuredProviders).find( + ([, providerCfg]) => + providerCfg && + Array.isArray(providerCfg.models) && + providerCfg.models.length > 0 && + providerCfg.models[0]?.id, + ); + if (!availableProvider) { + return null; + } + const [provider, providerCfg] = availableProvider; + return { provider, model: providerCfg.models[0].id }; +} diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 574479bab03..48f3af0de78 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -12,6 +12,7 @@ import { resolveAgentEffectiveModelPrimary, resolveAgentModelFallbacksOverride, } from "./agent-scope.js"; +import { resolveConfiguredProviderFallback } from "./configured-provider-fallback.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; @@ -323,23 +324,12 @@ export function resolveConfiguredModelRef(params: { // is actually available. If it isn't but other providers are configured, prefer // the first configured provider's first model to avoid reporting a stale default // from a removed provider. (See #38880) - const configuredProviders = params.cfg.models?.providers; - if (configuredProviders && typeof configuredProviders === "object") { - const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]); - if (!hasDefaultProvider) { - const availableProvider = Object.entries(configuredProviders).find( - ([, providerCfg]) => - providerCfg && - Array.isArray(providerCfg.models) && - providerCfg.models.length > 0 && - providerCfg.models[0]?.id, - ); - if (availableProvider) { - const [providerName, providerCfg] = availableProvider; - const firstModel = providerCfg.models[0]; - return { provider: providerName, model: firstModel.id }; - } - } + const fallbackProvider = resolveConfiguredProviderFallback({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + }); + if (fallbackProvider) { + return fallbackProvider; } return { provider: params.defaultProvider, model: params.defaultModel }; } diff --git a/src/agents/payload-redaction.ts b/src/agents/payload-redaction.ts index 81b788ca7dc..de2e0224820 100644 --- a/src/agents/payload-redaction.ts +++ b/src/agents/payload-redaction.ts @@ -60,10 +60,10 @@ function digestBase64Payload(data: string): string { return crypto.createHash("sha256").update(data).digest("hex"); } -/** - * Redacts image/base64 payload data from diagnostic objects before persistence. - */ -export function redactImageDataForDiagnostics(value: unknown): unknown { +function visitDiagnosticPayload( + value: unknown, + opts?: { omitField?: (key: string) => boolean }, +): unknown { const seen = new WeakSet(); const visit = (input: unknown): unknown => { @@ -81,43 +81,7 @@ export function redactImageDataForDiagnostics(value: unknown): unknown { const record = input as Record; const out: Record = {}; for (const [key, val] of Object.entries(record)) { - out[key] = visit(val); - } - - if (shouldRedactImageData(record)) { - out.data = REDACTED_IMAGE_DATA; - out.bytes = estimateBase64DecodedBytes(record.data); - out.sha256 = digestBase64Payload(record.data); - } - return out; - }; - - return visit(value); -} - -/** - * Removes credential-like fields and image/base64 payload data from diagnostic - * objects before persistence. - */ -export function sanitizeDiagnosticPayload(value: unknown): unknown { - const seen = new WeakSet(); - - const visit = (input: unknown): unknown => { - if (Array.isArray(input)) { - return input.map((entry) => visit(entry)); - } - if (!input || typeof input !== "object") { - return input; - } - if (seen.has(input)) { - return "[Circular]"; - } - seen.add(input); - - const record = input as Record; - const out: Record = {}; - for (const [key, val] of Object.entries(record)) { - if (isCredentialFieldName(key)) { + if (opts?.omitField?.(key)) { continue; } out[key] = visit(val); @@ -133,3 +97,18 @@ export function sanitizeDiagnosticPayload(value: unknown): unknown { return visit(value); } + +/** + * Redacts image/base64 payload data from diagnostic objects before persistence. + */ +export function redactImageDataForDiagnostics(value: unknown): unknown { + return visitDiagnosticPayload(value); +} + +/** + * Removes credential-like fields and image/base64 payload data from diagnostic + * objects before persistence. + */ +export function sanitizeDiagnosticPayload(value: unknown): unknown { + return visitDiagnosticPayload(value, { omitField: isCredentialFieldName }); +} diff --git a/src/agents/pi-bundle-lsp-runtime.ts b/src/agents/pi-bundle-lsp-runtime.ts index cecc95bb475..74a4d6f4554 100644 --- a/src/agents/pi-bundle-lsp-runtime.ts +++ b/src/agents/pi-bundle-lsp-runtime.ts @@ -36,6 +36,12 @@ export type BundleLspToolRuntime = { dispose: () => Promise; }; +type LspPositionParams = { + uri: string; + line: number; + character: number; +}; + function encodeLspMessage(body: unknown): string { const json = JSON.stringify(body); return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`; @@ -168,59 +174,67 @@ async function disposeSession(session: LspSession) { session.process.kill(); } +function createLspPositionTool(params: { + session: LspSession; + toolName: string; + label: string; + description: string; + method: string; + resultLabel: string; +}): AnyAgentTool { + return { + name: params.toolName, + label: params.label, + description: params.description, + parameters: { + type: "object", + properties: { + uri: { type: "string", description: "File URI (file:///path/to/file)" }, + line: { type: "number", description: "Zero-based line number" }, + character: { type: "number", description: "Zero-based character offset" }, + }, + required: ["uri", "line", "character"], + }, + execute: async (_toolCallId, input) => { + const position = input as LspPositionParams; + const result = await sendRequest(params.session, params.method, { + textDocument: { uri: position.uri }, + position: { line: position.line, character: position.character }, + }); + return formatLspResult(params.session.serverName, params.resultLabel, result); + }, + }; +} + function buildLspTools(session: LspSession): AnyAgentTool[] { const tools: AnyAgentTool[] = []; const caps = session.capabilities; const serverLabel = session.serverName; if (caps.hoverProvider) { - tools.push({ - name: `lsp_hover_${serverLabel}`, - label: `LSP Hover (${serverLabel})`, - description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.`, - parameters: { - type: "object", - properties: { - uri: { type: "string", description: "File URI (file:///path/to/file)" }, - line: { type: "number", description: "Zero-based line number" }, - character: { type: "number", description: "Zero-based character offset" }, - }, - required: ["uri", "line", "character"], - }, - execute: async (_toolCallId, input) => { - const params = input as { uri: string; line: number; character: number }; - const result = await sendRequest(session, "textDocument/hover", { - textDocument: { uri: params.uri }, - position: { line: params.line, character: params.character }, - }); - return formatLspResult(serverLabel, "hover", result); - }, - }); + tools.push( + createLspPositionTool({ + session, + toolName: `lsp_hover_${serverLabel}`, + label: `LSP Hover (${serverLabel})`, + description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.`, + method: "textDocument/hover", + resultLabel: "hover", + }), + ); } if (caps.definitionProvider) { - tools.push({ - name: `lsp_definition_${serverLabel}`, - label: `LSP Go to Definition (${serverLabel})`, - description: `Find the definition of a symbol at a position in a file via the ${serverLabel} language server.`, - parameters: { - type: "object", - properties: { - uri: { type: "string", description: "File URI (file:///path/to/file)" }, - line: { type: "number", description: "Zero-based line number" }, - character: { type: "number", description: "Zero-based character offset" }, - }, - required: ["uri", "line", "character"], - }, - execute: async (_toolCallId, input) => { - const params = input as { uri: string; line: number; character: number }; - const result = await sendRequest(session, "textDocument/definition", { - textDocument: { uri: params.uri }, - position: { line: params.line, character: params.character }, - }); - return formatLspResult(serverLabel, "definition", result); - }, - }); + tools.push( + createLspPositionTool({ + session, + toolName: `lsp_definition_${serverLabel}`, + label: `LSP Go to Definition (${serverLabel})`, + description: `Find the definition of a symbol at a position in a file via the ${serverLabel} language server.`, + method: "textDocument/definition", + resultLabel: "definition", + }), + ); } if (caps.referencesProvider) { diff --git a/src/agents/pi-tools.params.ts b/src/agents/pi-tools.params.ts index 4bd2aeed9ac..a752154f889 100644 --- a/src/agents/pi-tools.params.ts +++ b/src/agents/pi-tools.params.ts @@ -32,6 +32,23 @@ export const CLAUDE_PARAM_GROUPS = { ], } as const; +type ClaudeParamAlias = { + original: string; + alias: string; +}; + +const CLAUDE_PARAM_ALIASES: ClaudeParamAlias[] = [ + { original: "path", alias: "file_path" }, + { original: "path", alias: "filePath" }, + { original: "path", alias: "file" }, + { original: "oldText", alias: "old_string" }, + { original: "oldText", alias: "old_text" }, + { original: "oldText", alias: "oldString" }, + { original: "newText", alias: "new_string" }, + { original: "newText", alias: "new_text" }, + { original: "newText", alias: "newString" }, +]; + function extractStructuredText(value: unknown, depth = 0): string | undefined { if (depth > 6) { return undefined; @@ -82,6 +99,37 @@ function normalizeTextLikeParam(record: Record, key: string) { } } +function normalizeClaudeParamAliases(record: Record) { + for (const { original, alias } of CLAUDE_PARAM_ALIASES) { + if (alias in record && !(original in record)) { + record[original] = record[alias]; + } + delete record[alias]; + } +} + +function addClaudeParamAliasesToSchema(params: { + properties: Record; + required: string[]; +}): boolean { + let changed = false; + for (const { original, alias } of CLAUDE_PARAM_ALIASES) { + if (!(original in params.properties)) { + continue; + } + if (!(alias in params.properties)) { + params.properties[alias] = params.properties[original]; + changed = true; + } + const idx = params.required.indexOf(original); + if (idx !== -1) { + params.required.splice(idx, 1); + changed = true; + } + } + return changed; +} + // Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions. // Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText. // This prevents models trained on Claude Code from getting stuck in tool-call loops. @@ -91,23 +139,7 @@ export function normalizeToolParams(params: unknown): Record | } const record = params as Record; const normalized = { ...record }; - const aliasPairs: Array<{ original: string; alias: string }> = [ - { original: "path", alias: "file_path" }, - { original: "path", alias: "filePath" }, - { original: "path", alias: "file" }, - { original: "oldText", alias: "old_string" }, - { original: "oldText", alias: "old_text" }, - { original: "oldText", alias: "oldString" }, - { original: "newText", alias: "new_string" }, - { original: "newText", alias: "new_text" }, - { original: "newText", alias: "newString" }, - ]; - for (const { original, alias } of aliasPairs) { - if (alias in normalized && !(original in normalized)) { - normalized[original] = normalized[alias]; - } - delete normalized[alias]; - } + normalizeClaudeParamAliases(normalized); // Some providers/models emit text payloads as structured blocks instead of raw strings. // Normalize these for write/edit so content matching and writes stay deterministic. normalizeTextLikeParam(normalized, "content"); @@ -130,34 +162,7 @@ export function patchToolSchemaForClaudeCompatibility(tool: AnyAgentTool): AnyAg const required = Array.isArray(schema.required) ? schema.required.filter((key): key is string => typeof key === "string") : []; - let changed = false; - - const aliasPairs: Array<{ original: string; alias: string }> = [ - { original: "path", alias: "file_path" }, - { original: "path", alias: "filePath" }, - { original: "path", alias: "file" }, - { original: "oldText", alias: "old_string" }, - { original: "oldText", alias: "old_text" }, - { original: "oldText", alias: "oldString" }, - { original: "newText", alias: "new_string" }, - { original: "newText", alias: "new_text" }, - { original: "newText", alias: "newString" }, - ]; - - for (const { original, alias } of aliasPairs) { - if (!(original in properties)) { - continue; - } - if (!(alias in properties)) { - properties[alias] = properties[original]; - changed = true; - } - const idx = required.indexOf(original); - if (idx !== -1) { - required.splice(idx, 1); - changed = true; - } - } + const changed = addClaudeParamAliasesToSchema({ properties, required }); if (!changed) { return tool; diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 5b8b6c0728c..0134cd23280 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -1,6 +1,7 @@ import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import { parseImageGenerationModelRef } from "../../image-generation/model-ref.js"; import { generateImage, listRuntimeImageGenerationProviders, @@ -235,23 +236,6 @@ function normalizeReferenceImages(args: Record): string[] { return normalized; } -function parseImageGenerationModelRef( - raw: string | undefined, -): { provider: string; model: string } | null { - const trimmed = raw?.trim(); - if (!trimmed) { - return null; - } - const slashIndex = trimmed.indexOf("/"); - if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { - return null; - } - return { - provider: trimmed.slice(0, slashIndex).trim(), - model: trimmed.slice(slashIndex + 1).trim(), - }; -} - function resolveSelectedImageGenerationProvider(params: { config?: OpenClawConfig; imageGenerationModelConfig: ToolModelConfig; diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index 70d72010fa8..3c239cd6452 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -13,6 +13,41 @@ import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registr installDiscordRegistryHooks(); describe("resolveCommandAuthorization", () => { + const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => + allowFrom.map((entry) => String(entry).trim()).filter(Boolean); + + function createAllowFromPlugin( + id: string, + resolveAllowFrom: () => Array | undefined, + ) { + return { + pluginId: id, + plugin: { + ...createOutboundTestPlugin({ + id, + outbound: { deliveryMode: "direct" }, + }), + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + resolveAllowFrom, + formatAllowFrom, + }, + }, + source: "test" as const, + }; + } + + function createThrowingAllowFromPlugin(id: string, error: string) { + return createAllowFromPlugin(id, () => { + throw new Error(error); + }); + } + + function registerAllowFromPlugins(...plugins: ReturnType[]) { + setActivePluginRegistry(createTestRegistry(plugins)); + } + function resolveWhatsAppAuthorization(params: { from: string; senderId?: string; @@ -182,28 +217,8 @@ describe("resolveCommandAuthorization", () => { }); it("falls back to channel allowFrom when provider allowlist resolution throws", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: { - ...createOutboundTestPlugin({ - id: "telegram", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => { - throw new Error("channels.telegram.botToken: unresolved SecretRef"); - }, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), + registerAllowFromPlugins( + createThrowingAllowFromPlugin("telegram", "channels.telegram.botToken: unresolved SecretRef"), ); const cfg = { channels: { telegram: { allowFrom: ["123"] } }, @@ -488,28 +503,11 @@ describe("resolveCommandAuthorization", () => { expect(deniedAuth.isAuthorizedSender).toBe(false); }); it("fails closed when provider inference hits unresolved SecretRef allowlists", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: { - ...createOutboundTestPlugin({ - id: "telegram", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => { - throw new Error("channels.telegram.botToken: unresolved SecretRef"); - }, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), + registerAllowFromPlugins( + createThrowingAllowFromPlugin( + "telegram", + "channels.telegram.botToken: unresolved SecretRef", + ), ); const cfg = { @@ -538,28 +536,11 @@ describe("resolveCommandAuthorization", () => { }); it("preserves provider resolution errors when inferred fallback allowFrom is empty", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: { - ...createOutboundTestPlugin({ - id: "telegram", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => { - throw new Error("channels.telegram.botToken: unresolved SecretRef"); - }, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), + registerAllowFromPlugins( + createThrowingAllowFromPlugin( + "telegram", + "channels.telegram.botToken: unresolved SecretRef", + ), ); const auth = resolveCommandAuthorization({ @@ -584,28 +565,8 @@ describe("resolveCommandAuthorization", () => { }); it("fails closed for global commands.allowFrom when inference errors drop every provider", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "slack", - plugin: { - ...createOutboundTestPlugin({ - id: "slack", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => { - throw new Error("channels.slack.token: unresolved SecretRef"); - }, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), + registerAllowFromPlugins( + createThrowingAllowFromPlugin("slack", "channels.slack.token: unresolved SecretRef"), ); const auth = resolveCommandAuthorization({ @@ -629,45 +590,9 @@ describe("resolveCommandAuthorization", () => { expect(auth.isAuthorizedSender).toBe(false); }); it("does not let an unrelated provider resolution error poison inferred commands.allowFrom", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: { - ...createOutboundTestPlugin({ - id: "telegram", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => ["123"], - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - { - pluginId: "slack", - plugin: { - ...createOutboundTestPlugin({ - id: "slack", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => { - throw new Error("channels.slack.token: unresolved SecretRef"); - }, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), + registerAllowFromPlugins( + createAllowFromPlugin("telegram", () => ["123"]), + createThrowingAllowFromPlugin("slack", "channels.slack.token: unresolved SecretRef"), ); const auth = resolveCommandAuthorization({ @@ -694,28 +619,11 @@ describe("resolveCommandAuthorization", () => { }); it("preserves default-account allowFrom on SecretRef fallback", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: { - ...createOutboundTestPlugin({ - id: "telegram", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => { - throw new Error("channels.telegram.botToken: unresolved SecretRef"); - }, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), + registerAllowFromPlugins( + createThrowingAllowFromPlugin( + "telegram", + "channels.telegram.botToken: unresolved SecretRef", + ), ); const auth = resolveCommandAuthorization({ @@ -743,27 +651,7 @@ describe("resolveCommandAuthorization", () => { }); it("treats undefined allowFrom as an open channel, not a resolution failure", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "discord", - plugin: { - ...createOutboundTestPlugin({ - id: "discord", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => undefined, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), - ); + registerAllowFromPlugins(createAllowFromPlugin("discord", () => undefined)); const auth = resolveCommandAuthorization({ ctx: { @@ -783,29 +671,7 @@ describe("resolveCommandAuthorization", () => { }); it("does not log raw resolution messages from thrown allowFrom errors", () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: { - ...createOutboundTestPlugin({ - id: "telegram", - outbound: { deliveryMode: "direct" }, - }), - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: () => { - throw new Error("SECRET-TOKEN-123"); - }, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - }, - source: "test", - }, - ]), - ); + registerAllowFromPlugins(createThrowingAllowFromPlugin("telegram", "SECRET-TOKEN-123")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { diff --git a/src/auto-reply/reply.media-note.test.ts b/src/auto-reply/reply.media-note.test.ts index d8c349a7699..842a069c95b 100644 --- a/src/auto-reply/reply.media-note.test.ts +++ b/src/auto-reply/reply.media-note.test.ts @@ -2,58 +2,17 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; - -const agentMocks = vi.hoisted(() => ({ - runEmbeddedPiAgent: vi.fn(), - loadModelCatalog: vi.fn(), - webAuthExists: vi.fn().mockResolvedValue(true), - getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), - readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), -})); - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (...args: unknown[]) => agentMocks.runEmbeddedPiAgent(...args), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), -})); - -vi.mock("../agents/model-catalog.runtime.js", () => ({ - loadModelCatalog: agentMocks.loadModelCatalog, -})); - -vi.mock("../agents/auth-profiles/session-override.js", () => ({ - clearSessionAuthProfileOverride: vi.fn(), - resolveSessionAuthProfileOverride: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../commands-registry.runtime.js", () => ({ - listChatCommands: () => [], -})); - -vi.mock("../skill-commands.runtime.js", () => ({ - listSkillCommandsForWorkspace: () => [], -})); - -vi.mock("../../extensions/whatsapp/src/session.js", () => ({ - webAuthExists: agentMocks.webAuthExists, - getWebAuthAgeMs: agentMocks.getWebAuthAgeMs, - readWebSelfId: agentMocks.readWebSelfId, -})); +import { + createReplyRuntimeMocks, + installReplyRuntimeMocks, + makeEmbeddedTextResult, + resetReplyRuntimeMocks, +} from "./reply.test-harness.js"; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; +const agentMocks = createReplyRuntimeMocks(); -function makeResult(text: string) { - return { - payloads: [{ text }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }; -} +installReplyRuntimeMocks(agentMocks); async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase( @@ -86,11 +45,7 @@ function makeCfg(home: string) { describe("getReplyFromConfig media note plumbing", () => { beforeEach(async () => { vi.resetModules(); - agentMocks.runEmbeddedPiAgent.mockClear(); - agentMocks.loadModelCatalog.mockClear(); - agentMocks.loadModelCatalog.mockResolvedValue([ - { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, - ]); + resetReplyRuntimeMocks(agentMocks); ({ getReplyFromConfig } = await import("./reply.js")); }); @@ -103,7 +58,7 @@ describe("getReplyFromConfig media note plumbing", () => { let seenPrompt: string | undefined; agentMocks.runEmbeddedPiAgent.mockImplementation(async (params) => { seenPrompt = params.prompt; - return makeResult("ok"); + return makeEmbeddedTextResult("ok"); }); const cfg = makeCfg(home); diff --git a/src/auto-reply/reply.raw-body.test.ts b/src/auto-reply/reply.raw-body.test.ts index d7eb97afd75..edc83d62f1f 100644 --- a/src/auto-reply/reply.raw-body.test.ts +++ b/src/auto-reply/reply.raw-body.test.ts @@ -1,59 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { createTempHomeHarness, makeReplyConfig } from "./reply.test-harness.js"; - -const agentMocks = vi.hoisted(() => ({ - runEmbeddedPiAgent: vi.fn(), - loadModelCatalog: vi.fn(), - webAuthExists: vi.fn().mockResolvedValue(true), - getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), - readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), -})); - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (...args: unknown[]) => agentMocks.runEmbeddedPiAgent(...args), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), -})); - -vi.mock("../agents/model-catalog.runtime.js", () => ({ - loadModelCatalog: agentMocks.loadModelCatalog, -})); - -vi.mock("../agents/auth-profiles/session-override.js", () => ({ - clearSessionAuthProfileOverride: vi.fn(), - resolveSessionAuthProfileOverride: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../commands-registry.runtime.js", () => ({ - listChatCommands: () => [], -})); - -vi.mock("../skill-commands.runtime.js", () => ({ - listSkillCommandsForWorkspace: () => [], -})); - -vi.mock("../../extensions/whatsapp/src/session.js", () => ({ - webAuthExists: agentMocks.webAuthExists, - getWebAuthAgeMs: agentMocks.getWebAuthAgeMs, - readWebSelfId: agentMocks.readWebSelfId, -})); +import { + createReplyRuntimeMocks, + createTempHomeHarness, + installReplyRuntimeMocks, + makeEmbeddedTextResult, + makeReplyConfig, + resetReplyRuntimeMocks, +} from "./reply.test-harness.js"; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; +const agentMocks = createReplyRuntimeMocks(); const { withTempHome } = createTempHomeHarness({ prefix: "openclaw-rawbody-" }); +installReplyRuntimeMocks(agentMocks); + describe("RawBody directive parsing", () => { beforeEach(async () => { vi.resetModules(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - agentMocks.runEmbeddedPiAgent.mockClear(); - agentMocks.loadModelCatalog.mockClear(); - agentMocks.loadModelCatalog.mockResolvedValue([ - { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, - ]); + resetReplyRuntimeMocks(agentMocks); ({ getReplyFromConfig } = await import("./reply.js")); }); @@ -63,13 +29,7 @@ describe("RawBody directive parsing", () => { it("handles directives and history in the prompt", async () => { await withTempHome(async (home) => { - agentMocks.runEmbeddedPiAgent.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + agentMocks.runEmbeddedPiAgent.mockResolvedValue(makeEmbeddedTextResult("ok")); const groupMessageCtx = { Body: "/think:high status please", diff --git a/src/auto-reply/reply.test-harness.ts b/src/auto-reply/reply.test-harness.ts index a75862836ff..09aa01ab7d2 100644 --- a/src/auto-reply/reply.test-harness.ts +++ b/src/auto-reply/reply.test-harness.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll } from "vitest"; +import { afterAll, beforeAll, vi, type Mock } from "vitest"; type HomeEnvSnapshot = { HOME: string | undefined; @@ -95,3 +95,73 @@ export function makeReplyConfig(home: string) { session: { store: path.join(home, "sessions.json") }, }; } + +export type ReplyRuntimeMocks = { + runEmbeddedPiAgent: Mock; + loadModelCatalog: Mock; + webAuthExists: Mock; + getWebAuthAgeMs: Mock; + readWebSelfId: Mock; +}; + +export function createReplyRuntimeMocks(): ReplyRuntimeMocks { + return { + runEmbeddedPiAgent: vi.fn(), + loadModelCatalog: vi.fn(), + webAuthExists: vi.fn().mockResolvedValue(true), + getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), + readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), + }; +} + +export function installReplyRuntimeMocks(mocks: ReplyRuntimeMocks) { + vi.mock("../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: (...args: unknown[]) => mocks.runEmbeddedPiAgent(...args), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + })); + + vi.mock("../agents/model-catalog.runtime.js", () => ({ + loadModelCatalog: mocks.loadModelCatalog, + })); + + vi.mock("../agents/auth-profiles/session-override.js", () => ({ + clearSessionAuthProfileOverride: vi.fn(), + resolveSessionAuthProfileOverride: vi.fn().mockResolvedValue(undefined), + })); + + vi.mock("../commands-registry.runtime.js", () => ({ + listChatCommands: () => [], + })); + + vi.mock("../skill-commands.runtime.js", () => ({ + listSkillCommandsForWorkspace: () => [], + })); + + vi.mock("../plugins/runtime/runtime-whatsapp-boundary.js", () => ({ + webAuthExists: mocks.webAuthExists, + getWebAuthAgeMs: mocks.getWebAuthAgeMs, + readWebSelfId: mocks.readWebSelfId, + })); +} + +export function resetReplyRuntimeMocks(mocks: ReplyRuntimeMocks) { + mocks.runEmbeddedPiAgent.mockClear(); + mocks.loadModelCatalog.mockClear(); + mocks.loadModelCatalog.mockResolvedValue([ + { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, + ]); +} + +export function makeEmbeddedTextResult(text: string) { + return { + payloads: [{ text }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }; +} diff --git a/src/auto-reply/reply/commands-plugins.toggle.test.ts b/src/auto-reply/reply/commands-plugins.toggle.test.ts index 0f788da6ccf..17702e2e911 100644 --- a/src/auto-reply/reply/commands-plugins.toggle.test.ts +++ b/src/auto-reply/reply/commands-plugins.toggle.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginRecord, createPluginStatusReport } from "../../plugins/status.test-helpers.js"; const { readConfigFileSnapshotMock, @@ -62,36 +63,20 @@ describe("handleCommands /plugins toggle", () => { path: "/tmp/openclaw.json", resolved: config, }); - buildPluginStatusReportMock.mockReturnValue({ - workspaceDir: "/tmp/workspace", - plugins: [ - { - id: "superpowers", - name: "superpowers", - format: "bundle", - source: "/tmp/workspace/.openclaw/extensions/superpowers", - origin: "workspace", - enabled: false, - status: "disabled", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, - ], - diagnostics: [], - }); + buildPluginStatusReportMock.mockReturnValue( + createPluginStatusReport({ + workspaceDir: "/tmp/workspace", + plugins: [ + createPluginRecord({ + id: "superpowers", + format: "bundle", + source: "/tmp/workspace/.openclaw/extensions/superpowers", + enabled: false, + status: "disabled", + }), + ], + }), + ); validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next })); writeConfigFileMock.mockResolvedValue(undefined); @@ -118,36 +103,19 @@ describe("handleCommands /plugins toggle", () => { path: "/tmp/openclaw.json", resolved: config, }); - buildPluginStatusReportMock.mockReturnValue({ - workspaceDir: "/tmp/workspace", - plugins: [ - { - id: "superpowers", - name: "superpowers", - format: "bundle", - source: "/tmp/workspace/.openclaw/extensions/superpowers", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, - ], - diagnostics: [], - }); + buildPluginStatusReportMock.mockReturnValue( + createPluginStatusReport({ + workspaceDir: "/tmp/workspace", + plugins: [ + createPluginRecord({ + id: "superpowers", + format: "bundle", + source: "/tmp/workspace/.openclaw/extensions/superpowers", + enabled: true, + }), + ], + }), + ); validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next })); writeConfigFileMock.mockResolvedValue(undefined); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 5e79ed7ae9f..c57d08671f3 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,13 +1,9 @@ import { buildBrowseProvidersButton } from "../../../extensions/telegram/api.js"; -import { - ensureAuthProfileStore, - resolveAuthStorePathForDisplay, -} from "../../agents/auth-profiles.js"; +import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js"; import { type ModelAliasIndex, modelKey, normalizeProviderId, - normalizeProviderIdForAuth, resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; @@ -21,14 +17,13 @@ import { formatAuthLabel, type ModelAuthDetailMode, resolveAuthLabel, - resolveProfileOverride, } from "./directive-handling.auth.js"; import { type ModelPickerCatalogEntry, resolveProviderEndpointLabel, } from "./directive-handling.model-picker.js"; +export { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; -import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js"; function pushUniqueCatalogEntry(params: { keys: Set; @@ -356,151 +351,3 @@ export async function maybeHandleModelDirectiveInfo(params: { } return { text: lines.join("\n") }; } - -function resolveStoredNumericProfileModelDirective(params: { raw: string; agentDir: string }): { - modelRaw: string; - profileId: string; - profileProvider: string; -} | null { - const trimmed = params.raw.trim(); - const lastSlash = trimmed.lastIndexOf("/"); - const profileDelimiter = trimmed.indexOf("@", lastSlash + 1); - if (profileDelimiter <= 0) { - return null; - } - - const profileId = trimmed.slice(profileDelimiter + 1).trim(); - if (!/^\d{8}$/.test(profileId)) { - return null; - } - - const modelRaw = trimmed.slice(0, profileDelimiter).trim(); - if (!modelRaw) { - return null; - } - - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const profile = store.profiles[profileId]; - if (!profile) { - return null; - } - - return { modelRaw, profileId, profileProvider: profile.provider }; -} - -export function resolveModelSelectionFromDirective(params: { - directives: InlineDirectives; - cfg: OpenClawConfig; - agentDir: string; - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; - allowedModelKeys: Set; - allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; - provider: string; -}): { - modelSelection?: ModelDirectiveSelection; - profileOverride?: string; - errorText?: string; -} { - if (!params.directives.hasModelDirective || !params.directives.rawModelDirective) { - if (params.directives.rawModelProfile) { - return { errorText: "Auth profile override requires a model selection." }; - } - return {}; - } - - const raw = params.directives.rawModelDirective.trim(); - const storedNumericProfile = - params.directives.rawModelProfile === undefined - ? resolveStoredNumericProfileModelDirective({ - raw, - agentDir: params.agentDir, - }) - : null; - const storedNumericProfileSelection = storedNumericProfile - ? resolveModelDirectiveSelection({ - raw: storedNumericProfile.modelRaw, - defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, - aliasIndex: params.aliasIndex, - allowedModelKeys: params.allowedModelKeys, - }) - : null; - const useStoredNumericProfile = - Boolean(storedNumericProfileSelection?.selection) && - normalizeProviderIdForAuth(storedNumericProfileSelection?.selection?.provider ?? "") === - normalizeProviderIdForAuth(storedNumericProfile?.profileProvider ?? ""); - const modelRaw = - useStoredNumericProfile && storedNumericProfile ? storedNumericProfile.modelRaw : raw; - let modelSelection: ModelDirectiveSelection | undefined; - - if (/^[0-9]+$/.test(raw)) { - return { - errorText: [ - "Numeric model selection is not supported in chat.", - "", - "Browse: /models or /models ", - "Switch: /model ", - ].join("\n"), - }; - } - - const explicit = resolveModelRefFromString({ - raw: modelRaw, - defaultProvider: params.defaultProvider, - aliasIndex: params.aliasIndex, - }); - if (explicit) { - const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model); - if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) { - modelSelection = { - provider: explicit.ref.provider, - model: explicit.ref.model, - isDefault: - explicit.ref.provider === params.defaultProvider && - explicit.ref.model === params.defaultModel, - ...(explicit.alias ? { alias: explicit.alias } : {}), - }; - } - } - - if (!modelSelection) { - const resolved = resolveModelDirectiveSelection({ - raw: modelRaw, - defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, - aliasIndex: params.aliasIndex, - allowedModelKeys: params.allowedModelKeys, - }); - - if (resolved.error) { - return { errorText: resolved.error }; - } - - if (resolved.selection) { - modelSelection = resolved.selection; - } - } - - let profileOverride: string | undefined; - const rawProfile = - params.directives.rawModelProfile ?? - (useStoredNumericProfile ? storedNumericProfile?.profileId : undefined); - if (modelSelection && rawProfile) { - const profileResolved = resolveProfileOverride({ - rawProfile, - provider: modelSelection.provider, - cfg: params.cfg, - agentDir: params.agentDir, - }); - if (profileResolved.error) { - return { errorText: profileResolved.error }; - } - profileOverride = profileResolved.profileId; - } - - return { modelSelection, profileOverride }; -} diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index 83b4408e5b4..08e0f8ee4b6 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -54,6 +54,35 @@ async function createCompactionSessionFixture(entry: SessionEntry) { return { storePath, sessionKey, sessionStore }; } +async function rotateCompactionSessionFile(params: { + tempPrefix: string; + sessionFile: (tmp: string) => string; + newSessionId: string; +}) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), params.tempPrefix)); + tempDirs.push(tmp); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { + sessionId: "s1", + sessionFile: params.sessionFile(tmp), + updatedAt: Date.now(), + compactionCount: 0, + } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + newSessionId: params.newSessionId, + }); + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + const expectedDir = await fs.realpath(tmp); + return { stored, sessionKey, expectedDir }; +} + describe("history helpers", () => { function createHistoryMapWithTwoEntries() { const historyMap = new Map(); @@ -446,57 +475,21 @@ describe("incrementCompactionCount", () => { }); it("updates sessionId and sessionFile when compaction rotated transcripts", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-rotate-")); - tempDirs.push(tmp); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - sessionFile: path.join(tmp, "s1-topic-456.jsonl"), - updatedAt: Date.now(), - compactionCount: 0, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, + const { stored, sessionKey, expectedDir } = await rotateCompactionSessionFile({ + tempPrefix: "openclaw-compact-rotate-", + sessionFile: (tmp) => path.join(tmp, "s1-topic-456.jsonl"), newSessionId: "s2", }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - const expectedDir = await fs.realpath(tmp); expect(stored[sessionKey].sessionId).toBe("s2"); expect(stored[sessionKey].sessionFile).toBe(path.join(expectedDir, "s2-topic-456.jsonl")); }); it("preserves fork transcript filenames when compaction rotates transcripts", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-fork-")); - tempDirs.push(tmp); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - sessionFile: path.join(tmp, "2026-03-23T12-34-56-789Z_s1.jsonl"), - updatedAt: Date.now(), - compactionCount: 0, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, + const { stored, sessionKey, expectedDir } = await rotateCompactionSessionFile({ + tempPrefix: "openclaw-compact-fork-", + sessionFile: (tmp) => path.join(tmp, "2026-03-23T12-34-56-789Z_s1.jsonl"), newSessionId: "s2", }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - const expectedDir = await fs.realpath(tmp); expect(stored[sessionKey].sessionId).toBe("s2"); expect(stored[sessionKey].sessionFile).toBe( path.join(expectedDir, "2026-03-23T12-34-56-789Z_s2.jsonl"), @@ -504,30 +497,11 @@ describe("incrementCompactionCount", () => { }); it("falls back to the derived transcript path when rewritten absolute sessionFile is unsafe", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-unsafe-")); - tempDirs.push(tmp); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const unsafePath = path.join(tmp, "outside", "s1.jsonl"); - const entry = { - sessionId: "s1", - sessionFile: unsafePath, - updatedAt: Date.now(), - compactionCount: 0, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, + const { stored, sessionKey, expectedDir } = await rotateCompactionSessionFile({ + tempPrefix: "openclaw-compact-unsafe-", + sessionFile: (tmp) => path.join(tmp, "outside", "s1.jsonl"), newSessionId: "s2", }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - const expectedDir = await fs.realpath(tmp); expect(stored[sessionKey].sessionId).toBe("s2"); expect(stored[sessionKey].sessionFile).toBe(path.join(expectedDir, "s2.jsonl")); }); diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts index 7140c4ebe01..6bd07645485 100644 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ b/src/auto-reply/reply/session-reset-model.test.ts @@ -10,31 +10,50 @@ const modelCatalog: ModelCatalogEntry[] = [ { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, ]; +function createResetFixture(entry: Partial = {}) { + const cfg = {} as OpenClawConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry: SessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + ...entry, + }; + return { + cfg, + aliasIndex, + sessionEntry, + sessionStore: { "agent:main:dm:1": sessionEntry } as Record, + sessionCtx: { BodyStripped: "minimax summarize" }, + ctx: { ChatType: "direct" }, + }; +} + +async function applyResetFixture(params: { + resetTriggered: boolean; + sessionEntry?: Partial; +}) { + const fixture = createResetFixture(params.sessionEntry); + await applyResetModelOverride({ + cfg: fixture.cfg, + resetTriggered: params.resetTriggered, + bodyStripped: "minimax summarize", + sessionCtx: fixture.sessionCtx, + ctx: fixture.ctx, + sessionEntry: fixture.sessionEntry, + sessionStore: fixture.sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: fixture.aliasIndex, + modelCatalog, + }); + return fixture; +} + describe("applyResetModelOverride", () => { it("selects a model hint and strips it from the body", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry: SessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore: Record = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, + const { sessionEntry, sessionCtx } = await applyResetFixture({ resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - modelCatalog, }); expect(sessionEntry.providerOverride).toBe("minimax"); @@ -43,32 +62,13 @@ describe("applyResetModelOverride", () => { }); it("clears auth profile overrides when reset applies a model", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry: SessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - authProfileOverride: "anthropic:default", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 2, - }; - const sessionStore: Record = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, + const { sessionEntry } = await applyResetFixture({ resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - modelCatalog, + sessionEntry: { + authProfileOverride: "anthropic:default", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + }, }); expect(sessionEntry.authProfileOverride).toBeUndefined(); @@ -77,29 +77,8 @@ describe("applyResetModelOverride", () => { }); it("skips when resetTriggered is false", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry: SessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore: Record = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, + const { sessionEntry, sessionCtx } = await applyResetFixture({ resetTriggered: false, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - modelCatalog, }); expect(sessionEntry.providerOverride).toBeUndefined(); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index e18ea3987c0..19565eb966a 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,7 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { resolveUserTimezone } from "../../agents/date-time.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -11,119 +10,9 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; -import { buildChannelSummary } from "../../infra/channel-summary.js"; -import { - resolveTimezone, - formatUtcTimestamp, - formatZonedTimestamp, -} from "../../infra/format-time/format-datetime.ts"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; -import { drainSystemEventEntries } from "../../infra/system-events.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; - -/** Drain queued system events, format as `System:` lines, return the block (or undefined). */ -export async function drainFormattedSystemEvents(params: { - cfg: OpenClawConfig; - sessionKey: string; - isMainSession: boolean; - isNewSession: boolean; -}): Promise { - const compactSystemEvent = (line: string): string | null => { - const trimmed = line.trim(); - if (!trimmed) { - return null; - } - const lower = trimmed.toLowerCase(); - if (lower.includes("reason periodic")) { - return null; - } - // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat" - // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this - if (lower.startsWith("read heartbeat.md")) { - return null; - } - // Also filter heartbeat poll/wake noise - if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) { - return null; - } - if (trimmed.startsWith("Node:")) { - return trimmed.replace(/ · last input [^·]+/i, "").trim(); - } - return trimmed; - }; - - const resolveSystemEventTimezone = (cfg: OpenClawConfig) => { - const raw = cfg.agents?.defaults?.envelopeTimezone?.trim(); - if (!raw) { - return { mode: "local" as const }; - } - const lowered = raw.toLowerCase(); - if (lowered === "utc" || lowered === "gmt") { - return { mode: "utc" as const }; - } - if (lowered === "local" || lowered === "host") { - return { mode: "local" as const }; - } - if (lowered === "user") { - return { - mode: "iana" as const, - timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone), - }; - } - const explicit = resolveTimezone(raw); - return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const }; - }; - - const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => { - const date = new Date(ts); - if (Number.isNaN(date.getTime())) { - return "unknown-time"; - } - const zone = resolveSystemEventTimezone(cfg); - if (zone.mode === "utc") { - return formatUtcTimestamp(date, { displaySeconds: true }); - } - if (zone.mode === "local") { - return formatZonedTimestamp(date, { displaySeconds: true }) ?? "unknown-time"; - } - return ( - formatZonedTimestamp(date, { timeZone: zone.timeZone, displaySeconds: true }) ?? - "unknown-time" - ); - }; - - const systemLines: string[] = []; - const queued = drainSystemEventEntries(params.sessionKey); - systemLines.push( - ...queued - .map((event) => { - const compacted = compactSystemEvent(event.text); - if (!compacted) { - return null; - } - return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`; - }) - .filter((v): v is string => Boolean(v)), - ); - if (params.isMainSession && params.isNewSession) { - const summary = await buildChannelSummary(params.cfg); - if (summary.length > 0) { - systemLines.unshift(...summary); - } - } - if (systemLines.length === 0) { - return undefined; - } - - // Format events as trusted System: lines for the message timeline. - // Inbound sanitization rewrites any user-supplied "System:" to "System (untrusted):", - // so these gateway-originated lines are distinguishable by the model. - // Each sub-line of a multi-line event gets its own System: prefix so continuation - // lines can't be mistaken for user content. - return systemLines - .flatMap((line) => line.split("\n").map((subline) => `System: ${subline}`)) - .join("\n"); -} +export { drainFormattedSystemEvents } from "./session-system-events.js"; async function persistSessionEntryUpdate(params: { sessionStore?: Record; diff --git a/src/auto-reply/skill-commands.test.ts b/src/auto-reply/skill-commands.test.ts index 38a7fedf485..b8a84bfe718 100644 --- a/src/auto-reply/skill-commands.test.ts +++ b/src/auto-reply/skill-commands.test.ts @@ -3,135 +3,91 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -// Avoid importing the full chat command registry for reserved-name calculation. -vi.mock("./commands-registry.js", () => ({ - listChatCommands: () => [], -})); - -vi.mock("../infra/skills-remote.js", () => ({ - getRemoteSkillEligibility: () => ({}), -})); - -// Avoid filesystem-driven skill scanning for these unit tests; we only need command naming semantics. -vi.mock("../agents/skills.js", () => { - function resolveUniqueName(base: string, used: Set): string { - let name = base; - let suffix = 2; - while (used.has(name.toLowerCase())) { - name = `${base}_${suffix}`; - suffix += 1; - } - used.add(name.toLowerCase()); - return name; - } - - function resolveWorkspaceSkills( - workspaceDir: string, - ): Array<{ skillName: string; description: string }> { - const dirName = path.basename(workspaceDir); - if (dirName === "main") { - return [{ skillName: "demo-skill", description: "Demo skill" }]; - } - if (dirName === "research") { - return [ - { skillName: "demo-skill", description: "Demo skill 2" }, - { skillName: "extra-skill", description: "Extra skill" }, - ]; - } - return []; - } - - return { - buildWorkspaceSkillCommandSpecs: ( - workspaceDir: string, - opts?: { reservedNames?: Set; skillFilter?: string[] }, - ) => { - const used = new Set(); - for (const reserved of opts?.reservedNames ?? []) { - used.add(String(reserved).toLowerCase()); - } - const filter = opts?.skillFilter; - const entries = - filter === undefined - ? resolveWorkspaceSkills(workspaceDir) - : resolveWorkspaceSkills(workspaceDir).filter((entry) => - filter.some((skillName) => skillName === entry.skillName), - ); - - return entries.map((entry) => { - const base = entry.skillName.replace(/-/g, "_"); - const name = resolveUniqueName(base, used); - return { name, skillName: entry.skillName, description: entry.description }; - }); - }, - }; -}); - let listSkillCommandsForAgents: typeof import("./skill-commands.js").listSkillCommandsForAgents; let resolveSkillCommandInvocation: typeof import("./skill-commands.js").resolveSkillCommandInvocation; let skillCommandsTesting: typeof import("./skill-commands.js").__testing; -async function loadFreshSkillCommandsModuleForTest() { - vi.resetModules(); - vi.doMock("./commands-registry.js", () => ({ +type SkillCommandMockRegistrar = (path: string, factory: () => unknown) => void; + +function resolveUniqueSkillCommandName(base: string, used: Set): string { + let name = base; + let suffix = 2; + while (used.has(name.toLowerCase())) { + name = `${base}_${suffix}`; + suffix += 1; + } + used.add(name.toLowerCase()); + return name; +} + +function resolveWorkspaceSkills( + workspaceDir: string, +): Array<{ skillName: string; description: string }> { + const dirName = path.basename(workspaceDir); + if (dirName === "main") { + return [{ skillName: "demo-skill", description: "Demo skill" }]; + } + if (dirName === "research") { + return [ + { skillName: "demo-skill", description: "Demo skill 2" }, + { skillName: "extra-skill", description: "Extra skill" }, + ]; + } + return []; +} + +function buildWorkspaceSkillCommandSpecs( + workspaceDir: string, + opts?: { reservedNames?: Set; skillFilter?: string[] }, +) { + const used = new Set(); + for (const reserved of opts?.reservedNames ?? []) { + used.add(String(reserved).toLowerCase()); + } + const filter = opts?.skillFilter; + const entries = + filter === undefined + ? resolveWorkspaceSkills(workspaceDir) + : resolveWorkspaceSkills(workspaceDir).filter((entry) => + filter.some((skillName) => skillName === entry.skillName), + ); + + return entries.map((entry) => { + const base = entry.skillName.replace(/-/g, "_"); + const name = resolveUniqueSkillCommandName(base, used); + return { name, skillName: entry.skillName, description: entry.description }; + }); +} + +function installSkillCommandTestMocks(registerMock: SkillCommandMockRegistrar) { + // Avoid importing the full chat command registry for reserved-name calculation. + registerMock("./commands-registry.js", () => ({ listChatCommands: () => [], })); - vi.doMock("../infra/skills-remote.js", () => ({ + + registerMock("../infra/skills-remote.js", () => ({ getRemoteSkillEligibility: () => ({}), })); - vi.doMock("../agents/skills.js", () => { - function resolveUniqueName(base: string, used: Set): string { - let name = base; - let suffix = 2; - while (used.has(name.toLowerCase())) { - name = `${base}_${suffix}`; - suffix += 1; - } - used.add(name.toLowerCase()); - return name; - } - function resolveWorkspaceSkills( - workspaceDir: string, - ): Array<{ skillName: string; description: string }> { - const dirName = path.basename(workspaceDir); - if (dirName === "main") { - return [{ skillName: "demo-skill", description: "Demo skill" }]; - } - if (dirName === "research") { - return [ - { skillName: "demo-skill", description: "Demo skill 2" }, - { skillName: "extra-skill", description: "Extra skill" }, - ]; - } - return []; - } + // Avoid filesystem-driven skill scanning for these unit tests; we only need command naming semantics. + registerMock("../agents/skills.js", () => ({ + buildWorkspaceSkillCommandSpecs, + })); +} - return { - buildWorkspaceSkillCommandSpecs: ( - workspaceDir: string, - opts?: { reservedNames?: Set; skillFilter?: string[] }, - ) => { - const used = new Set(); - for (const reserved of opts?.reservedNames ?? []) { - used.add(String(reserved).toLowerCase()); - } - const filter = opts?.skillFilter; - const entries = - filter === undefined - ? resolveWorkspaceSkills(workspaceDir) - : resolveWorkspaceSkills(workspaceDir).filter((entry) => - filter.some((skillName) => skillName === entry.skillName), - ); +const registerSkillCommandMock: SkillCommandMockRegistrar = (modulePath, factory) => { + vi.mock(modulePath, factory as Parameters[1]); +}; - return entries.map((entry) => { - const base = entry.skillName.replace(/-/g, "_"); - const name = resolveUniqueName(base, used); - return { name, skillName: entry.skillName, description: entry.description }; - }); - }, - }; - }); +const registerDynamicSkillCommandMock: SkillCommandMockRegistrar = (modulePath, factory) => { + vi.doMock(modulePath, factory as Parameters[1]); +}; + +installSkillCommandTestMocks(registerSkillCommandMock); + +async function loadFreshSkillCommandsModuleForTest() { + vi.resetModules(); + installSkillCommandTestMocks(registerDynamicSkillCommandMock); ({ listSkillCommandsForAgents, resolveSkillCommandInvocation, diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts index 5488b918510..e8534879541 100644 --- a/src/channels/plugins/contracts/outbound-payload.contract.test.ts +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -9,10 +9,10 @@ import { sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia, } from "../../../../src/plugin-sdk/zalo.js"; import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../../src/plugin-sdk/zalouser.js"; -import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; import { createDirectTextMediaOutbound } from "../outbound/direct-text-media.js"; import { + createSlackOutboundPayloadHarness, installChannelOutboundPayloadContractSuite, primeChannelOutboundSendMock, } from "./suites.js"; @@ -82,29 +82,6 @@ function buildChannelSendResult(channel: string, result: Record const mockedSendZalo = vi.mocked(sendMessageZalo); const mockedSendZalouser = vi.mocked(sendMessageZalouser); -function createSlackHarness(params: PayloadHarnessParams) { - const sendSlack = vi.fn(); - primeChannelOutboundSendMock( - sendSlack, - { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, - params.sendResults, - ); - const ctx = { - cfg: {}, - to: "C12345", - text: "", - payload: params.payload, - deps: { - sendSlack, - }, - }; - return { - run: async () => await slackOutbound.sendPayload!(ctx), - sendMock: sendSlack, - to: ctx.to, - }; -} - function createDiscordHarness(params: PayloadHarnessParams) { const sendDiscord = vi.fn(); primeChannelOutboundSendMock( @@ -263,7 +240,7 @@ describe("channel outbound payload contract", () => { installChannelOutboundPayloadContractSuite({ channel: "slack", chunking: { mode: "passthrough", longTextLength: 5000 }, - createHarness: createSlackHarness, + createHarness: createSlackOutboundPayloadHarness, }); }); diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 2224b13fbb7..09b3481a8d1 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -1,5 +1,7 @@ -import { expect, it, type Mock } from "vitest"; +import { expect, it, vi, type Mock } from "vitest"; +import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { MsgContext } from "../../../auto-reply/templating.js"; +import type { ReplyPayload } from "../../../auto-reply/types.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { ResolveProviderRuntimeGroupPolicyParams, @@ -113,6 +115,32 @@ function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) { expect(binding.labelNoun.trim()).not.toBe(""); } +export function createSlackOutboundPayloadHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendSlack = vi.fn(); + primeChannelOutboundSendMock( + sendSlack, + { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "C12345", + text: "", + payload: params.payload, + deps: { + sendSlack, + }, + }; + return { + run: async () => await slackOutbound.sendPayload!(ctx), + sendMock: sendSlack, + to: ctx.to, + }; +} + export function installChannelPluginContractSuite(params: { plugin: Pick; }) { diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index a78916c1336..b3eb93e1d28 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,32 +1,12 @@ -import { describe, expect, it, vi } from "vitest"; -import { slackOutbound } from "../../../../test/channel-outbounds.js"; +import { describe, expect, it } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { primeChannelOutboundSendMock } from "../contracts/suites.js"; +import { createSlackOutboundPayloadHarness } from "../contracts/suites.js"; function createHarness(params: { payload: ReplyPayload; sendResults?: Array<{ messageId: string }>; }) { - const sendSlack = vi.fn(); - primeChannelOutboundSendMock( - sendSlack, - { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, - params.sendResults, - ); - const ctx = { - cfg: {}, - to: "C12345", - text: "", - payload: params.payload, - deps: { - sendSlack, - }, - }; - return { - run: async () => await slackOutbound.sendPayload!(ctx), - sendMock: sendSlack, - to: ctx.to, - }; + return createSlackOutboundPayloadHarness(params); } describe("slackOutbound sendPayload", () => { diff --git a/src/cli/container-target.ts b/src/cli/container-target.ts index 72fb4797590..20685687631 100644 --- a/src/cli/container-target.ts +++ b/src/cli/container-target.ts @@ -1,10 +1,7 @@ import { spawnSync } from "node:child_process"; -import { - consumeRootOptionToken, - FLAG_TERMINATOR, - isValueToken, -} from "../infra/cli-root-options.js"; +import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js"; import { getPrimaryCommand } from "./argv.js"; +import { takeCliRootOptionValue } from "./root-option-value.js"; type CliContainerParseResult = | { ok: true; container: string | null; argv: string[] } @@ -27,23 +24,6 @@ type ContainerRuntimeExec = { argsPrefix: string[]; }; -function takeValue( - raw: string, - next: string | undefined, -): { - value: string | null; - consumedNext: boolean; -} { - if (raw.includes("=")) { - const [, value] = raw.split("=", 2); - const trimmed = (value ?? "").trim(); - return { value: trimmed || null, consumedNext: false }; - } - const consumedNext = isValueToken(next); - const trimmed = consumedNext ? next!.trim() : ""; - return { value: trimmed || null, consumedNext }; -} - export function parseCliContainerArgs(argv: string[]): CliContainerParseResult { if (argv.length < 2) { return { ok: true, container: null, argv }; @@ -65,7 +45,7 @@ export function parseCliContainerArgs(argv: string[]): CliContainerParseResult { if (arg === "--container" || arg.startsWith("--container=")) { const next = args[i + 1]; - const { value, consumedNext } = takeValue(arg, next); + const { value, consumedNext } = takeCliRootOptionValue(arg, next); if (consumedNext) { i += 1; } diff --git a/src/cli/memory-cli.runtime.ts b/src/cli/memory-cli.runtime.ts index 93fb3fddf56..f7145c24572 100644 --- a/src/cli/memory-cli.runtime.ts +++ b/src/cli/memory-cli.runtime.ts @@ -2,7 +2,6 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { Command } from "commander"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; @@ -11,15 +10,14 @@ import { setVerbose } from "../globals.js"; import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js"; import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; import { defaultRuntime } from "../runtime.js"; -import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatErrorMessage, withManager } from "./cli-utils.js"; import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; import { getMemoryCommandSecretTargetIds } from "./command-secret-targets.js"; -import { formatHelpExamples } from "./help-format.js"; import type { MemoryCommandOptions, MemorySearchCommandOptions } from "./memory-cli.types.js"; import { withProgress, withProgressTotals } from "./progress.js"; +export { registerMemoryCli } from "./memory-cli.js"; type MemoryManager = NonNullable; type MemoryManagerPurpose = Parameters[0]["purpose"]; @@ -747,59 +745,3 @@ export async function runMemorySearch( }, }); } - -export function registerMemoryCli(program: Command) { - const memory = program - .command("memory") - .description("Search, inspect, and reindex memory files") - .addHelpText( - "after", - () => - `\n${theme.heading("Examples:")}\n${formatHelpExamples([ - ["openclaw memory status", "Show index and provider status."], - ["openclaw memory status --deep", "Probe embedding provider readiness."], - ["openclaw memory index --force", "Force a full reindex."], - ['openclaw memory search "meeting notes"', "Quick search using positional query."], - [ - 'openclaw memory search --query "deployment" --max-results 20', - "Limit results for focused troubleshooting.", - ], - ["openclaw memory status --json", "Output machine-readable JSON (good for scripts)."], - ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/memory", "docs.openclaw.ai/cli/memory")}\n`, - ); - - memory - .command("status") - .description("Show memory search index status") - .option("--agent ", "Agent id (default: default agent)") - .option("--json", "Print JSON") - .option("--deep", "Probe embedding provider availability") - .option("--index", "Reindex if dirty (implies --deep)") - .option("--verbose", "Verbose logging", false) - .action(async (opts: MemoryCommandOptions & { force?: boolean }) => { - await runMemoryStatus(opts); - }); - - memory - .command("index") - .description("Reindex memory files") - .option("--agent ", "Agent id (default: default agent)") - .option("--force", "Force full reindex", false) - .option("--verbose", "Verbose logging", false) - .action(async (opts: MemoryCommandOptions) => { - await runMemoryIndex(opts); - }); - - memory - .command("search") - .description("Search memory files") - .argument("[query]", "Search query") - .option("--query ", "Search query (alternative to positional argument)") - .option("--agent ", "Agent id (default: default agent)") - .option("--max-results ", "Max results", (value: string) => Number(value)) - .option("--min-score ", "Minimum score", (value: string) => Number(value)) - .option("--json", "Print JSON") - .action(async (queryArg: string | undefined, opts: MemorySearchCommandOptions) => { - await runMemorySearch(queryArg, opts); - }); -} diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 02b6f8ace6a..d9fd8876c5a 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -1,35 +1,15 @@ import os from "node:os"; import path from "node:path"; -import { - consumeRootOptionToken, - FLAG_TERMINATOR, - isValueToken, -} from "../infra/cli-root-options.js"; +import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { getPrimaryCommand } from "./argv.js"; import { isValidProfileName } from "./profile-utils.js"; +import { takeCliRootOptionValue } from "./root-option-value.js"; export type CliProfileParseResult = | { ok: true; profile: string | null; argv: string[] } | { ok: false; error: string }; -function takeValue( - raw: string, - next: string | undefined, -): { - value: string | null; - consumedNext: boolean; -} { - if (raw.includes("=")) { - const [, value] = raw.split("=", 2); - const trimmed = (value ?? "").trim(); - return { value: trimmed || null, consumedNext: false }; - } - const consumedNext = isValueToken(next); - const trimmed = consumedNext ? next!.trim() : ""; - return { value: trimmed || null, consumedNext }; -} - export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { if (argv.length < 2) { return { ok: true, profile: null, argv }; @@ -68,7 +48,7 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { return { ok: false, error: "Cannot combine --dev with --profile" }; } const next = args[i + 1]; - const { value, consumedNext } = takeValue(arg, next); + const { value, consumedNext } = takeCliRootOptionValue(arg, next); if (consumedNext) { i += 1; } diff --git a/src/cli/root-option-value.ts b/src/cli/root-option-value.ts new file mode 100644 index 00000000000..b4adf8d0644 --- /dev/null +++ b/src/cli/root-option-value.ts @@ -0,0 +1,18 @@ +import { isValueToken } from "../infra/cli-root-options.js"; + +export function takeCliRootOptionValue( + raw: string, + next: string | undefined, +): { + value: string | null; + consumedNext: boolean; +} { + if (raw.includes("=")) { + const [, value] = raw.split("=", 2); + const trimmed = (value ?? "").trim(); + return { value: trimmed || null, consumedNext: false }; + } + const consumedNext = isValueToken(next); + const trimmed = consumedNext ? next!.trim() : ""; + return { value: trimmed || null, consumedNext }; +} diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 2be1a76ff34..5bf0870009f 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; +import { createCompatibilityNotice } from "../plugins/status.test-helpers.js"; const readConfigFileSnapshot = vi.fn(); const buildPluginCompatibilityNotices = vi.fn<(_params?: unknown) => PluginCompatibilityNotice[]>( @@ -29,13 +30,7 @@ describe("requireValidConfigSnapshot", () => { issues: [], }); buildPluginCompatibilityNotices.mockReturnValue([ - { - pluginId: "legacy-plugin", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }, + createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), ]); } diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 2e9965aaccf..b55418584ba 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import { + createPluginLoadResult, + createPluginRecord, + createTypedHook, +} from "../plugins/status.test-helpers.js"; import * as noteModule from "../terminal/note.js"; const resolveAgentWorkspaceDirMock = vi.fn(); @@ -19,29 +24,6 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), })); -function createPluginLoadResult(params: { plugins: unknown[]; typedHooks?: unknown[] }) { - return { - plugins: params.plugins, - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: params.typedHooks ?? [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], - conversationBindingResolvedHandlers: [], - }; -} - async function runNoteWorkspaceStatusForTest( loadResult: ReturnType, ) { @@ -63,37 +45,14 @@ describe("noteWorkspaceStatus", () => { const noteSpy = await runNoteWorkspaceStatusForTest( createPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "legacy-plugin", name: "Legacy Plugin", - source: "/tmp/legacy-plugin/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, + }), ], typedHooks: [ - { - pluginId: "legacy-plugin", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/legacy-plugin/index.ts", - }, + createTypedHook({ pluginId: "legacy-plugin", hookName: "before_agent_start" }), ], }), ); @@ -117,32 +76,14 @@ describe("noteWorkspaceStatus", () => { const noteSpy = await runNoteWorkspaceStatusForTest( createPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "claude-bundle", name: "Claude Bundle", source: "/tmp/claude-bundle", - origin: "workspace", - enabled: true, - status: "loaded", format: "bundle", bundleFormat: "claude", bundleCapabilities: ["skills", "commands", "agents"], - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], }), ); @@ -161,29 +102,11 @@ describe("noteWorkspaceStatus", () => { const noteSpy = await runNoteWorkspaceStatusForTest( createPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "modern-plugin", name: "Modern Plugin", - source: "/tmp/modern-plugin/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["modern"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], }), ); diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 31380c2cd48..25dfdad6696 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -115,6 +115,32 @@ function createUnexpectedConfigureCall(message: string) { }); } +async function expectQuickstartPickerSkipsWithoutRuntime() { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "__skip__"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await expect( + runSetupChannels({} as OpenClawConfig, prompter, { + quickstartDefaults: true, + }), + ).resolves.toEqual({} as OpenClawConfig); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Select channel (QuickStart)" }), + ); + expect(multiselect).not.toHaveBeenCalled(); +} + async function runConfiguredTelegramSetup(params: { strictUnexpected?: boolean; configureWhenConfigured: NonNullable< @@ -278,55 +304,11 @@ describe("setupChannels", () => { }); it("renders the QuickStart channel picker without requiring the LINE runtime", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "__skip__"; - } - return "__done__"; - }); - const { multiselect, text } = createUnexpectedPromptGuards(); - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - await expect( - runSetupChannels({} as OpenClawConfig, prompter, { - quickstartDefaults: true, - }), - ).resolves.toEqual({} as OpenClawConfig); - - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ message: "Select channel (QuickStart)" }), - ); - expect(multiselect).not.toHaveBeenCalled(); + await expectQuickstartPickerSkipsWithoutRuntime(); }); it("renders the QuickStart channel picker without requiring the Matrix runtime", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "__skip__"; - } - return "__done__"; - }); - const { multiselect, text } = createUnexpectedPromptGuards(); - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - await expect( - runSetupChannels({} as OpenClawConfig, prompter, { - quickstartDefaults: true, - }), - ).resolves.toEqual({} as OpenClawConfig); - - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ message: "Select channel (QuickStart)" }), - ); - expect(multiselect).not.toHaveBeenCalled(); + await expectQuickstartPickerSkipsWithoutRuntime(); }); it("continues Telegram setup when the plugin registry is empty", async () => { diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index e67c06ea611..8c2f9f28ba8 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -11,7 +11,7 @@ import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { isValidIPv4 } from "../gateway/net.js"; -import { isSafeExecutableValue } from "../infra/exec-safety.js"; +import { detectBinary } from "../infra/detect-binary.js"; import { inspectBestEffortPrimaryTailnetIPv4, pickBestEffortPrimaryLanIPv4, @@ -20,17 +20,13 @@ import { isWSL } from "../infra/wsl.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; -import { - CONFIG_DIR, - resolveUserPath, - shortenHomeInString, - shortenHomePath, - sleep, -} from "../utils.js"; +import { CONFIG_DIR, shortenHomeInString, shortenHomePath, sleep } from "../utils.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js"; +export { detectBinary }; + export function guardCancel(value: T | symbol, runtime: RuntimeEnv): T { if (isCancel(value)) { cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled."); @@ -344,37 +340,6 @@ export async function handleReset(scope: ResetScope, workspaceDir: string, runti } } -export async function detectBinary(name: string): Promise { - if (!name?.trim()) { - return false; - } - if (!isSafeExecutableValue(name)) { - return false; - } - const resolved = name.startsWith("~") ? resolveUserPath(name) : name; - if ( - path.isAbsolute(resolved) || - resolved.startsWith(".") || - resolved.includes("/") || - resolved.includes("\\") - ) { - try { - await fs.access(resolved); - return true; - } catch { - return false; - } - } - - const command = process.platform === "win32" ? ["where", name] : ["/usr/bin/env", "which", name]; - try { - const result = await runCommandWithTimeout(command, { timeoutMs: 2000 }); - return result.code === 0 && result.stdout.trim().length > 0; - } catch { - return false; - } -} - function shouldSkipBrowserOpenInTests(): boolean { if (process.env.VITEST) { return true; diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index a47c044d997..359cce41612 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loggingState } from "../logging/state.js"; import { applyStatusScanDefaults, + createStatusGatewayCallModuleMock, + createStatusGatewayProbeModuleMock, createStatusMemorySearchConfig, createStatusMemorySearchManager, + createStatusOsSummaryModuleMock, + createStatusPluginRegistryModuleMock, + createStatusPluginStatusModuleMock, + createStatusScanDepsRuntimeModuleMock, createStatusSummary, withTemporaryEnv, } from "./status.scan.test-helpers.js"; @@ -13,17 +19,17 @@ const mocks = vi.hoisted(() => ({ hasPotentialConfiguredChannels: vi.fn(), readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), - getStatusCommandSecretTargetIds: vi.fn(() => []), getUpdateCheckResult: vi.fn(), getAgentLocalStatuses: vi.fn(), getStatusSummary: vi.fn(), - resolveMemorySearchConfig: vi.fn(), getMemorySearchManager: vi.fn(), buildGatewayConnectionDetails: vi.fn(), probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), ensurePluginRegistryLoaded: vi.fn(), buildPluginCompatibilityNotices: vi.fn(() => []), + getStatusCommandSecretTargetIds: vi.fn(() => []), + resolveMemorySearchConfig: vi.fn(), })); let originalForceStderr: boolean; @@ -68,55 +74,30 @@ vi.mock("../cli/command-secret-targets.js", () => ({ getStatusCommandSecretTargetIds: mocks.getStatusCommandSecretTargetIds, })); -vi.mock("./status.update.js", () => ({ - getUpdateCheckResult: mocks.getUpdateCheckResult, -})); - -vi.mock("./status.agent-local.js", () => ({ - getAgentLocalStatuses: mocks.getAgentLocalStatuses, -})); - -vi.mock("./status.summary.js", () => ({ - getStatusSummary: mocks.getStatusSummary, -})); - -vi.mock("../infra/os-summary.js", () => ({ - resolveOsSummary: vi.fn(() => ({ label: "test-os" })), -})); - -vi.mock("./status.scan.deps.runtime.js", () => ({ - getTailnetHostname: vi.fn(), - getMemorySearchManager: mocks.getMemorySearchManager, -})); +vi.mock("./status.update.js", () => ({ getUpdateCheckResult: mocks.getUpdateCheckResult })); +vi.mock("./status.agent-local.js", () => ({ getAgentLocalStatuses: mocks.getAgentLocalStatuses })); +vi.mock("./status.summary.js", () => ({ getStatusSummary: mocks.getStatusSummary })); +vi.mock("../infra/os-summary.js", () => createStatusOsSummaryModuleMock()); +vi.mock("./status.scan.deps.runtime.js", () => createStatusScanDepsRuntimeModuleMock(mocks)); vi.mock("../agents/memory-search.js", () => ({ resolveMemorySearchConfig: mocks.resolveMemorySearchConfig, })); -vi.mock("../gateway/call.js", () => ({ - buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, -})); +vi.mock("../gateway/call.js", () => createStatusGatewayCallModuleMock(mocks)); vi.mock("../gateway/probe.js", () => ({ probeGateway: mocks.probeGateway, })); -vi.mock("./status.gateway-probe.js", () => ({ - pickGatewaySelfPresence: vi.fn(() => null), - resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution, -})); +vi.mock("./status.gateway-probe.js", () => createStatusGatewayProbeModuleMock(mocks)); vi.mock("../process/exec.js", () => ({ runExec: vi.fn(), })); -vi.mock("../cli/plugin-registry.js", () => ({ - ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, -})); - -vi.mock("../plugins/status.js", () => ({ - buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, -})); +vi.mock("../cli/plugin-registry.js", () => createStatusPluginRegistryModuleMock(mocks)); +vi.mock("../plugins/status.js", () => createStatusPluginStatusModuleMock(mocks)); const { scanStatusJsonFast } = await import("./status.scan.fast-json.js"); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 4c4a7fd50ce..73efdc0f6fe 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -5,24 +5,15 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveOsSummary } from "../infra/os-summary.js"; -import { loggingState } from "../logging/state.js"; -import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; import type { StatusScanResult } from "./status.scan.js"; +import { scanStatusJsonCore } from "./status.scan.json-core.js"; import { - buildTailscaleHttpsUrl, - pickGatewaySelfPresence, - resolveGatewayProbeSnapshot, - resolveMemoryPluginStatus, resolveSharedMemoryStatusSnapshot, type MemoryPluginStatus, type MemoryStatusSnapshot, } from "./status.scan.shared.js"; -import { getStatusSummary } from "./status.summary.js"; -import { getUpdateCheckResult } from "./status.update.js"; - -let pluginRegistryModulePromise: Promise | undefined; let configIoModulePromise: Promise | undefined; let commandSecretTargetsModulePromise: | Promise @@ -35,11 +26,6 @@ let statusScanDepsRuntimeModulePromise: | Promise | undefined; -function loadPluginRegistryModule() { - pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); - return pluginRegistryModulePromise; -} - function loadConfigIoModule() { configIoModulePromise ??= import("../config/io.js"); return configIoModulePromise; @@ -77,14 +63,6 @@ function isMissingConfigColdStart(): boolean { return !shouldSkipMissingConfigFastPath() && !existsSync(resolveConfigPath(process.env)); } -function buildColdStartUpdateResult(): Awaited> { - return { - root: null, - installKind: "unknown", - packageManager: "unknown", - }; -} - function resolveDefaultMemoryStorePath(agentId: string): string { return path.join(resolveStateDir(process.env, os.homedir), "memory", `${agentId}.sqlite`); } @@ -136,7 +114,7 @@ export async function scanStatusJsonFast( timeoutMs?: number; all?: boolean; }, - _runtime: RuntimeEnv, + runtime: RuntimeEnv, ): Promise { const coldStart = isMissingConfigColdStart(); const loadedRaw = await readStatusSourceConfig(); @@ -144,109 +122,16 @@ export async function scanStatusJsonFast( sourceConfig: loadedRaw, commandName: "status --json", }); - const hasConfiguredChannels = hasPotentialConfiguredChannels(cfg); - if (hasConfiguredChannels) { - const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - // Route plugin registration logs to stderr so they don't corrupt JSON on stdout. - const prev = loggingState.forceConsoleToStderr; - loggingState.forceConsoleToStderr = true; - try { - ensurePluginRegistryLoaded({ scope: "configured-channels" }); - } finally { - loggingState.forceConsoleToStderr = prev; - } - } - const osSummary = resolveOsSummary(); - const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const updateTimeoutMs = opts.all ? 6500 : 2500; - const skipColdStartNetworkChecks = coldStart && !hasConfiguredChannels && opts.all !== true; - const updatePromise = skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartUpdateResult()) - : getUpdateCheckResult({ - timeoutMs: updateTimeoutMs, - fetchGit: true, - includeRegistry: true, - }); - const agentStatusPromise = getAgentLocalStatuses(cfg); - const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw }); - - const tailscaleDnsPromise = - tailscaleMode === "off" - ? Promise.resolve(null) - : loadStatusScanDepsRuntimeModule() - .then(({ getTailnetHostname }) => - getTailnetHostname((cmd, args) => - runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), - ), - ) - .catch(() => null); - - const gatewayProbePromise = resolveGatewayProbeSnapshot({ - cfg, - opts: { - ...opts, - ...(skipColdStartNetworkChecks ? { skipProbe: true } : {}), - }, - }); - - const [tailscaleDns, update, agentStatus, gatewaySnapshot, summary] = await Promise.all([ - tailscaleDnsPromise, - updatePromise, - agentStatusPromise, - gatewayProbePromise, - summaryPromise, - ]); - const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ - tailscaleMode, - tailscaleDns, - controlUiBasePath: cfg.gateway?.controlUi?.basePath, - }); - - const { - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - } = gatewaySnapshot; - const gatewayReachable = gatewayProbe?.ok === true; - const gatewaySelf = gatewayProbe?.presence - ? pickGatewaySelfPresence(gatewayProbe.presence) - : null; - const memoryPlugin = resolveMemoryPluginStatus(cfg); - // Keep the lean `status --json` route off the memory manager/runtime graph. - // Deep memory inspection is still available on the explicit `--all` path. - const memory = opts.all - ? await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }) - : null; - // `status --json` does not serialize plugin compatibility notices, so keep the - // fast path off the full plugin status graph after the initial scoped preload. - const pluginCompatibility: StatusScanResult["pluginCompatibility"] = []; - - return { + return await scanStatusJsonCore({ + coldStart, cfg, sourceConfig: loadedRaw, secretDiagnostics, - osSummary, - tailscaleMode, - tailscaleDns, - tailscaleHttpsUrl, - update, - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - gatewayReachable, - gatewaySelf, - channelIssues: [], - agentStatus, - channels: { rows: [], details: [] }, - summary, - memory, - memoryPlugin, - pluginCompatibility, - }; + hasConfiguredChannels: hasPotentialConfiguredChannels(cfg), + opts, + resolveOsSummary, + resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => + opts.all ? await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }) : null, + runtime, + }); } diff --git a/src/commands/status.scan.json-core.ts b/src/commands/status.scan.json-core.ts new file mode 100644 index 00000000000..186e45eb17a --- /dev/null +++ b/src/commands/status.scan.json-core.ts @@ -0,0 +1,161 @@ +import type { OpenClawConfig } from "../config/types.js"; +import { loggingState } from "../logging/state.js"; +import { runExec } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { getAgentLocalStatuses } from "./status.agent-local.js"; +import type { StatusScanResult } from "./status.scan.js"; +import { + buildTailscaleHttpsUrl, + pickGatewaySelfPresence, + resolveGatewayProbeSnapshot, + resolveMemoryPluginStatus, +} from "./status.scan.shared.js"; +import { getStatusSummary } from "./status.summary.js"; +import { getUpdateCheckResult } from "./status.update.js"; + +let pluginRegistryModulePromise: Promise | undefined; +let statusScanDepsRuntimeModulePromise: + | Promise + | undefined; + +function loadPluginRegistryModule() { + pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); + return pluginRegistryModulePromise; +} + +function loadStatusScanDepsRuntimeModule() { + statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); + return statusScanDepsRuntimeModulePromise; +} + +export function buildColdStartUpdateResult(): Awaited> { + return { + root: null, + installKind: "unknown", + packageManager: "unknown", + }; +} + +export async function scanStatusJsonCore(params: { + coldStart: boolean; + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; + secretDiagnostics: string[]; + hasConfiguredChannels: boolean; + opts: { timeoutMs?: number; all?: boolean }; + resolveOsSummary: () => StatusScanResult["osSummary"]; + resolveMemory: (args: { + cfg: OpenClawConfig; + agentStatus: Awaited>; + memoryPlugin: StatusScanResult["memoryPlugin"]; + runtime: RuntimeEnv; + }) => Promise; + runtime: RuntimeEnv; +}): Promise { + const { cfg, sourceConfig, secretDiagnostics, hasConfiguredChannels, opts } = params; + if (hasConfiguredChannels) { + const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); + // Route plugin registration logs to stderr so they don't corrupt JSON on stdout. + const previousForceStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = true; + try { + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + } finally { + loggingState.forceConsoleToStderr = previousForceStderr; + } + } + + const osSummary = params.resolveOsSummary(); + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const updateTimeoutMs = opts.all ? 6500 : 2500; + const skipColdStartNetworkChecks = + params.coldStart && !hasConfiguredChannels && opts.all !== true; + const updatePromise = skipColdStartNetworkChecks + ? Promise.resolve(buildColdStartUpdateResult()) + : getUpdateCheckResult({ + timeoutMs: updateTimeoutMs, + fetchGit: true, + includeRegistry: true, + }); + const agentStatusPromise = getAgentLocalStatuses(cfg); + const summaryPromise = getStatusSummary({ config: cfg, sourceConfig }); + const tailscaleDnsPromise = + tailscaleMode === "off" + ? Promise.resolve(null) + : loadStatusScanDepsRuntimeModule() + .then(({ getTailnetHostname }) => + getTailnetHostname((cmd, args) => + runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), + ), + ) + .catch(() => null); + const gatewayProbePromise = resolveGatewayProbeSnapshot({ + cfg, + opts: { + ...opts, + ...(skipColdStartNetworkChecks ? { skipProbe: true } : {}), + }, + }); + + const [tailscaleDns, update, agentStatus, gatewaySnapshot, summary] = await Promise.all([ + tailscaleDnsPromise, + updatePromise, + agentStatusPromise, + gatewayProbePromise, + summaryPromise, + ]); + const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ + tailscaleMode, + tailscaleDns, + controlUiBasePath: cfg.gateway?.controlUi?.basePath, + }); + + const { + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, + gatewayProbe, + } = gatewaySnapshot; + const gatewayReachable = gatewayProbe?.ok === true; + const gatewaySelf = gatewayProbe?.presence + ? pickGatewaySelfPresence(gatewayProbe.presence) + : null; + const memoryPlugin = resolveMemoryPluginStatus(cfg); + const memory = await params.resolveMemory({ + cfg, + agentStatus, + memoryPlugin, + runtime: params.runtime, + }); + // `status --json` does not serialize plugin compatibility notices, so keep + // both routes off the full plugin status graph after the scoped preload. + const pluginCompatibility: StatusScanResult["pluginCompatibility"] = []; + + return { + cfg, + sourceConfig, + secretDiagnostics, + osSummary, + tailscaleMode, + tailscaleDns, + tailscaleHttpsUrl, + update, + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, + gatewayProbe, + gatewayReachable, + gatewaySelf, + channelIssues: [], + agentStatus, + channels: { rows: [], details: [] }, + summary, + memory, + memoryPlugin, + pluginCompatibility, + }; +} diff --git a/src/commands/status.scan.test-helpers.ts b/src/commands/status.scan.test-helpers.ts index 9904742f6fc..113c317cf70 100644 --- a/src/commands/status.scan.test-helpers.ts +++ b/src/commands/status.scan.test-helpers.ts @@ -21,6 +21,57 @@ export function createStatusScanSharedMocks(configPathLabel: string) { export type StatusScanSharedMocks = ReturnType; +export function createStatusOsSummaryModuleMock() { + return { + resolveOsSummary: vi.fn(() => ({ label: "test-os" })), + }; +} + +export function createStatusScanDepsRuntimeModuleMock( + mocks: Pick, +) { + return { + getTailnetHostname: vi.fn(), + getMemorySearchManager: mocks.getMemorySearchManager, + }; +} + +export function createStatusGatewayProbeModuleMock( + mocks: Pick, +) { + return { + pickGatewaySelfPresence: vi.fn(() => null), + resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution, + }; +} + +export function createStatusGatewayCallModuleMock( + mocks: Pick & { + callGateway?: unknown; + }, +) { + return { + buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, + ...(mocks.callGateway ? { callGateway: mocks.callGateway } : {}), + }; +} + +export function createStatusPluginRegistryModuleMock( + mocks: Pick, +) { + return { + ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, + }; +} + +export function createStatusPluginStatusModuleMock( + mocks: Pick, +) { + return { + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, + }; +} + export function createStatusScanConfig( overrides: T = {} as T, ): OpenClawConfig & T { diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 7483f441d59..382128126e4 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -2,9 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loggingState } from "../logging/state.js"; import { applyStatusScanDefaults, + createStatusGatewayCallModuleMock, + createStatusGatewayProbeModuleMock, createStatusMemorySearchConfig, createStatusMemorySearchManager, createStatusScanConfig, + createStatusScanDepsRuntimeModuleMock, + createStatusOsSummaryModuleMock, + createStatusPluginRegistryModuleMock, + createStatusPluginStatusModuleMock, createStatusSummary, withTemporaryEnv, } from "./status.scan.test-helpers.js"; @@ -14,8 +20,6 @@ const mocks = vi.hoisted(() => ({ hasPotentialConfiguredChannels: vi.fn(), readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), - buildChannelsTable: vi.fn(), - callGateway: vi.fn(), getUpdateCheckResult: vi.fn(), getAgentLocalStatuses: vi.fn(), getStatusSummary: vi.fn(), @@ -25,6 +29,8 @@ const mocks = vi.hoisted(() => ({ resolveGatewayProbeAuthResolution: vi.fn(), ensurePluginRegistryLoaded: vi.fn(), buildPluginCompatibilityNotices: vi.fn(() => []), + buildChannelsTable: vi.fn(), + callGateway: vi.fn(), })); let originalForceStderr: boolean; @@ -75,52 +81,25 @@ vi.mock("./status.scan.runtime.js", () => ({ }, })); -vi.mock("./status.update.js", () => ({ - getUpdateCheckResult: mocks.getUpdateCheckResult, -})); - -vi.mock("./status.agent-local.js", () => ({ - getAgentLocalStatuses: mocks.getAgentLocalStatuses, -})); - -vi.mock("./status.summary.js", () => ({ - getStatusSummary: mocks.getStatusSummary, -})); - -vi.mock("../infra/os-summary.js", () => ({ - resolveOsSummary: vi.fn(() => ({ label: "test-os" })), -})); - -vi.mock("./status.scan.deps.runtime.js", () => ({ - getTailnetHostname: vi.fn(), - getMemorySearchManager: mocks.getMemorySearchManager, -})); - -vi.mock("../gateway/call.js", () => ({ - buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, - callGateway: mocks.callGateway, -})); +vi.mock("./status.update.js", () => ({ getUpdateCheckResult: mocks.getUpdateCheckResult })); +vi.mock("./status.agent-local.js", () => ({ getAgentLocalStatuses: mocks.getAgentLocalStatuses })); +vi.mock("./status.summary.js", () => ({ getStatusSummary: mocks.getStatusSummary })); +vi.mock("../infra/os-summary.js", () => createStatusOsSummaryModuleMock()); +vi.mock("./status.scan.deps.runtime.js", () => createStatusScanDepsRuntimeModuleMock(mocks)); +vi.mock("../gateway/call.js", () => createStatusGatewayCallModuleMock(mocks)); vi.mock("../gateway/probe.js", () => ({ probeGateway: mocks.probeGateway, })); -vi.mock("./status.gateway-probe.js", () => ({ - pickGatewaySelfPresence: vi.fn(() => null), - resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution, -})); +vi.mock("./status.gateway-probe.js", () => createStatusGatewayProbeModuleMock(mocks)); vi.mock("../process/exec.js", () => ({ runExec: vi.fn(), })); -vi.mock("../cli/plugin-registry.js", () => ({ - ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, -})); - -vi.mock("../plugins/status.js", () => ({ - buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, -})); +vi.mock("../cli/plugin-registry.js", () => createStatusPluginRegistryModuleMock(mocks)); +vi.mock("../plugins/status.js", () => createStatusPluginStatusModuleMock(mocks)); import { scanStatus } from "./status.scan.js"; diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index cbec8e31bbd..1b6b0f8bd70 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -10,7 +10,6 @@ import { resolveConfigPath } from "../config/paths.js"; import { callGateway } from "../gateway/call.js"; import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; -import { loggingState } from "../logging/state.js"; import { buildPluginCompatibilityNotices, type PluginCompatibilityNotice, @@ -20,6 +19,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; +import { buildColdStartUpdateResult, scanStatusJsonCore } from "./status.scan.json-core.js"; import { buildTailscaleHttpsUrl, pickGatewaySelfPresence, @@ -35,16 +35,10 @@ import { getUpdateCheckResult } from "./status.update.js"; type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; -let pluginRegistryModulePromise: Promise | undefined; let statusScanDepsRuntimeModulePromise: | Promise | undefined; -function loadPluginRegistryModule() { - pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); - return pluginRegistryModulePromise; -} - const loadStatusScanRuntimeModule = createLazyRuntimeSurface( () => import("./status.scan.runtime.js"), ({ statusScanRuntime }) => statusScanRuntime, @@ -73,14 +67,6 @@ function isMissingConfigColdStart(): boolean { return !existsSync(resolveConfigPath(process.env)); } -function buildColdStartUpdateResult(): Awaited> { - return { - root: null, - installKind: "unknown", - packageManager: "unknown", - }; -} - async function resolveChannelsStatus(params: { cfg: OpenClawConfig; gatewayReachable: boolean; @@ -147,6 +133,7 @@ async function resolveMemoryStatusSnapshot(params: { async function scanStatusJsonFast(opts: { timeoutMs?: number; all?: boolean; + runtime: RuntimeEnv; }): Promise { const coldStart = isMissingConfigColdStart(); const loadedRaw = await readBestEffortConfig(); @@ -157,108 +144,18 @@ async function scanStatusJsonFast(opts: { targetIds: getStatusCommandSecretTargetIds(), mode: "read_only_status", }); - const hasConfiguredChannels = hasPotentialConfiguredChannels(cfg); - if (hasConfiguredChannels) { - const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - // Route plugin registration logs to stderr so they don't corrupt JSON on stdout. - const prev = loggingState.forceConsoleToStderr; - loggingState.forceConsoleToStderr = true; - try { - ensurePluginRegistryLoaded({ scope: "configured-channels" }); - } finally { - loggingState.forceConsoleToStderr = prev; - } - } - const osSummary = resolveOsSummary(); - const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const updateTimeoutMs = opts.all ? 6500 : 2500; - const skipColdStartNetworkChecks = coldStart && !hasConfiguredChannels && opts.all !== true; - const updatePromise = skipColdStartNetworkChecks - ? Promise.resolve(buildColdStartUpdateResult()) - : getUpdateCheckResult({ - timeoutMs: updateTimeoutMs, - fetchGit: true, - includeRegistry: true, - }); - const agentStatusPromise = getAgentLocalStatuses(cfg); - const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw }); - - const tailscaleDnsPromise = - tailscaleMode === "off" - ? Promise.resolve(null) - : loadStatusScanDepsRuntimeModule() - .then(({ getTailnetHostname }) => - getTailnetHostname((cmd, args) => - runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), - ), - ) - .catch(() => null); - - const gatewayProbePromise = resolveGatewayProbeSnapshot({ - cfg, - opts: { - ...opts, - ...(skipColdStartNetworkChecks ? { skipProbe: true } : {}), - }, - }); - - const [tailscaleDns, update, agentStatus, gatewaySnapshot, summary] = await Promise.all([ - tailscaleDnsPromise, - updatePromise, - agentStatusPromise, - gatewayProbePromise, - summaryPromise, - ]); - const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ - tailscaleMode, - tailscaleDns, - controlUiBasePath: cfg.gateway?.controlUi?.basePath, - }); - - const { - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - } = gatewaySnapshot; - const gatewayReachable = gatewayProbe?.ok === true; - const gatewaySelf = gatewayProbe?.presence - ? pickGatewaySelfPresence(gatewayProbe.presence) - : null; - const memoryPlugin = resolveMemoryPluginStatus(cfg); - const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); - const memory = await memoryPromise; - // `status --json` never renders plugin compatibility notices, so skip the - // full compatibility scan and avoid a second plugin load on the JSON path. - const pluginCompatibility: StatusScanResult["pluginCompatibility"] = []; - - return { + return await scanStatusJsonCore({ + coldStart, cfg, sourceConfig: loadedRaw, secretDiagnostics, - osSummary, - tailscaleMode, - tailscaleDns, - tailscaleHttpsUrl, - update, - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth, - gatewayProbeAuthWarning, - gatewayProbe, - gatewayReachable, - gatewaySelf, - channelIssues: [], - agentStatus, - channels: { rows: [], details: [] }, - summary, - memory, - memoryPlugin, - pluginCompatibility, - }; + hasConfiguredChannels: hasPotentialConfiguredChannels(cfg), + opts, + resolveOsSummary, + resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => + await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }), + runtime: opts.runtime, + }); } export async function scanStatus( @@ -270,7 +167,11 @@ export async function scanStatus( _runtime: RuntimeEnv, ): Promise { if (opts.json) { - return await scanStatusJsonFast({ timeoutMs: opts.timeoutMs, all: opts.all }); + return await scanStatusJsonFast({ + timeoutMs: opts.timeoutMs, + all: opts.all, + runtime: _runtime, + }); } return await withProgress( { diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index 1c18b907b00..ed4dd975923 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -1,3 +1,4 @@ +import { resolveConfiguredProviderFallback } from "../agents/configured-provider-fallback.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { SessionEntry } from "../config/sessions/types.js"; @@ -85,22 +86,12 @@ function resolveConfiguredStatusModelRef(params: { } } - const configuredProviders = params.cfg.models?.providers; - if (configuredProviders && typeof configuredProviders === "object") { - const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]); - if (!hasDefaultProvider) { - const availableProvider = Object.entries(configuredProviders).find( - ([, providerCfg]) => - providerCfg && - Array.isArray(providerCfg.models) && - providerCfg.models.length > 0 && - providerCfg.models[0]?.id, - ); - if (availableProvider) { - const [providerName, providerCfg] = availableProvider; - return { provider: providerName, model: providerCfg.models[0].id }; - } - } + const fallbackProvider = resolveConfiguredProviderFallback({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + }); + if (fallbackProvider) { + return fallbackProvider; } return { provider: params.defaultProvider, model: params.defaultModel }; @@ -226,4 +217,5 @@ export const statusSummaryRuntime = { resolveContextTokensForModel, classifySessionKey, resolveSessionModelRef, + resolveConfiguredStatusModelRef, }; diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 15ed07afc9f..9036272e0f2 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -7,6 +7,10 @@ vi.mock("../channels/config-presence.js", () => ({ vi.mock("./status.summary.runtime.js", () => ({ statusSummaryRuntime: { classifySessionKey: vi.fn(() => "direct"), + resolveConfiguredStatusModelRef: vi.fn(() => ({ + provider: "openai", + model: "gpt-5.2", + })), resolveSessionModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-5.2", diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 8911bee6caf..0045b55ebbf 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,6 +1,5 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { readSessionStoreReadOnly } from "../config/sessions/store-read.js"; @@ -38,78 +37,6 @@ function loadConfigIoModule() { return configIoModulePromise; } -function parseStatusModelRef( - raw: string, - defaultProvider: string, -): { provider: string; model: string } | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - const slash = trimmed.indexOf("/"); - if (slash === -1) { - return { provider: defaultProvider, model: trimmed }; - } - const provider = trimmed.slice(0, slash).trim(); - const model = trimmed.slice(slash + 1).trim(); - if (!provider || !model) { - return null; - } - return { provider, model }; -} - -function resolveConfiguredStatusModelRef(params: { - cfg: OpenClawConfig; - defaultProvider: string; - defaultModel: string; -}): { provider: string; model: string } { - const rawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model) ?? ""; - if (rawModel) { - const trimmed = rawModel.trim(); - const configuredModels = params.cfg.agents?.defaults?.models ?? {}; - if (!trimmed.includes("/")) { - const aliasKey = trimmed.toLowerCase(); - for (const [modelKey, entry] of Object.entries(configuredModels)) { - const aliasValue = (entry as { alias?: unknown } | undefined)?.alias; - const alias = typeof aliasValue === "string" ? aliasValue.trim() : ""; - if (!alias || alias.toLowerCase() !== aliasKey) { - continue; - } - const parsed = parseStatusModelRef(modelKey, params.defaultProvider); - if (parsed) { - return parsed; - } - } - return { provider: "anthropic", model: trimmed }; - } - const parsed = parseStatusModelRef(trimmed, params.defaultProvider); - if (parsed) { - return parsed; - } - } - - const configuredProviders = params.cfg.models?.providers; - if (configuredProviders && typeof configuredProviders === "object") { - const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]); - if (!hasDefaultProvider) { - const availableProvider = Object.entries(configuredProviders).find( - ([, providerCfg]) => - providerCfg && - Array.isArray(providerCfg.models) && - providerCfg.models.length > 0 && - providerCfg.models[0]?.id, - ); - if (availableProvider) { - const [providerName, providerCfg] = availableProvider; - const firstModel = providerCfg.models[0]; - return { provider: providerName, model: firstModel.id }; - } - } - } - - return { provider: params.defaultProvider, model: params.defaultModel }; -} - const buildFlags = (entry?: SessionEntry): string[] => { if (!entry) { return []; @@ -175,8 +102,12 @@ export async function getStatusSummary( } = {}, ): Promise { const { includeSensitive = true } = options; - const { classifySessionKey, resolveContextTokensForModel, resolveSessionModelRef } = - await loadStatusSummaryRuntimeModule(); + const { + classifySessionKey, + resolveConfiguredStatusModelRef, + resolveContextTokensForModel, + resolveSessionModelRef, + } = await loadStatusSummaryRuntimeModule(); const cfg = options.config ?? (await loadConfigIoModule()).loadConfig(); const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); const linkContext = needsChannelPlugins diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 3f72752fc78..e9daa581e2a 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1,6 +1,7 @@ import type { Mock } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; +import { createCompatibilityNotice } from "../plugins/status.test-helpers.js"; import { captureEnv } from "../test-utils/env.js"; let envSnapshot: ReturnType; @@ -139,6 +140,40 @@ function createDefaultProbeGatewayResult(): ProbeGatewayResult { }; } +function createDefaultSecurityAuditResult() { + return { + ts: 0, + summary: { critical: 1, warn: 1, info: 2 }, + findings: [ + { + checkId: "test.critical", + severity: "critical", + title: "Test critical finding", + detail: "Something is very wrong\nbut on two lines", + remediation: "Do the thing", + }, + { + checkId: "test.warn", + severity: "warn", + title: "Test warning finding", + detail: "Something is maybe wrong", + }, + { + checkId: "test.info", + severity: "info", + title: "Test info finding", + detail: "FYI only", + }, + { + checkId: "test.info2", + severity: "info", + title: "Another info finding", + detail: "More FYI", + }, + ], + }; +} + async function withEnvVar(key: string, value: string, run: () => Promise): Promise { const prevValue = process.env[key]; process.env[key] = value; @@ -175,37 +210,7 @@ const mocks = vi.hoisted(() => ({ scope: "per-sender", agents: [{ id: "main", name: "Main" }], }), - runSecurityAudit: vi.fn().mockResolvedValue({ - ts: 0, - summary: { critical: 1, warn: 1, info: 2 }, - findings: [ - { - checkId: "test.critical", - severity: "critical", - title: "Test critical finding", - detail: "Something is very wrong\nbut on two lines", - remediation: "Do the thing", - }, - { - checkId: "test.warn", - severity: "warn", - title: "Test warning finding", - detail: "Something is maybe wrong", - }, - { - checkId: "test.info", - severity: "info", - title: "Test info finding", - detail: "FYI only", - }, - { - checkId: "test.info2", - severity: "info", - title: "Another info finding", - detail: "More FYI", - }, - ], - }), + runSecurityAudit: vi.fn().mockResolvedValue(createDefaultSecurityAuditResult()), buildPluginCompatibilityNotices: vi.fn((): PluginCompatibilityNotice[] => []), })); @@ -471,37 +476,7 @@ describe("statusCommand", () => { mocks.hasPotentialConfiguredChannels.mockReset(); mocks.hasPotentialConfiguredChannels.mockReturnValue(true); mocks.runSecurityAudit.mockReset(); - mocks.runSecurityAudit.mockResolvedValue({ - ts: 0, - summary: { critical: 1, warn: 1, info: 2 }, - findings: [ - { - checkId: "test.critical", - severity: "critical", - title: "Test critical finding", - detail: "Something is very wrong\nbut on two lines", - remediation: "Do the thing", - }, - { - checkId: "test.warn", - severity: "warn", - title: "Test warning finding", - detail: "Something is maybe wrong", - }, - { - checkId: "test.info", - severity: "info", - title: "Test info finding", - detail: "FYI only", - }, - { - checkId: "test.info2", - severity: "info", - title: "Another info finding", - detail: "More FYI", - }, - ], - }); + mocks.runSecurityAudit.mockResolvedValue(createDefaultSecurityAuditResult()); runtimeLogMock.mockClear(); (runtime.error as Mock<(...args: unknown[]) => void>).mockClear(); }); @@ -509,13 +484,7 @@ describe("statusCommand", () => { it("prints JSON when requested", async () => { mocks.hasPotentialConfiguredChannels.mockReturnValue(false); mocks.buildPluginCompatibilityNotices.mockReturnValue([ - { - pluginId: "legacy-plugin", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }, + createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), ]); await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); @@ -570,13 +539,7 @@ describe("statusCommand", () => { it("prints formatted lines otherwise", async () => { mocks.buildPluginCompatibilityNotices.mockReturnValue([ - { - pluginId: "legacy-plugin", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }, + createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), ]); const logs = await runStatusAndGetLogs(); for (const token of [ diff --git a/src/config/version.ts b/src/config/version.ts index 2febecc0835..9e7855588b0 100644 --- a/src/config/version.ts +++ b/src/config/version.ts @@ -1,3 +1,8 @@ +import { + comparePrereleaseIdentifiers, + normalizeLegacyDotBetaVersion, +} from "../infra/semver-compare.js"; + export type OpenClawVersion = { major: number; minor: number; @@ -89,7 +94,7 @@ export function compareOpenClawVersions( } if (parsedA.prerelease || parsedB.prerelease) { - return comparePrerelease(parsedA.prerelease, parsedB.prerelease); + return comparePrereleaseIdentifiers(parsedA.prerelease, parsedB.prerelease); } return 0; @@ -105,15 +110,6 @@ export function shouldWarnOnTouchedVersion( const cmp = compareOpenClawVersions(current, touched); return cmp !== null && cmp < 0; } -function normalizeLegacyDotBetaVersion(version: string): string { - const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(version); - if (!dotBetaMatch) { - return version; - } - const base = dotBetaMatch[1]; - const suffix = dotBetaMatch[2]; - return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; -} function releaseRank(version: OpenClawVersion): number { if (version.prerelease?.length) { @@ -124,50 +120,3 @@ function releaseRank(version: OpenClawVersion): number { } return 1; } - -function comparePrerelease(a: string[] | null, b: string[] | null): number { - if (!a?.length && !b?.length) { - return 0; - } - if (!a?.length) { - return 1; - } - if (!b?.length) { - return -1; - } - - const max = Math.max(a.length, b.length); - for (let i = 0; i < max; i += 1) { - const ai = a[i]; - const bi = b[i]; - if (ai == null && bi == null) { - return 0; - } - if (ai == null) { - return -1; - } - if (bi == null) { - return 1; - } - if (ai === bi) { - continue; - } - - const aiNumeric = /^[0-9]+$/.test(ai); - const biNumeric = /^[0-9]+$/.test(bi); - if (aiNumeric && biNumeric) { - const aiNum = Number.parseInt(ai, 10); - const biNum = Number.parseInt(bi, 10); - return aiNum < biNum ? -1 : 1; - } - if (aiNumeric && !biNumeric) { - return -1; - } - if (!aiNumeric && biNumeric) { - return 1; - } - return ai < bi ? -1 : 1; - } - - return 0; -} diff --git a/src/gateway/session-archive.fs.ts b/src/gateway/session-archive.fs.ts index 214748730af..15f5171b896 100644 --- a/src/gateway/session-archive.fs.ts +++ b/src/gateway/session-archive.fs.ts @@ -1,154 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { - formatSessionArchiveTimestamp, - parseSessionArchiveTimestamp, - type SessionArchiveReason, -} from "../config/sessions/artifacts.js"; -import { - resolveSessionFilePath, - resolveSessionTranscriptPath, - resolveSessionTranscriptPathInDir, -} from "../config/sessions/paths.js"; -import { resolveRequiredHomeDir } from "../infra/home-dir.js"; - -export type ArchiveFileReason = SessionArchiveReason; - -function canonicalizePathForComparison(filePath: string): string { - const resolved = path.resolve(filePath); - try { - return fs.realpathSync(resolved); - } catch { - return resolved; - } -} - -function resolveSessionTranscriptCandidates( - sessionId: string, - storePath: string | undefined, - sessionFile?: string, - agentId?: string, -): string[] { - const candidates: string[] = []; - const pushCandidate = (resolve: () => string): void => { - try { - candidates.push(resolve()); - } catch { - // Ignore invalid paths/IDs and keep scanning other safe candidates. - } - }; - - if (storePath) { - const sessionsDir = path.dirname(storePath); - if (sessionFile) { - pushCandidate(() => - resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), - ); - } - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); - } else if (sessionFile) { - if (agentId) { - pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); - } else { - const trimmed = sessionFile.trim(); - if (trimmed) { - candidates.push(path.resolve(trimmed)); - } - } - } - - if (agentId) { - pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); - } - - const home = resolveRequiredHomeDir(process.env, os.homedir); - const legacyDir = path.join(home, ".openclaw", "sessions"); - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); - - return Array.from(new Set(candidates)); -} - -export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { - const ts = formatSessionArchiveTimestamp(); - const archived = `${filePath}.${reason}.${ts}`; - fs.renameSync(filePath, archived); - return archived; -} - -export function archiveSessionTranscripts(opts: { - sessionId: string; - storePath: string | undefined; - sessionFile?: string; - agentId?: string; - reason: "reset" | "deleted"; - restrictToStoreDir?: boolean; -}): string[] { - const archived: string[] = []; - const storeDir = - opts.restrictToStoreDir && opts.storePath - ? canonicalizePathForComparison(path.dirname(opts.storePath)) - : null; - for (const candidate of resolveSessionTranscriptCandidates( - opts.sessionId, - opts.storePath, - opts.sessionFile, - opts.agentId, - )) { - const candidatePath = canonicalizePathForComparison(candidate); - if (storeDir) { - const relative = path.relative(storeDir, candidatePath); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { - continue; - } - } - if (!fs.existsSync(candidatePath)) { - continue; - } - try { - archived.push(archiveFileOnDisk(candidatePath, opts.reason)); - } catch { - // Best-effort. - } - } - return archived; -} - -export async function cleanupArchivedSessionTranscripts(opts: { - directories: string[]; - olderThanMs: number; - reason?: ArchiveFileReason; - nowMs?: number; -}): Promise<{ removed: number; scanned: number }> { - if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) { - return { removed: 0, scanned: 0 }; - } - const now = opts.nowMs ?? Date.now(); - const reason: ArchiveFileReason = opts.reason ?? "deleted"; - const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir)))); - let removed = 0; - let scanned = 0; - - for (const dir of directories) { - const entries = await fs.promises.readdir(dir).catch(() => []); - for (const entry of entries) { - const timestamp = parseSessionArchiveTimestamp(entry, reason); - if (timestamp == null) { - continue; - } - scanned += 1; - if (now - timestamp <= opts.olderThanMs) { - continue; - } - const fullPath = path.join(dir, entry); - const stat = await fs.promises.stat(fullPath).catch(() => null); - if (!stat?.isFile()) { - continue; - } - await fs.promises.rm(fullPath).catch(() => undefined); - removed += 1; - } - } - - return { removed, scanned }; -} +export { + archiveFileOnDisk, + archiveSessionTranscripts, + cleanupArchivedSessionTranscripts, +} from "./session-transcript-files.fs.js"; diff --git a/src/gateway/session-kill-http.ts b/src/gateway/session-kill-http.ts index 04d411ffd9b..8e255b4e387 100644 --- a/src/gateway/session-kill-http.ts +++ b/src/gateway/session-kill-http.ts @@ -7,12 +7,9 @@ import { import { getSubagentRunByChildSessionKey } from "../agents/subagent-registry.js"; import { loadConfig } from "../config/config.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { - authorizeHttpGatewayConnect, - isLocalDirectRequest, - type ResolvedGatewayAuth, -} from "./auth.js"; -import { sendGatewayAuthFailure, sendJson, sendMethodNotAllowed } from "./http-common.js"; +import { isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; +import { sendJson, sendMethodNotAllowed } from "./http-common.js"; import { getBearerToken } from "./http-utils.js"; import { ADMIN_SCOPE, WRITE_SCOPE, authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { loadSessionEntry } from "./session-utils.js"; @@ -69,16 +66,15 @@ export async function handleSessionKillHttpRequest( } const token = getBearerToken(req); - const authResult = await authorizeHttpGatewayConnect({ - auth: opts.auth, - connectAuth: token ? { token, password: token } : null, + const ok = await authorizeGatewayBearerRequestOrReply({ req, + res, + auth: opts.auth, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); + if (!ok) { return true; } @@ -98,7 +94,7 @@ export async function handleSessionKillHttpRequest( const allowRealIpFallback = opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback; const requesterSessionKey = req.headers[REQUESTER_SESSION_KEY_HEADER]?.toString().trim(); const allowLocalAdminKill = isLocalDirectRequest(req, trustedProxies, allowRealIpFallback); - const allowBearerOperatorKill = canBearerTokenKillSessions(token, authResult.ok); + const allowBearerOperatorKill = canBearerTokenKillSessions(token, true); if (!requesterSessionKey && !allowLocalAdminKill && !allowBearerOperatorKill) { sendJson(res, 403, { diff --git a/src/gateway/session-transcript-files.fs.ts b/src/gateway/session-transcript-files.fs.ts new file mode 100644 index 00000000000..5df99089792 --- /dev/null +++ b/src/gateway/session-transcript-files.fs.ts @@ -0,0 +1,206 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + formatSessionArchiveTimestamp, + parseSessionArchiveTimestamp, + type SessionArchiveReason, + resolveSessionFilePath, + resolveSessionTranscriptPath, + resolveSessionTranscriptPathInDir, +} from "../config/sessions.js"; +import { resolveRequiredHomeDir } from "../infra/home-dir.js"; + +export type ArchiveFileReason = SessionArchiveReason; + +function classifySessionTranscriptCandidate( + sessionId: string, + sessionFile?: string, +): "current" | "stale" | "custom" { + const transcriptSessionId = extractGeneratedTranscriptSessionId(sessionFile); + if (!transcriptSessionId) { + return "custom"; + } + return transcriptSessionId === sessionId ? "current" : "stale"; +} + +function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined { + const trimmed = sessionFile?.trim(); + if (!trimmed) { + return undefined; + } + const base = path.basename(trimmed); + if (!base.endsWith(".jsonl")) { + return undefined; + } + const withoutExt = base.slice(0, -".jsonl".length); + const topicIndex = withoutExt.indexOf("-topic-"); + if (topicIndex > 0) { + const topicSessionId = withoutExt.slice(0, topicIndex); + return looksLikeGeneratedSessionId(topicSessionId) ? topicSessionId : undefined; + } + const forkMatch = withoutExt.match( + /^(\d{4}-\d{2}-\d{2}T[\w-]+(?:Z|[+-]\d{2}(?:-\d{2})?)?)_(.+)$/, + ); + if (forkMatch?.[2]) { + return looksLikeGeneratedSessionId(forkMatch[2]) ? forkMatch[2] : undefined; + } + return looksLikeGeneratedSessionId(withoutExt) ? withoutExt : undefined; +} + +function looksLikeGeneratedSessionId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function canonicalizePathForComparison(filePath: string): string { + const resolved = path.resolve(filePath); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +export function resolveSessionTranscriptCandidates( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, + agentId?: string, +): string[] { + const candidates: string[] = []; + const sessionFileState = classifySessionTranscriptCandidate(sessionId, sessionFile); + const pushCandidate = (resolve: () => string): void => { + try { + candidates.push(resolve()); + } catch { + // Ignore invalid paths/IDs and keep scanning other safe candidates. + } + }; + + if (storePath) { + const sessionsDir = path.dirname(storePath); + if (sessionFile && sessionFileState !== "stale") { + pushCandidate(() => + resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), + ); + } + pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); + if (sessionFile && sessionFileState === "stale") { + pushCandidate(() => + resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), + ); + } + } else if (sessionFile) { + if (agentId) { + if (sessionFileState !== "stale") { + pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); + } + } else { + const trimmed = sessionFile.trim(); + if (trimmed) { + candidates.push(path.resolve(trimmed)); + } + } + } + + if (agentId) { + pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); + if (sessionFile && sessionFileState === "stale") { + pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); + } + } + + const home = resolveRequiredHomeDir(process.env, os.homedir); + const legacyDir = path.join(home, ".openclaw", "sessions"); + pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); + + return Array.from(new Set(candidates)); +} + +export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { + const ts = formatSessionArchiveTimestamp(); + const archived = `${filePath}.${reason}.${ts}`; + fs.renameSync(filePath, archived); + return archived; +} + +export function archiveSessionTranscripts(opts: { + sessionId: string; + storePath: string | undefined; + sessionFile?: string; + agentId?: string; + reason: "reset" | "deleted"; + /** + * When true, only archive files resolved under the session store directory. + * This prevents maintenance operations from mutating paths outside the agent sessions dir. + */ + restrictToStoreDir?: boolean; +}): string[] { + const archived: string[] = []; + const storeDir = + opts.restrictToStoreDir && opts.storePath + ? canonicalizePathForComparison(path.dirname(opts.storePath)) + : null; + for (const candidate of resolveSessionTranscriptCandidates( + opts.sessionId, + opts.storePath, + opts.sessionFile, + opts.agentId, + )) { + const candidatePath = canonicalizePathForComparison(candidate); + if (storeDir) { + const relative = path.relative(storeDir, candidatePath); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + continue; + } + } + if (!fs.existsSync(candidatePath)) { + continue; + } + try { + archived.push(archiveFileOnDisk(candidatePath, opts.reason)); + } catch { + // Best-effort. + } + } + return archived; +} + +export async function cleanupArchivedSessionTranscripts(opts: { + directories: string[]; + olderThanMs: number; + reason?: ArchiveFileReason; + nowMs?: number; +}): Promise<{ removed: number; scanned: number }> { + if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) { + return { removed: 0, scanned: 0 }; + } + const now = opts.nowMs ?? Date.now(); + const reason: ArchiveFileReason = opts.reason ?? "deleted"; + const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir)))); + let removed = 0; + let scanned = 0; + + for (const dir of directories) { + const entries = await fs.promises.readdir(dir).catch(() => []); + for (const entry of entries) { + const timestamp = parseSessionArchiveTimestamp(entry, reason); + if (timestamp == null) { + continue; + } + scanned += 1; + if (now - timestamp <= opts.olderThanMs) { + continue; + } + const fullPath = path.join(dir, entry); + const stat = await fs.promises.stat(fullPath).catch(() => null); + if (!stat?.isFile()) { + continue; + } + await fs.promises.rm(fullPath).catch(() => undefined); + removed += 1; + } + } + + return { removed, scanned }; +} diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 9aa22caf461..8482d1da7ac 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -1,21 +1,16 @@ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { deriveSessionTotalTokens, hasNonzeroUsage, normalizeUsage } from "../agents/usage.js"; -import { - formatSessionArchiveTimestamp, - parseSessionArchiveTimestamp, - type SessionArchiveReason, - resolveSessionFilePath, - resolveSessionTranscriptPath, - resolveSessionTranscriptPathInDir, -} from "../config/sessions.js"; -import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; +import { + resolveSessionTranscriptCandidates, + archiveFileOnDisk, + archiveSessionTranscripts, + cleanupArchivedSessionTranscripts, +} from "./session-transcript-files.fs.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; type SessionTitleFields = { @@ -149,206 +144,12 @@ export function readSessionMessages( return messages; } -export function resolveSessionTranscriptCandidates( - sessionId: string, - storePath: string | undefined, - sessionFile?: string, - agentId?: string, -): string[] { - const candidates: string[] = []; - const sessionFileState = classifySessionTranscriptCandidate(sessionId, sessionFile); - const pushCandidate = (resolve: () => string): void => { - try { - candidates.push(resolve()); - } catch { - // Ignore invalid paths/IDs and keep scanning other safe candidates. - } - }; - - if (storePath) { - const sessionsDir = path.dirname(storePath); - if (sessionFile && sessionFileState !== "stale") { - pushCandidate(() => - resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), - ); - } - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); - if (sessionFile && sessionFileState === "stale") { - pushCandidate(() => - resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), - ); - } - } else if (sessionFile) { - if (agentId) { - if (sessionFileState !== "stale") { - pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); - } - } else { - const trimmed = sessionFile.trim(); - if (trimmed) { - candidates.push(path.resolve(trimmed)); - } - } - } - - if (agentId) { - pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); - if (sessionFile && sessionFileState === "stale") { - pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); - } - } - - const home = resolveRequiredHomeDir(process.env, os.homedir); - const legacyDir = path.join(home, ".openclaw", "sessions"); - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); - - return Array.from(new Set(candidates)); -} - -export type ArchiveFileReason = SessionArchiveReason; - -function classifySessionTranscriptCandidate( - sessionId: string, - sessionFile?: string, -): "current" | "stale" | "custom" { - const transcriptSessionId = extractGeneratedTranscriptSessionId(sessionFile); - if (!transcriptSessionId) { - return "custom"; - } - return transcriptSessionId === sessionId ? "current" : "stale"; -} - -function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined { - const trimmed = sessionFile?.trim(); - if (!trimmed) { - return undefined; - } - const base = path.basename(trimmed); - if (!base.endsWith(".jsonl")) { - return undefined; - } - const withoutExt = base.slice(0, -".jsonl".length); - const topicIndex = withoutExt.indexOf("-topic-"); - if (topicIndex > 0) { - const topicSessionId = withoutExt.slice(0, topicIndex); - return looksLikeGeneratedSessionId(topicSessionId) ? topicSessionId : undefined; - } - const forkMatch = withoutExt.match( - /^(\d{4}-\d{2}-\d{2}T[\w-]+(?:Z|[+-]\d{2}(?:-\d{2})?)?)_(.+)$/, - ); - if (forkMatch?.[2]) { - return looksLikeGeneratedSessionId(forkMatch[2]) ? forkMatch[2] : undefined; - } - if (looksLikeGeneratedSessionId(withoutExt)) { - return withoutExt; - } - return undefined; -} - -function looksLikeGeneratedSessionId(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); -} - -function canonicalizePathForComparison(filePath: string): string { - const resolved = path.resolve(filePath); - try { - return fs.realpathSync(resolved); - } catch { - return resolved; - } -} - -export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { - const ts = formatSessionArchiveTimestamp(); - const archived = `${filePath}.${reason}.${ts}`; - fs.renameSync(filePath, archived); - return archived; -} - -/** - * Archives all transcript files for a given session. - * Best-effort: silently skips files that don't exist or fail to rename. - */ -export function archiveSessionTranscripts(opts: { - sessionId: string; - storePath: string | undefined; - sessionFile?: string; - agentId?: string; - reason: "reset" | "deleted"; - /** - * When true, only archive files resolved under the session store directory. - * This prevents maintenance operations from mutating paths outside the agent sessions dir. - */ - restrictToStoreDir?: boolean; -}): string[] { - const archived: string[] = []; - const storeDir = - opts.restrictToStoreDir && opts.storePath - ? canonicalizePathForComparison(path.dirname(opts.storePath)) - : null; - for (const candidate of resolveSessionTranscriptCandidates( - opts.sessionId, - opts.storePath, - opts.sessionFile, - opts.agentId, - )) { - const candidatePath = canonicalizePathForComparison(candidate); - if (storeDir) { - const relative = path.relative(storeDir, candidatePath); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { - continue; - } - } - if (!fs.existsSync(candidatePath)) { - continue; - } - try { - archived.push(archiveFileOnDisk(candidatePath, opts.reason)); - } catch { - // Best-effort. - } - } - return archived; -} - -export async function cleanupArchivedSessionTranscripts(opts: { - directories: string[]; - olderThanMs: number; - reason?: ArchiveFileReason; - nowMs?: number; -}): Promise<{ removed: number; scanned: number }> { - if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) { - return { removed: 0, scanned: 0 }; - } - const now = opts.nowMs ?? Date.now(); - const reason: ArchiveFileReason = opts.reason ?? "deleted"; - const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir)))); - let removed = 0; - let scanned = 0; - - for (const dir of directories) { - const entries = await fs.promises.readdir(dir).catch(() => []); - for (const entry of entries) { - const timestamp = parseSessionArchiveTimestamp(entry, reason); - if (timestamp == null) { - continue; - } - scanned += 1; - if (now - timestamp <= opts.olderThanMs) { - continue; - } - const fullPath = path.join(dir, entry); - const stat = await fs.promises.stat(fullPath).catch(() => null); - if (!stat?.isFile()) { - continue; - } - await fs.promises.rm(fullPath).catch(() => undefined); - removed += 1; - } - } - - return { removed, scanned }; -} +export { + archiveFileOnDisk, + archiveSessionTranscripts, + cleanupArchivedSessionTranscripts, + resolveSessionTranscriptCandidates, +} from "./session-transcript-files.fs.js"; export function capArrayByJsonBytes( items: T[], diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 8e7f060c824..a5a4cb33e7f 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -5,15 +5,15 @@ import { loadConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; import { - sendGatewayAuthFailure, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, } from "./http-common.js"; -import { getBearerToken, getHeader } from "./http-utils.js"; +import { getHeader } from "./http-utils.js"; import { attachOpenClawTranscriptMeta, readSessionMessages, @@ -154,17 +154,15 @@ export async function handleSessionHistoryHttpRequest( } const cfg = loadConfig(); - const token = getBearerToken(req); - const authResult = await authorizeHttpGatewayConnect({ - auth: opts.auth, - connectAuth: token ? { token, password: token } : null, + const ok = await authorizeGatewayBearerRequestOrReply({ req, + res, + auth: opts.auth, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); + if (!ok) { return true; } diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 531a2d2d043..7f41104ba4f 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -322,6 +322,34 @@ export const testIsNixMode = hoisted.testIsNixMode; export const sessionStoreSaveDelayMs = hoisted.sessionStoreSaveDelayMs; export const embeddedRunMock = hoisted.embeddedRunMock; +function createEmbeddedRunMockExports() { + return { + isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), + abortEmbeddedPiRun: (sessionId: string) => { + embeddedRunMock.abortCalls.push(sessionId); + return embeddedRunMock.activeIds.has(sessionId); + }, + waitForEmbeddedPiRunEnd: async (sessionId: string) => { + embeddedRunMock.waitCalls.push(sessionId); + return embeddedRunMock.waitResults.get(sessionId) ?? true; + }, + }; +} + +async function importEmbeddedRunMockModule( + actualPath: string, + opts?: { includeActiveCount?: boolean }, +): Promise { + const actual = await vi.importActual(actualPath); + return { + ...actual, + ...createEmbeddedRunMockExports(), + ...(opts?.includeActiveCount + ? { getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size } + : {}), + }; +} + vi.mock("../agents/pi-model-discovery.js", async () => { const actual = await vi.importActual( "../agents/pi-model-discovery.js", @@ -633,77 +661,29 @@ vi.mock("../config/config.js", async () => { }); vi.mock("../agents/pi-embedded.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded.js", ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - }; }); vi.mock("/src/agents/pi-embedded.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded.js", ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - }; }); vi.mock("../agents/pi-embedded-runner/runs.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded-runner/runs.js", + { includeActiveCount: true }, ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size, - }; }); vi.mock("/src/agents/pi-embedded-runner/runs.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded-runner/runs.js", + { includeActiveCount: true }, ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size, - }; }); vi.mock("../commands/health.js", () => ({ diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 80b6dc37733..a67aa3b38a3 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -26,15 +26,15 @@ import { isSubagentSessionKey } from "../routing/session-key.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; import { readJsonBodyOrError, - sendGatewayAuthFailure, sendInvalidRequest, sendJson, sendMethodNotAllowed, } from "./http-common.js"; -import { getBearerToken, getHeader } from "./http-utils.js"; +import { getHeader } from "./http-utils.js"; const DEFAULT_BODY_BYTES = 2 * 1024 * 1024; const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]); @@ -155,17 +155,15 @@ export async function handleToolsInvokeHttpRequest( } const cfg = loadConfig(); - const token = getBearerToken(req); - const authResult = await authorizeHttpGatewayConnect({ - auth: opts.auth, - connectAuth: token ? { token, password: token } : null, + const ok = await authorizeGatewayBearerRequestOrReply({ req, + res, + auth: opts.auth, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); + if (!ok) { return true; } diff --git a/src/image-generation/model-ref.ts b/src/image-generation/model-ref.ts new file mode 100644 index 00000000000..d76b230996f --- /dev/null +++ b/src/image-generation/model-ref.ts @@ -0,0 +1,16 @@ +export function parseImageGenerationModelRef( + raw: string | undefined, +): { provider: string; model: string } | null { + const trimmed = raw?.trim(); + if (!trimmed) { + return null; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { + return null; + } + return { + provider: trimmed.slice(0, slashIndex).trim(), + model: trimmed.slice(slashIndex + 1).trim(), + }; +} diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 60ff86f24ce..5b273988966 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -8,6 +8,7 @@ import { } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; +import { parseImageGenerationModelRef } from "./model-ref.js"; import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js"; import type { GeneratedImageAsset, @@ -39,21 +40,6 @@ export type GenerateImageRuntimeResult = { metadata?: Record; }; -function parseModelRef(raw: string | undefined): { provider: string; model: string } | null { - const trimmed = raw?.trim(); - if (!trimmed) { - return null; - } - const slashIndex = trimmed.indexOf("/"); - if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { - return null; - } - return { - provider: trimmed.slice(0, slashIndex).trim(), - model: trimmed.slice(slashIndex + 1).trim(), - }; -} - function resolveImageGenerationCandidates(params: { cfg: OpenClawConfig; modelOverride?: string; @@ -61,7 +47,7 @@ function resolveImageGenerationCandidates(params: { const candidates: Array<{ provider: string; model: string }> = []; const seen = new Set(); const add = (raw: string | undefined) => { - const parsed = parseModelRef(raw); + const parsed = parseImageGenerationModelRef(raw); if (!parsed) { return; } diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index f588b6c0c7f..902c561f81b 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { isAtLeast, parseSemver } from "./runtime-guard.js"; +import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; const DEFAULT_CLAWHUB_URL = "https://clawhub.ai"; const DEFAULT_FETCH_TIMEOUT_MS = 30_000; @@ -160,13 +161,6 @@ export type ClawHubDownloadResult = { type FetchLike = typeof fetch; -type ComparableSemver = { - major: number; - minor: number; - patch: number; - prerelease: string[] | null; -}; - type ClawHubRequestParams = { baseUrl?: string; path: string; @@ -278,90 +272,8 @@ export async function resolveClawHubAuthToken(): Promise { return undefined; } -function parseComparableSemver(version: string | null | undefined): ComparableSemver | null { - if (!version) { - return null; - } - const normalized = version.trim(); - const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( - normalized, - ); - if (!match) { - return null; - } - const [, major, minor, patch, prereleaseRaw] = match; - if (!major || !minor || !patch) { - return null; - } - return { - major: Number.parseInt(major, 10), - minor: Number.parseInt(minor, 10), - patch: Number.parseInt(patch, 10), - prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, - }; -} - -function comparePrerelease(a: string[] | null, b: string[] | null): number { - if (!a?.length && !b?.length) { - return 0; - } - if (!a?.length) { - return 1; - } - if (!b?.length) { - return -1; - } - - const max = Math.max(a.length, b.length); - for (let i = 0; i < max; i += 1) { - const ai = a[i]; - const bi = b[i]; - if (ai == null && bi == null) { - return 0; - } - if (ai == null) { - return -1; - } - if (bi == null) { - return 1; - } - const aNum = /^[0-9]+$/.test(ai) ? Number.parseInt(ai, 10) : null; - const bNum = /^[0-9]+$/.test(bi) ? Number.parseInt(bi, 10) : null; - if (aNum != null && bNum != null) { - if (aNum !== bNum) { - return aNum < bNum ? -1 : 1; - } - continue; - } - if (aNum != null) { - return -1; - } - if (bNum != null) { - return 1; - } - if (ai !== bi) { - return ai < bi ? -1 : 1; - } - } - return 0; -} - function compareSemver(left: string, right: string): number | null { - const a = parseComparableSemver(left); - const b = parseComparableSemver(right); - if (!a || !b) { - return null; - } - if (a.major !== b.major) { - return a.major < b.major ? -1 : 1; - } - if (a.minor !== b.minor) { - return a.minor < b.minor ? -1 : 1; - } - if (a.patch !== b.patch) { - return a.patch < b.patch ? -1 : 1; - } - return comparePrerelease(a.prerelease, b.prerelease); + return compareComparableSemver(parseComparableSemver(left), parseComparableSemver(right)); } function upperBoundForCaret(version: string): string | null { diff --git a/src/infra/detect-binary.ts b/src/infra/detect-binary.ts new file mode 100644 index 00000000000..94b47cbbe44 --- /dev/null +++ b/src/infra/detect-binary.ts @@ -0,0 +1,36 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { resolveUserPath } from "../utils.js"; +import { isSafeExecutableValue } from "./exec-safety.js"; + +export async function detectBinary(name: string): Promise { + if (!name?.trim()) { + return false; + } + if (!isSafeExecutableValue(name)) { + return false; + } + const resolved = name.startsWith("~") ? resolveUserPath(name) : name; + if ( + path.isAbsolute(resolved) || + resolved.startsWith(".") || + resolved.includes("/") || + resolved.includes("\\") + ) { + try { + await fs.access(resolved); + return true; + } catch { + return false; + } + } + + const command = process.platform === "win32" ? ["where", name] : ["/usr/bin/env", "which", name]; + try { + const result = await runCommandWithTimeout(command, { timeoutMs: 2000 }); + return result.code === 0 && result.stdout.trim().length > 0; + } catch { + return false; + } +} diff --git a/src/infra/semver-compare.ts b/src/infra/semver-compare.ts new file mode 100644 index 00000000000..ab8f44c400c --- /dev/null +++ b/src/infra/semver-compare.ts @@ -0,0 +1,111 @@ +export type ComparableSemver = { + major: number; + minor: number; + patch: number; + prerelease: string[] | null; +}; + +export function normalizeLegacyDotBetaVersion(version: string): string { + const trimmed = version.trim(); + const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed); + if (!dotBetaMatch) { + return trimmed; + } + const base = dotBetaMatch[1]; + const suffix = dotBetaMatch[2]; + return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; +} + +export function parseComparableSemver( + version: string | null | undefined, + options?: { normalizeLegacyDotBeta?: boolean }, +): ComparableSemver | null { + if (!version) { + return null; + } + const normalized = options?.normalizeLegacyDotBeta + ? normalizeLegacyDotBetaVersion(version) + : version.trim(); + const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + normalized, + ); + if (!match) { + return null; + } + const [, major, minor, patch, prereleaseRaw] = match; + if (!major || !minor || !patch) { + return null; + } + return { + major: Number.parseInt(major, 10), + minor: Number.parseInt(minor, 10), + patch: Number.parseInt(patch, 10), + prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, + }; +} + +export function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | null): number { + if (!a?.length && !b?.length) { + return 0; + } + if (!a?.length) { + return 1; + } + if (!b?.length) { + return -1; + } + + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i += 1) { + const ai = a[i]; + const bi = b[i]; + if (ai == null && bi == null) { + return 0; + } + if (ai == null) { + return -1; + } + if (bi == null) { + return 1; + } + if (ai === bi) { + continue; + } + + const aiNumeric = /^[0-9]+$/.test(ai); + const biNumeric = /^[0-9]+$/.test(bi); + if (aiNumeric && biNumeric) { + const aiNum = Number.parseInt(ai, 10); + const biNum = Number.parseInt(bi, 10); + return aiNum < biNum ? -1 : 1; + } + if (aiNumeric && !biNumeric) { + return -1; + } + if (!aiNumeric && biNumeric) { + return 1; + } + return ai < bi ? -1 : 1; + } + + return 0; +} + +export function compareComparableSemver( + a: ComparableSemver | null, + b: ComparableSemver | null, +): number | null { + if (!a || !b) { + return null; + } + if (a.major !== b.major) { + return a.major < b.major ? -1 : 1; + } + if (a.minor !== b.minor) { + return a.minor < b.minor ? -1 : 1; + } + if (a.patch !== b.patch) { + return a.patch < b.patch ? -1 : 1; + } + return comparePrereleaseIdentifiers(a.prerelease, b.prerelease); +} diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 33ff9076947..1ea07fd4c47 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; +import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; import { channelToNpmTag, type UpdateChannel } from "./update-channels.js"; export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; @@ -342,109 +343,10 @@ export async function resolveNpmChannelTag(params: { } export function compareSemverStrings(a: string | null, b: string | null): number | null { - const pa = parseComparableSemver(a); - const pb = parseComparableSemver(b); - if (!pa || !pb) { - return null; - } - if (pa.major !== pb.major) { - return pa.major < pb.major ? -1 : 1; - } - if (pa.minor !== pb.minor) { - return pa.minor < pb.minor ? -1 : 1; - } - if (pa.patch !== pb.patch) { - return pa.patch < pb.patch ? -1 : 1; - } - return comparePrerelease(pa.prerelease, pb.prerelease); -} - -type ComparableSemver = { - major: number; - minor: number; - patch: number; - prerelease: string[] | null; -}; - -function parseComparableSemver(version: string | null): ComparableSemver | null { - if (!version) { - return null; - } - const normalized = normalizeLegacyDotBetaVersion(version.trim()); - const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( - normalized, + return compareComparableSemver( + parseComparableSemver(a, { normalizeLegacyDotBeta: true }), + parseComparableSemver(b, { normalizeLegacyDotBeta: true }), ); - if (!match) { - return null; - } - const [, major, minor, patch, prereleaseRaw] = match; - if (!major || !minor || !patch) { - return null; - } - return { - major: Number.parseInt(major, 10), - minor: Number.parseInt(minor, 10), - patch: Number.parseInt(patch, 10), - prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, - }; -} - -function normalizeLegacyDotBetaVersion(version: string): string { - const trimmed = version.trim(); - const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed); - if (!dotBetaMatch) { - return trimmed; - } - const base = dotBetaMatch[1]; - const suffix = dotBetaMatch[2]; - return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; -} - -function comparePrerelease(a: string[] | null, b: string[] | null): number { - if (!a?.length && !b?.length) { - return 0; - } - if (!a?.length) { - return 1; - } - if (!b?.length) { - return -1; - } - - const max = Math.max(a.length, b.length); - for (let i = 0; i < max; i += 1) { - const ai = a[i]; - const bi = b[i]; - if (ai == null && bi == null) { - return 0; - } - if (ai == null) { - return -1; - } - if (bi == null) { - return 1; - } - if (ai === bi) { - continue; - } - - const aiNumeric = /^[0-9]+$/.test(ai); - const biNumeric = /^[0-9]+$/.test(bi); - if (aiNumeric && biNumeric) { - const aiNum = Number.parseInt(ai, 10); - const biNum = Number.parseInt(bi, 10); - return aiNum < biNum ? -1 : 1; - } - if (aiNumeric && !biNumeric) { - return -1; - } - if (!aiNumeric && biNumeric) { - return 1; - } - return ai < bi ? -1 : 1; - } - - return 0; } export async function checkUpdateStatus(params: { diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 9571bd15ee6..ccec4815f39 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -34,6 +34,51 @@ export { }; export type { ConfigWriteAuthorizationResult, ConfigWriteScope, ConfigWriteTarget }; +type ChannelCrudConfigAdapter = Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +>; + +type ChannelConfigAdapterWithAccessors = Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" + | "resolveAllowFrom" + | "formatAllowFrom" + | "resolveDefaultTo" +>; + +type ChannelConfigAccessorParams = { + cfg: Config; + accountId?: string | null; +}; + +type MultiAccountChannelConfigAdapterParams< + ResolvedAccount, + AccessorAccount = ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +> = { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + resolveAccessorAccount?: (params: ChannelConfigAccessorParams) => AccessorAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}; + /** Coerce mixed allowlist config values into plain strings without trimming or deduping. */ export function mapAllowFromEntries( allowFrom: Array | null | undefined, @@ -99,6 +144,102 @@ export function createScopedAccountConfigAccessors< }; } +function createNamedAccountConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + defaultAccountId: (cfg: Config) => string; + setAccountEnabled: (params: { + cfg: OpenClawConfig; + accountId: string; + enabled: boolean; + }) => OpenClawConfig; + deleteAccount: (params: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig; +}): ChannelCrudConfigAdapter { + return { + listAccountIds: (cfg) => params.listAccountIds(cfg as Config), + resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), + inspectAccount: params.inspectAccount + ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + setAccountEnabled: ({ cfg, accountId, enabled }) => + params.setAccountEnabled({ + cfg, + accountId: normalizeAccountId(accountId), + enabled, + }) as Config, + deleteAccount: ({ cfg, accountId }) => + params.deleteAccount({ + cfg, + accountId: normalizeAccountId(accountId), + }) as Config, + }; +} + +function resolveAccessorAccountWithFallback< + AccessorAccount, + Config extends OpenClawConfig = OpenClawConfig, +>( + resolveAccessorAccount: + | ((params: ChannelConfigAccessorParams) => AccessorAccount) + | undefined, + fallbackResolveAccessorAccount: (params: ChannelConfigAccessorParams) => AccessorAccount, +): (params: ChannelConfigAccessorParams) => AccessorAccount { + return resolveAccessorAccount ?? fallbackResolveAccessorAccount; +} + +function createChannelConfigAdapterWithAccessors< + ResolvedAccount, + AccessorAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + base: ChannelCrudConfigAdapter; + resolveAccessorAccount?: (params: ChannelConfigAccessorParams) => AccessorAccount; + fallbackResolveAccessorAccount: (params: ChannelConfigAccessorParams) => AccessorAccount; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}): ChannelConfigAdapterWithAccessors { + return { + ...params.base, + ...createScopedAccountConfigAccessors({ + resolveAccount: resolveAccessorAccountWithFallback( + params.resolveAccessorAccount, + params.fallbackResolveAccessorAccount, + ), + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }), + }; +} + +function createChannelConfigAdapterFromBase< + ResolvedAccount, + AccessorAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + base: ChannelCrudConfigAdapter; + resolveAccessorAccount?: (params: ChannelConfigAccessorParams) => AccessorAccount; + resolveAccountForAccessors: (params: ChannelConfigAccessorParams) => AccessorAccount; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterWithAccessors({ + base: params.base, + resolveAccessorAccount: params.resolveAccessorAccount, + fallbackResolveAccessorAccount: params.resolveAccountForAccessors, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); +} + /** Build the common CRUD/config helpers for channels that store multiple named accounts. */ export function createScopedChannelConfigBase< ResolvedAccount, @@ -120,16 +261,14 @@ export function createScopedChannelConfigBase< | "setAccountEnabled" | "deleteAccount" > { - return { - listAccountIds: (cfg) => params.listAccountIds(cfg as Config), - resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), - inspectAccount: params.inspectAccount - ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) - : undefined, - defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + return createNamedAccountConfigBase({ + listAccountIds: params.listAccountIds, + resolveAccount: params.resolveAccount, + inspectAccount: params.inspectAccount, + defaultAccountId: params.defaultAccountId, setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, enabled, @@ -137,12 +276,12 @@ export function createScopedChannelConfigBase< }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, clearBaseFields: params.clearBaseFields, }), - }; + }); } /** Build the full shared config adapter for account-scoped channels with allowlist/default target accessors. */ @@ -150,37 +289,13 @@ export function createScopedChannelConfigAdapter< ResolvedAccount, AccessorAccount = ResolvedAccount, Config extends OpenClawConfig = OpenClawConfig, ->(params: { - sectionKey: string; - listAccountIds: (cfg: Config) => string[]; - resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; - resolveAccessorAccount?: (params: { cfg: Config; accountId?: string | null }) => AccessorAccount; - defaultAccountId: (cfg: Config) => string; - inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; - clearBaseFields: string[]; - allowTopLevel?: boolean; - resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; - formatAllowFrom: (allowFrom: Array) => string[]; - resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; -}): Pick< - ChannelConfigAdapter, - | "listAccountIds" - | "resolveAccount" - | "inspectAccount" - | "defaultAccountId" - | "setAccountEnabled" - | "deleteAccount" - | "resolveAllowFrom" - | "formatAllowFrom" - | "resolveDefaultTo" -> { - const resolveAccessorAccount = - params.resolveAccessorAccount ?? - (({ cfg, accountId }: { cfg: Config; accountId?: string | null }) => - params.resolveAccount(cfg, accountId) as unknown as AccessorAccount); - - return { - ...createScopedChannelConfigBase({ +>( + params: MultiAccountChannelConfigAdapterParams & { + allowTopLevel?: boolean; + }, +): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterFromBase({ + base: createScopedChannelConfigBase({ sectionKey: params.sectionKey, listAccountIds: params.listAccountIds, resolveAccount: params.resolveAccount, @@ -189,13 +304,13 @@ export function createScopedChannelConfigAdapter< clearBaseFields: params.clearBaseFields, allowTopLevel: params.allowTopLevel, }), - ...createScopedAccountConfigAccessors({ - resolveAccount: resolveAccessorAccount, - resolveAllowFrom: params.resolveAllowFrom, - formatAllowFrom: params.formatAllowFrom, - resolveDefaultTo: params.resolveDefaultTo, - }), - }; + resolveAccessorAccount: params.resolveAccessorAccount, + resolveAccountForAccessors: ({ cfg, accountId }) => + params.resolveAccount(cfg, accountId) as unknown as AccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); } function setTopLevelChannelEnabledInConfigSection(params: { @@ -318,25 +433,9 @@ export function createTopLevelChannelConfigAdapter< resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; formatAllowFrom: (allowFrom: Array) => string[]; resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; -}): Pick< - ChannelConfigAdapter, - | "listAccountIds" - | "resolveAccount" - | "inspectAccount" - | "defaultAccountId" - | "setAccountEnabled" - | "deleteAccount" - | "resolveAllowFrom" - | "formatAllowFrom" - | "resolveDefaultTo" -> { - const resolveAccessorAccount = - params.resolveAccessorAccount ?? - (({ cfg }: { cfg: Config; accountId?: string | null }) => - params.resolveAccount(cfg) as unknown as AccessorAccount); - - return { - ...createTopLevelChannelConfigBase({ +}): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterFromBase({ + base: createTopLevelChannelConfigBase({ sectionKey: params.sectionKey, resolveAccount: params.resolveAccount, listAccountIds: params.listAccountIds, @@ -345,13 +444,13 @@ export function createTopLevelChannelConfigAdapter< deleteMode: params.deleteMode, clearBaseFields: params.clearBaseFields, }), - ...createScopedAccountConfigAccessors({ - resolveAccount: resolveAccessorAccount, - resolveAllowFrom: params.resolveAllowFrom, - formatAllowFrom: params.formatAllowFrom, - resolveDefaultTo: params.resolveDefaultTo, - }), - }; + resolveAccessorAccount: params.resolveAccessorAccount, + resolveAccountForAccessors: ({ cfg }) => + params.resolveAccount(cfg) as unknown as AccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); } /** Build CRUD/config helpers for channels where the default account lives at channel root and named accounts live under `accounts`. */ @@ -375,23 +474,21 @@ export function createHybridChannelConfigBase< | "setAccountEnabled" | "deleteAccount" > { - return { - listAccountIds: (cfg) => params.listAccountIds(cfg as Config), - resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), - inspectAccount: params.inspectAccount - ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) - : undefined, - defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + return createNamedAccountConfigBase({ + listAccountIds: params.listAccountIds, + resolveAccount: params.resolveAccount, + inspectAccount: params.inspectAccount, + defaultAccountId: params.defaultAccountId, setAccountEnabled: ({ cfg, accountId, enabled }) => { if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { return setTopLevelChannelEnabledInConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, enabled, }); } return setAccountEnabledInConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, enabled, @@ -401,26 +498,26 @@ export function createHybridChannelConfigBase< if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { if (params.preserveSectionOnDefaultDelete) { return clearTopLevelChannelConfigFields({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, clearBaseFields: params.clearBaseFields, }); } return deleteAccountFromConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, clearBaseFields: params.clearBaseFields, }); } return deleteAccountFromConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, clearBaseFields: params.clearBaseFields, }); }, - }; + }); } /** Build the full shared config adapter for hybrid channels with allowlist/default target accessors. */ @@ -428,37 +525,13 @@ export function createHybridChannelConfigAdapter< ResolvedAccount, AccessorAccount = ResolvedAccount, Config extends OpenClawConfig = OpenClawConfig, ->(params: { - sectionKey: string; - listAccountIds: (cfg: Config) => string[]; - resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; - resolveAccessorAccount?: (params: { cfg: Config; accountId?: string | null }) => AccessorAccount; - defaultAccountId: (cfg: Config) => string; - inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; - clearBaseFields: string[]; - preserveSectionOnDefaultDelete?: boolean; - resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; - formatAllowFrom: (allowFrom: Array) => string[]; - resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; -}): Pick< - ChannelConfigAdapter, - | "listAccountIds" - | "resolveAccount" - | "inspectAccount" - | "defaultAccountId" - | "setAccountEnabled" - | "deleteAccount" - | "resolveAllowFrom" - | "formatAllowFrom" - | "resolveDefaultTo" -> { - const resolveAccessorAccount = - params.resolveAccessorAccount ?? - (({ cfg, accountId }: { cfg: Config; accountId?: string | null }) => - params.resolveAccount(cfg, accountId) as unknown as AccessorAccount); - - return { - ...createHybridChannelConfigBase({ +>( + params: MultiAccountChannelConfigAdapterParams & { + preserveSectionOnDefaultDelete?: boolean; + }, +): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterFromBase({ + base: createHybridChannelConfigBase({ sectionKey: params.sectionKey, listAccountIds: params.listAccountIds, resolveAccount: params.resolveAccount, @@ -467,13 +540,13 @@ export function createHybridChannelConfigAdapter< clearBaseFields: params.clearBaseFields, preserveSectionOnDefaultDelete: params.preserveSectionOnDefaultDelete, }), - ...createScopedAccountConfigAccessors({ - resolveAccount: resolveAccessorAccount, - resolveAllowFrom: params.resolveAllowFrom, - formatAllowFrom: params.formatAllowFrom, - resolveDefaultTo: params.resolveDefaultTo, - }), - }; + resolveAccessorAccount: params.resolveAccessorAccount, + resolveAccountForAccessors: ({ cfg, accountId }) => + params.resolveAccount(cfg, accountId) as unknown as AccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); } /** Convert account-specific DM security fields into the shared runtime policy resolver shape. */ diff --git a/src/plugins/bundle-config-shared.ts b/src/plugins/bundle-config-shared.ts new file mode 100644 index 00000000000..7980f72a6fa --- /dev/null +++ b/src/plugins/bundle-config-shared.ts @@ -0,0 +1,139 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { applyMergePatch } from "../config/merge-patch.js"; +import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { isRecord } from "../utils.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginBundleFormat } from "./types.js"; + +type ReadBundleJsonResult = + | { ok: true; raw: Record } + | { ok: false; error: string }; + +export type BundleServerRuntimeSupport = { + hasSupportedServer: boolean; + supportedServerNames: string[]; + unsupportedServerNames: string[]; + diagnostics: string[]; +}; + +export function readBundleJsonObject(params: { + rootDir: string; + relativePath: string; + onOpenFailure?: ( + failure: Extract, { ok: false }>, + ) => ReadBundleJsonResult; +}): ReadBundleJsonResult { + const absolutePath = path.join(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + return params.onOpenFailure?.(opened) ?? { ok: true, raw: {} }; + } + try { + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + if (!isRecord(raw)) { + return { ok: false, error: `${params.relativePath} must contain a JSON object` }; + } + return { ok: true, raw }; + } catch (error) { + return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; + } finally { + fs.closeSync(opened.fd); + } +} + +export function resolveBundleJsonOpenFailure(params: { + failure: Extract, { ok: false }>; + relativePath: string; + allowMissing?: boolean; +}): ReadBundleJsonResult { + return matchBoundaryFileOpenFailure(params.failure, { + path: () => { + if (params.allowMissing) { + return { ok: true, raw: {} }; + } + return { ok: false, error: `unable to read ${params.relativePath}: path` }; + }, + fallback: (failure) => ({ + ok: false, + error: `unable to read ${params.relativePath}: ${failure.reason}`, + }), + }); +} + +export function inspectBundleServerRuntimeSupport(params: { + loaded: { config: TConfig; diagnostics: string[] }; + resolveServers: (config: TConfig) => Record>; +}): BundleServerRuntimeSupport { + const supportedServerNames: string[] = []; + const unsupportedServerNames: string[] = []; + let hasSupportedServer = false; + for (const [serverName, server] of Object.entries(params.resolveServers(params.loaded.config))) { + if (typeof server.command === "string" && server.command.trim().length > 0) { + hasSupportedServer = true; + supportedServerNames.push(serverName); + continue; + } + unsupportedServerNames.push(serverName); + } + return { + hasSupportedServer, + supportedServerNames, + unsupportedServerNames, + diagnostics: params.loaded.diagnostics, + }; +} + +export function loadEnabledBundleConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + createEmptyConfig: () => TConfig; + loadBundleConfig: (params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; + }) => { config: TConfig; diagnostics: string[] }; + createDiagnostic: (pluginId: string, message: string) => TDiagnostic; +}): { config: TConfig; diagnostics: TDiagnostic[] } { + const registry = loadPluginManifestRegistry({ + workspaceDir: params.workspaceDir, + config: params.cfg, + }); + const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); + const diagnostics: TDiagnostic[] = []; + let merged = params.createEmptyConfig(); + + for (const record of registry.plugins) { + if (record.format !== "bundle" || !record.bundleFormat) { + continue; + } + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.cfg, + }); + if (!enableState.enabled) { + continue; + } + + const loaded = params.loadBundleConfig({ + pluginId: record.id, + rootDir: record.rootDir, + bundleFormat: record.bundleFormat, + }); + merged = applyMergePatch(merged, loaded.config) as TConfig; + for (const message of loaded.diagnostics) { + diagnostics.push(params.createDiagnostic(record.id, message)); + } + } + + return { config: merged, diagnostics }; +} diff --git a/src/plugins/bundle-lsp.ts b/src/plugins/bundle-lsp.ts index 0151d5d1df2..7c8e29d7e2c 100644 --- a/src/plugins/bundle-lsp.ts +++ b/src/plugins/bundle-lsp.ts @@ -4,13 +4,16 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; +import { + inspectBundleServerRuntimeSupport, + loadEnabledBundleConfig, + readBundleJsonObject, +} from "./bundle-config-shared.js"; import { CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginBundleFormat } from "./types.js"; export type BundleLspServerConfig = Record; @@ -30,33 +33,6 @@ const MANIFEST_PATH_BY_FORMAT: Partial> = { claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, }; -function readPluginJsonObject(params: { - rootDir: string; - relativePath: string; -}): { ok: true; raw: Record } | { ok: false; error: string } { - const absolutePath = path.join(params.rootDir, params.relativePath); - const opened = openBoundaryFileSync({ - absolutePath, - rootPath: params.rootDir, - boundaryLabel: "plugin root", - rejectHardlinks: true, - }); - if (!opened.ok) { - return { ok: true, raw: {} }; - } - try { - const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; - if (!isRecord(raw)) { - return { ok: false, error: `${params.relativePath} must contain a JSON object` }; - } - return { ok: true, raw }; - } catch (error) { - return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; - } finally { - fs.closeSync(opened.fd); - } -} - function extractLspServerMap(raw: unknown): Record { if (!isRecord(raw)) { return {}; @@ -120,7 +96,7 @@ function loadBundleLspConfig(params: { return { config: { lspServers: {} }, diagnostics: [] }; } - const manifestLoaded = readPluginJsonObject({ + const manifestLoaded = readBundleJsonObject({ rootDir: params.rootDir, relativePath: manifestRelativePath, }); @@ -151,23 +127,15 @@ export function inspectBundleLspRuntimeSupport(params: { rootDir: string; bundleFormat: PluginBundleFormat; }): BundleLspRuntimeSupport { - const loaded = loadBundleLspConfig(params); - const supportedServerNames: string[] = []; - const unsupportedServerNames: string[] = []; - let hasStdioServer = false; - for (const [serverName, server] of Object.entries(loaded.config.lspServers)) { - if (typeof server.command === "string" && server.command.trim().length > 0) { - hasStdioServer = true; - supportedServerNames.push(serverName); - continue; - } - unsupportedServerNames.push(serverName); - } + const support = inspectBundleServerRuntimeSupport({ + loaded: loadBundleLspConfig(params), + resolveServers: (config) => config.lspServers, + }); return { - hasStdioServer, - supportedServerNames, - unsupportedServerNames, - diagnostics: loaded.diagnostics, + hasStdioServer: support.hasSupportedServer, + supportedServerNames: support.supportedServerNames, + unsupportedServerNames: support.unsupportedServerNames, + diagnostics: support.diagnostics, }; } @@ -175,38 +143,11 @@ export function loadEnabledBundleLspConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; }): { config: BundleLspConfig; diagnostics: Array<{ pluginId: string; message: string }> } { - const registry = loadPluginManifestRegistry({ + return loadEnabledBundleConfig({ workspaceDir: params.workspaceDir, - config: params.cfg, + cfg: params.cfg, + createEmptyConfig: () => ({ lspServers: {} }), + loadBundleConfig: loadBundleLspConfig, + createDiagnostic: (pluginId, message) => ({ pluginId, message }), }); - const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); - const diagnostics: Array<{ pluginId: string; message: string }> = []; - let merged: BundleLspConfig = { lspServers: {} }; - - for (const record of registry.plugins) { - if (record.format !== "bundle" || !record.bundleFormat) { - continue; - } - const enableState = resolveEffectiveEnableState({ - id: record.id, - origin: record.origin, - config: normalizedPlugins, - rootConfig: params.cfg, - }); - if (!enableState.enabled) { - continue; - } - - const loaded = loadBundleLspConfig({ - pluginId: record.id, - rootDir: record.rootDir, - bundleFormat: record.bundleFormat, - }); - merged = applyMergePatch(merged, loaded.config) as BundleLspConfig; - for (const message of loaded.diagnostics) { - diagnostics.push({ pluginId: record.id, message }); - } - } - - return { config: merged, diagnostics }; } diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 357961caf72..f117c6d39f8 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -2,8 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { applyMergePatch } from "../config/merge-patch.js"; -import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; +import { + inspectBundleServerRuntimeSupport, + loadEnabledBundleConfig, + readBundleJsonObject, + resolveBundleJsonOpenFailure, +} from "./bundle-config-shared.js"; import { CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, @@ -11,8 +17,6 @@ import { mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginBundleFormat } from "./types.js"; export type BundleMcpServerConfig = Record; @@ -44,45 +48,6 @@ const MANIFEST_PATH_BY_FORMAT: Record = { }; const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"; -function readPluginJsonObject(params: { - rootDir: string; - relativePath: string; - allowMissing?: boolean; -}): { ok: true; raw: Record } | { ok: false; error: string } { - const absolutePath = path.join(params.rootDir, params.relativePath); - const opened = openBoundaryFileSync({ - absolutePath, - rootPath: params.rootDir, - boundaryLabel: "plugin root", - rejectHardlinks: true, - }); - if (!opened.ok) { - return matchBoundaryFileOpenFailure(opened, { - path: () => { - if (params.allowMissing) { - return { ok: true, raw: {} }; - } - return { ok: false, error: `unable to read ${params.relativePath}: path` }; - }, - fallback: (failure) => ({ - ok: false, - error: `unable to read ${params.relativePath}: ${failure.reason}`, - }), - }); - } - try { - const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; - if (!isRecord(raw)) { - return { ok: false, error: `${params.relativePath} must contain a JSON object` }; - } - return { ok: true, raw }; - } catch (error) { - return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; - } finally { - fs.closeSync(opened.fd); - } -} - function resolveBundleMcpConfigPaths(params: { raw: Record; rootDir: string; @@ -258,10 +223,15 @@ function loadBundleMcpConfig(params: { bundleFormat: PluginBundleFormat; }): { config: BundleMcpConfig; diagnostics: string[] } { const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat]; - const manifestLoaded = readPluginJsonObject({ + const manifestLoaded = readBundleJsonObject({ rootDir: params.rootDir, relativePath: manifestRelativePath, - allowMissing: params.bundleFormat === "claude", + onOpenFailure: (failure) => + resolveBundleJsonOpenFailure({ + failure, + relativePath: manifestRelativePath, + allowMissing: params.bundleFormat === "claude", + }), }); if (!manifestLoaded.ok) { return { config: { mcpServers: {} }, diagnostics: [manifestLoaded.error] }; @@ -299,23 +269,15 @@ export function inspectBundleMcpRuntimeSupport(params: { rootDir: string; bundleFormat: PluginBundleFormat; }): BundleMcpRuntimeSupport { - const loaded = loadBundleMcpConfig(params); - const supportedServerNames: string[] = []; - const unsupportedServerNames: string[] = []; - let hasSupportedStdioServer = false; - for (const [serverName, server] of Object.entries(loaded.config.mcpServers)) { - if (typeof server.command === "string" && server.command.trim().length > 0) { - hasSupportedStdioServer = true; - supportedServerNames.push(serverName); - continue; - } - unsupportedServerNames.push(serverName); - } + const support = inspectBundleServerRuntimeSupport({ + loaded: loadBundleMcpConfig(params), + resolveServers: (config) => config.mcpServers, + }); return { - hasSupportedStdioServer, - supportedServerNames, - unsupportedServerNames, - diagnostics: loaded.diagnostics, + hasSupportedStdioServer: support.hasSupportedServer, + supportedServerNames: support.supportedServerNames, + unsupportedServerNames: support.unsupportedServerNames, + diagnostics: support.diagnostics, }; } @@ -323,38 +285,11 @@ export function loadEnabledBundleMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; }): EnabledBundleMcpConfigResult { - const registry = loadPluginManifestRegistry({ + return loadEnabledBundleConfig({ workspaceDir: params.workspaceDir, - config: params.cfg, + cfg: params.cfg, + createEmptyConfig: () => ({ mcpServers: {} }), + loadBundleConfig: loadBundleMcpConfig, + createDiagnostic: (pluginId, message) => ({ pluginId, message }), }); - const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); - const diagnostics: BundleMcpDiagnostic[] = []; - let merged: BundleMcpConfig = { mcpServers: {} }; - - for (const record of registry.plugins) { - if (record.format !== "bundle" || !record.bundleFormat) { - continue; - } - const enableState = resolveEffectiveEnableState({ - id: record.id, - origin: record.origin, - config: normalizedPlugins, - rootConfig: params.cfg, - }); - if (!enableState.enabled) { - continue; - } - - const loaded = loadBundleMcpConfig({ - pluginId: record.id, - rootDir: record.rootDir, - bundleFormat: record.bundleFormat, - }); - merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig; - for (const message of loaded.diagnostics) { - diagnostics.push({ pluginId: record.id, message }); - } - } - - return { config: merged, diagnostics }; } diff --git a/src/plugins/cache-controls.ts b/src/plugins/cache-controls.ts new file mode 100644 index 00000000000..3b11d632479 --- /dev/null +++ b/src/plugins/cache-controls.ts @@ -0,0 +1,68 @@ +export const DEFAULT_PLUGIN_DISCOVERY_CACHE_MS = 1000; +export const DEFAULT_PLUGIN_MANIFEST_CACHE_MS = 1000; + +export function shouldUsePluginSnapshotCache(env: NodeJS.ProcessEnv): boolean { + if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { + return false; + } + if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { + return false; + } + const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); + if (discoveryCacheMs === "0") { + return false; + } + const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); + if (manifestCacheMs === "0") { + return false; + } + return true; +} + +export function resolvePluginCacheMs(rawValue: string | undefined, defaultMs: number): number { + const raw = rawValue?.trim(); + if (raw === "" || raw === "0") { + return 0; + } + if (!raw) { + return defaultMs; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) { + return defaultMs; + } + return Math.max(0, parsed); +} + +export function resolvePluginSnapshotCacheTtlMs(env: NodeJS.ProcessEnv): number { + const discoveryCacheMs = resolvePluginCacheMs( + env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, + DEFAULT_PLUGIN_DISCOVERY_CACHE_MS, + ); + const manifestCacheMs = resolvePluginCacheMs( + env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, + DEFAULT_PLUGIN_MANIFEST_CACHE_MS, + ); + return Math.min(discoveryCacheMs, manifestCacheMs); +} + +export function buildPluginSnapshotCacheEnvKey( + env: NodeJS.ProcessEnv, + options: { includeProcessVitestFallback?: boolean } = {}, +) { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", + OPENCLAW_HOME: env.OPENCLAW_HOME ?? "", + OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR ?? "", + OPENCLAW_CONFIG_PATH: env.OPENCLAW_CONFIG_PATH ?? "", + HOME: env.HOME ?? "", + USERPROFILE: env.USERPROFILE ?? "", + VITEST: options.includeProcessVitestFallback + ? (env.VITEST ?? process.env.VITEST ?? "") + : (env.VITEST ?? ""), + }; +} diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index e0f19e7bac5..1d12d71165a 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -2,14 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { createNonExitingRuntime } from "../../runtime.js"; -import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import type { WizardMultiSelectParams, WizardPrompter, WizardProgress, WizardSelectParams, } from "../../wizard/prompts.js"; -import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; +import { registerProviders, requireProvider } from "./testkit.js"; type LoginOpenAICodexOAuth = (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; @@ -56,22 +55,6 @@ import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; import openAIPlugin from "../../../extensions/openai/index.js"; import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js"; -function registerProviders(...plugins: Array<{ register(api: OpenClawPluginApi): void }>) { - const captured = createCapturedPluginRegistration(); - for (const plugin of plugins) { - plugin.register(captured.api); - } - return captured.providers; -} - -function requireProvider(providers: ProviderPlugin[], providerId: string) { - const provider = providers.find((entry) => entry.id === providerId); - if (!provider) { - throw new Error(`provider ${providerId} missing`); - } - return provider; -} - function buildPrompter(): WizardPrompter { const progress: WizardProgress = { update() {}, diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 81cc69381f0..68c5249eccf 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -4,11 +4,10 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import openAIPlugin from "../../../extensions/openai/index.js"; import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js"; -import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; -import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; -import type { ProviderRuntimeModel } from "../types.js"; +import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js"; import { requireProviderContractProvider as requireBundledProviderContractProvider } from "./registry.js"; +import { registerProviders, requireProvider } from "./testkit.js"; const CONTRACT_SETUP_TIMEOUT_MS = 300_000; @@ -61,22 +60,6 @@ function createModel(overrides: Partial & Pick) { - const captured = createCapturedPluginRegistration(); - for (const plugin of plugins) { - plugin.register(captured.api); - } - return captured.providers; -} - -function requireProvider(providers: ProviderPlugin[], providerId: string) { - const provider = providers.find((entry) => entry.id === providerId); - if (!provider) { - throw new Error(`provider ${providerId} missing`); - } - return provider; -} - function requireProviderContractProvider(providerId: string): ProviderPlugin { if (providerId === "openai-codex") { return requireProvider(registerProviders(openAIPlugin), providerId); diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index ebd0915e719..13a49839050 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -123,6 +123,10 @@ let unregisterSessionBindingAdapter: typeof import("../infra/outbound/session-bi let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry; type PluginBindingRequest = Awaited>; +type PluginBindingRequestInput = Parameters[0]; +type PluginBindingDecision = Parameters< + typeof resolvePluginConversationBindingApproval +>[0]["decision"]; type ConversationBindingModule = typeof import("./conversation-binding.js"); const conversationBindingModuleUrl = new URL("./conversation-binding.ts", import.meta.url).href; @@ -152,14 +156,82 @@ function createAdapter(channel: string, accountId: string): SessionBindingAdapte }; } +function createDiscordCodexBindRequest( + conversationId: string, + summary: string, + accountId = "isolated", +): PluginBindingRequestInput { + return { + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId, + conversationId, + }, + binding: { summary }, + }; +} + +function createTelegramCodexBindRequest( + conversationId: string, + threadId: string, + summary: string, + pluginRoot = "/plugins/codex-a", +): PluginBindingRequestInput { + return { + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot, + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId, + parentConversationId: "-10099", + threadId, + }, + binding: { summary }, + }; +} + +async function requestPendingBinding( + input: PluginBindingRequestInput, + requestBinding = requestPluginConversationBinding, +) { + const request = await requestBinding(input); + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + return request; +} + +async function approveBindingRequest( + approvalId: string, + decision: PluginBindingDecision, + resolveApproval = resolvePluginConversationBindingApproval, +) { + return await resolveApproval({ + approvalId, + decision, + senderId: "user-1", + }); +} + +async function importDuplicateConversationBindingModules() { + const first = await importConversationBindingModule(`first-${Date.now()}`); + const second = await importConversationBindingModule(`second-${Date.now()}`); + first.__testing.reset(); + return { first, second }; +} + async function resolveRequestedBinding(request: PluginBindingRequest) { expect(["pending", "bound"]).toContain(request.status); if (request.status === "pending") { - const approved = await resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }); + const approved = await approveBindingRequest(request.approvalId, "allow-once"); expect(approved.status).toBe("approved"); if (approved.status !== "approved") { throw new Error("expected approved bind result"); @@ -246,138 +318,62 @@ describe("plugin conversation binding approvals", () => { }); it("requires a fresh approval again after allow-once is consumed", async () => { - const firstRequest = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:1", - }, - binding: { summary: "Bind this conversation to Codex thread 123." }, - }); - - expect(firstRequest.status).toBe("pending"); - if (firstRequest.status !== "pending") { - throw new Error("expected pending bind request"); - } - - const approved = await resolvePluginConversationBindingApproval({ - approvalId: firstRequest.approvalId, - decision: "allow-once", - senderId: "user-1", - }); + const firstRequest = await requestPendingBinding( + createDiscordCodexBindRequest("channel:1", "Bind this conversation to Codex thread 123."), + ); + const approved = await approveBindingRequest(firstRequest.approvalId, "allow-once"); expect(approved.status).toBe("approved"); - const secondRequest = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:2", - }, - binding: { summary: "Bind this conversation to Codex thread 456." }, - }); + const secondRequest = await requestPluginConversationBinding( + createDiscordCodexBindRequest("channel:2", "Bind this conversation to Codex thread 456."), + ); expect(secondRequest.status).toBe("pending"); }); it("persists always-allow by plugin root plus channel/account only", async () => { - const firstRequest = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:1", - }, - binding: { summary: "Bind this conversation to Codex thread 123." }, - }); - - expect(firstRequest.status).toBe("pending"); - if (firstRequest.status !== "pending") { - throw new Error("expected pending bind request"); - } - - const approved = await resolvePluginConversationBindingApproval({ - approvalId: firstRequest.approvalId, - decision: "allow-always", - senderId: "user-1", - }); + const firstRequest = await requestPendingBinding( + createDiscordCodexBindRequest("channel:1", "Bind this conversation to Codex thread 123."), + ); + const approved = await approveBindingRequest(firstRequest.approvalId, "allow-always"); expect(approved.status).toBe("approved"); - const sameScope = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:2", - }, - binding: { summary: "Bind this conversation to Codex thread 456." }, - }); + const sameScope = await requestPluginConversationBinding( + createDiscordCodexBindRequest("channel:2", "Bind this conversation to Codex thread 456."), + ); expect(sameScope.status).toBe("bound"); - const differentAccount = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "work", - conversationId: "channel:3", - }, - binding: { summary: "Bind this conversation to Codex thread 789." }, - }); + const differentAccount = await requestPluginConversationBinding( + createDiscordCodexBindRequest( + "channel:3", + "Bind this conversation to Codex thread 789.", + "work", + ), + ); expect(differentAccount.status).toBe("pending"); }); it("shares pending bind approvals across duplicate module instances", async () => { - const first = await importConversationBindingModule(`first-${Date.now()}`); - const second = await importConversationBindingModule(`second-${Date.now()}`); - - first.__testing.reset(); - - const request = await first.requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - threadId: "77", - }, - binding: { summary: "Bind this conversation to Codex thread abc." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } + const { first, second } = await importDuplicateConversationBindingModules(); + const request = await requestPendingBinding( + createTelegramCodexBindRequest( + "-10099:topic:77", + "77", + "Bind this conversation to Codex thread abc.", + ), + first.requestPluginConversationBinding, + ); await expect( - second.resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }), + approveBindingRequest( + request.approvalId, + "allow-once", + second.resolvePluginConversationBindingApproval, + ), ).resolves.toMatchObject({ status: "approved", binding: expect.objectContaining({ @@ -391,56 +387,34 @@ describe("plugin conversation binding approvals", () => { }); it("shares persistent approvals across duplicate module instances", async () => { - const first = await importConversationBindingModule(`first-${Date.now()}`); - const second = await importConversationBindingModule(`second-${Date.now()}`); - - first.__testing.reset(); - - const request = await first.requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - threadId: "77", - }, - binding: { summary: "Bind this conversation to Codex thread abc." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } + const { first, second } = await importDuplicateConversationBindingModules(); + const request = await requestPendingBinding( + createTelegramCodexBindRequest( + "-10099:topic:77", + "77", + "Bind this conversation to Codex thread abc.", + ), + first.requestPluginConversationBinding, + ); await expect( - second.resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-always", - senderId: "user-1", - }), + approveBindingRequest( + request.approvalId, + "allow-always", + second.resolvePluginConversationBindingApproval, + ), ).resolves.toMatchObject({ status: "approved", decision: "allow-always", }); - const rebound = await first.requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:78", - parentConversationId: "-10099", - threadId: "78", - }, - binding: { summary: "Bind this conversation to Codex thread def." }, - }); + const rebound = await first.requestPluginConversationBinding( + createTelegramCodexBindRequest( + "-10099:topic:78", + "78", + "Bind this conversation to Codex thread def.", + ), + ); expect(rebound.status).toBe("bound"); diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 7b68969b37a..6a149c55729 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -57,6 +57,117 @@ async function importInteractiveModule(cacheBust: string): Promise { + return { + channel: "telegram", + data: params.data, + callbackId: params.callbackId, + ctx: { + accountId: "default", + callbackId: params.callbackId, + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + }, + }; +} + +function createDiscordDispatchParams(params: { + data: string; + interactionId: string; + interaction?: Partial; +}): Extract { + return { + channel: "discord", + data: params.data, + interactionId: params.interactionId, + ctx: { + accountId: "default", + interactionId: params.interactionId, + conversationId: "channel-1", + parentConversationId: "parent-1", + guildId: "guild-1", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + messageId: "message-1", + values: ["allow"], + ...params.interaction, + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + clearComponents: vi.fn(async () => {}), + }, + }; +} + +function createSlackDispatchParams(params: { + data: string; + interactionId: string; + interaction?: Partial; +}): Extract { + return { + channel: "slack", + data: params.data, + interactionId: params.interactionId, + ctx: { + accountId: "default", + interactionId: params.interactionId, + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + actionId: "codex", + blockId: "codex_actions", + messageTs: "1710000000.000200", + threadTs: "1710000000.000100", + value: "approve:thread-1", + selectedValues: ["approve:thread-1"], + selectedLabels: ["Approve"], + triggerId: "trigger-1", + responseUrl: "https://hooks.slack.test/response", + ...params.interaction, + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + }, + }; +} + async function expectDedupedInteractiveDispatch(params: { baseParams: InteractiveDispatchParams; handler: ReturnType; @@ -134,35 +245,10 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "telegram" as const, + const baseParams = createTelegramDispatchParams({ data: "codex:resume:thread-1", callbackId: "cb-1", - ctx: { - accountId: "default", - callbackId: "cb-1", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }; + }); await expectDedupedInteractiveDispatch({ baseParams, @@ -196,35 +282,12 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - second.dispatchPluginInteractiveHandler({ - channel: "telegram", - data: "codexapp:resume:thread-1", - callbackId: "cb-shared-1", - ctx: { - accountId: "default", + second.dispatchPluginInteractiveHandler( + createTelegramDispatchParams({ + data: "codexapp:resume:thread-1", callbackId: "cb-shared-1", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }), + }), + ), ).resolves.toEqual({ matched: true, handled: true, duplicate: false }); expect(handler).toHaveBeenCalledWith( @@ -269,33 +332,11 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "discord" as const, + const baseParams = createDiscordDispatchParams({ data: "codex:approve:thread-1", interactionId: "ix-1", - ctx: { - accountId: "default", - interactionId: "ix-1", - conversationId: "channel-1", - parentConversationId: "parent-1", - guildId: "guild-1", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button" as const, - messageId: "message-1", - values: ["allow"], - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - clearComponents: vi.fn(async () => {}), - }, - }; + interaction: { kind: "button", values: ["allow"] }, + }); await expectDedupedInteractiveDispatch({ baseParams, @@ -330,30 +371,11 @@ describe("plugin interactive handlers", () => { await expect( dispatchPluginInteractiveHandler({ - channel: "discord", - data: "codex:approve:thread-1", - interactionId: "ix-ack-1", - ctx: { - accountId: "default", + ...createDiscordDispatchParams({ + data: "codex:approve:thread-1", interactionId: "ix-ack-1", - conversationId: "channel-1", - parentConversationId: "parent-1", - guildId: "guild-1", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button", - messageId: "message-1", - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - clearComponents: vi.fn(async () => {}), - }, + interaction: { kind: "button", values: undefined }, + }), onMatched: async () => { callOrder.push("ack"); }, @@ -375,40 +397,11 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "slack" as const, + const baseParams = createSlackDispatchParams({ data: "codex:approve:thread-1", interactionId: "slack-ix-1", - ctx: { - channel: "slack" as const, - accountId: "default", - interactionId: "slack-ix-1", - conversationId: "C123", - parentConversationId: "C123", - threadId: "1710000000.000100", - senderId: "U123", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button" as const, - actionId: "codex", - blockId: "codex_actions", - messageTs: "1710000000.000200", - threadTs: "1710000000.000100", - value: "approve:thread-1", - selectedValues: ["approve:thread-1"], - selectedLabels: ["Approve"], - triggerId: "trigger-1", - responseUrl: "https://hooks.slack.test/response", - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - }, - }; + interaction: { kind: "button" }, + }); await expectDedupedInteractiveDispatch({ baseParams, @@ -474,35 +467,12 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - dispatchPluginInteractiveHandler({ - channel: "telegram", - data: "codex:bind", - callbackId: "cb-bind", - ctx: { - accountId: "default", + dispatchPluginInteractiveHandler( + createTelegramDispatchParams({ + data: "codex:bind", callbackId: "cb-bind", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }), + }), + ), ).resolves.toEqual({ matched: true, handled: true, @@ -591,33 +561,13 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - dispatchPluginInteractiveHandler({ - channel: "discord", - data: "codex:bind", - interactionId: "ix-bind", - ctx: { - accountId: "default", + dispatchPluginInteractiveHandler( + createDiscordDispatchParams({ + data: "codex:bind", interactionId: "ix-bind", - conversationId: "channel-1", - parentConversationId: "parent-1", - guildId: "guild-1", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button", - messageId: "message-1", - values: ["allow"], - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - clearComponents: vi.fn(async () => {}), - }, - }), + interaction: { kind: "button", values: ["allow"] }, + }), + ), ).resolves.toEqual({ matched: true, handled: true, @@ -703,39 +653,18 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - dispatchPluginInteractiveHandler({ - channel: "slack", - data: "codex:bind", - interactionId: "slack-bind", - ctx: { - accountId: "default", + dispatchPluginInteractiveHandler( + createSlackDispatchParams({ + data: "codex:bind", interactionId: "slack-bind", - conversationId: "C123", - parentConversationId: "C123", - threadId: "1710000000.000100", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, interaction: { kind: "button", - actionId: "codex", - blockId: "codex_actions", - messageTs: "1710000000.000200", - threadTs: "1710000000.000100", value: "bind", selectedValues: ["bind"], selectedLabels: ["Bind"], - triggerId: "trigger-1", - responseUrl: "https://hooks.slack.test/response", }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - }, - }), + }), + ), ).resolves.toEqual({ matched: true, handled: true, @@ -793,35 +722,10 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "telegram" as const, + const baseParams = createTelegramDispatchParams({ data: "codex:resume:thread-1", callbackId: "cb-throw", - ctx: { - accountId: "default", - callbackId: "cb-throw", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }; + }); await expect(dispatchPluginInteractiveHandler(baseParams)).rejects.toThrow("boom"); await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({ diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 14ee652117d..63777eae834 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -24,6 +24,19 @@ async function withTempDir(fn: (dir: string) => Promise): Promise { } } +function mockRemoteMarketplaceClone(manifest: unknown) { + runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { + const repoDir = argv.at(-1); + expect(typeof repoDir).toBe("string"); + await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(repoDir as string, ".claude-plugin", "marketplace.json"), + JSON.stringify(manifest), + ); + return { code: 0, stdout: "", stderr: "", killed: false }; + }); +} + describe("marketplace plugins", () => { afterEach(() => { installPluginFromPathMock.mockReset(); @@ -202,25 +215,16 @@ describe("marketplace plugins", () => { }); it("rejects remote marketplace git plugin sources before cloning nested remotes", async () => { - runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { - const repoDir = argv.at(-1); - expect(typeof repoDir).toBe("string"); - await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(repoDir as string, ".claude-plugin", "marketplace.json"), - JSON.stringify({ - plugins: [ - { - name: "frontend-design", - source: { - type: "git", - url: "https://evil.example/repo.git", - }, - }, - ], - }), - ); - return { code: 0, stdout: "", stderr: "", killed: false }; + mockRemoteMarketplaceClone({ + plugins: [ + { + name: "frontend-design", + source: { + type: "git", + url: "https://evil.example/repo.git", + }, + }, + ], }); const { listMarketplacePlugins } = await import("./marketplace.js"); @@ -236,25 +240,16 @@ describe("marketplace plugins", () => { }); it("rejects remote marketplace absolute plugin paths", async () => { - runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { - const repoDir = argv.at(-1); - expect(typeof repoDir).toBe("string"); - await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(repoDir as string, ".claude-plugin", "marketplace.json"), - JSON.stringify({ - plugins: [ - { - name: "frontend-design", - source: { - type: "path", - path: "/tmp/frontend-design", - }, - }, - ], - }), - ); - return { code: 0, stdout: "", stderr: "", killed: false }; + mockRemoteMarketplaceClone({ + plugins: [ + { + name: "frontend-design", + source: { + type: "path", + path: "/tmp/frontend-design", + }, + }, + ], }); const { listMarketplacePlugins } = await import("./marketplace.js"); @@ -270,25 +265,16 @@ describe("marketplace plugins", () => { }); it("rejects remote marketplace HTTP plugin paths", async () => { - runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { - const repoDir = argv.at(-1); - expect(typeof repoDir).toBe("string"); - await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(repoDir as string, ".claude-plugin", "marketplace.json"), - JSON.stringify({ - plugins: [ - { - name: "frontend-design", - source: { - type: "path", - path: "https://evil.example/plugin.tgz", - }, - }, - ], - }), - ); - return { code: 0, stdout: "", stderr: "", killed: false }; + mockRemoteMarketplaceClone({ + plugins: [ + { + name: "frontend-design", + source: { + type: "path", + path: "https://evil.example/plugin.tgz", + }, + }, + ], }); const { listMarketplacePlugins } = await import("./marketplace.js"); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index f11d5f2d912..855ebdcce5b 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -53,6 +53,11 @@ type LoadedMarketplace = { type MarketplaceManifestOrigin = "local" | "remote"; +type ResolvedLocalMarketplaceSource = { + manifestPath: string; + rootDir: string; +}; + type KnownMarketplaceRecord = { installLocation?: string; source?: unknown; @@ -464,53 +469,10 @@ async function loadMarketplace(params: { logger?: MarketplaceLogger; timeoutMs?: number; }): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> { - const knownMarketplaces = await readClaudeKnownMarketplaces(); - const known = knownMarketplaces[params.source]; - if (known) { - if (known.installLocation) { - const local = await resolveLocalMarketplaceSource(known.installLocation); - if (local?.ok) { - const raw = await fs.readFile(local.manifestPath, "utf-8"); - const parsed = parseMarketplaceManifest(raw, local.manifestPath); - if (!parsed.ok) { - return parsed; - } - const validated = validateMarketplaceManifest({ - manifest: parsed.manifest, - sourceLabel: local.manifestPath, - rootDir: local.rootDir, - origin: "local", - }); - if (!validated.ok) { - return validated; - } - return { - ok: true, - marketplace: { - manifest: validated.manifest, - rootDir: local.rootDir, - sourceLabel: params.source, - }, - }; - } - } - - const normalizedSource = normalizeEntrySource(known.source); - if (normalizedSource.ok) { - return await loadMarketplace({ - source: marketplaceEntrySourceToInput(normalizedSource.source), - logger: params.logger, - timeoutMs: params.timeoutMs, - }); - } - } - - const local = await resolveLocalMarketplaceSource(params.source); - if (local?.ok === false) { - return local; - } - - if (local?.ok) { + const loadResolvedLocalMarketplace = async ( + local: ResolvedLocalMarketplaceSource, + sourceLabel: string, + ): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => { const raw = await fs.readFile(local.manifestPath, "utf-8"); const parsed = parseMarketplaceManifest(raw, local.manifestPath); if (!parsed.ok) { @@ -530,9 +492,38 @@ async function loadMarketplace(params: { marketplace: { manifest: validated.manifest, rootDir: local.rootDir, - sourceLabel: local.manifestPath, + sourceLabel, }, }; + }; + + const knownMarketplaces = await readClaudeKnownMarketplaces(); + const known = knownMarketplaces[params.source]; + if (known) { + if (known.installLocation) { + const local = await resolveLocalMarketplaceSource(known.installLocation); + if (local?.ok) { + return await loadResolvedLocalMarketplace(local, params.source); + } + } + + const normalizedSource = normalizeEntrySource(known.source); + if (normalizedSource.ok) { + return await loadMarketplace({ + source: marketplaceEntrySourceToInput(normalizedSource.source), + logger: params.logger, + timeoutMs: params.timeoutMs, + }); + } + } + + const local = await resolveLocalMarketplaceSource(params.source); + if (local?.ok === false) { + return local; + } + + if (local?.ok) { + return await loadResolvedLocalMarketplace(local, local.manifestPath); } const cloned = await cloneMarketplaceRepo({ diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index 317ab3024f0..385674da913 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -20,6 +20,46 @@ function makeProvider(overrides: Partial & Pick { beforeEach(() => { vi.clearAllMocks(); @@ -248,23 +288,8 @@ describe("provider wizard boundaries", () => { }); it("invalidates the wizard cache when config or env contents change in place", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); - const config = { - plugins: { - allow: ["sglang"], - }, - }; + const provider = createSglangSetupProvider(); + const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home-a" } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); @@ -291,95 +316,36 @@ describe("provider wizard boundaries", () => { }); it("skips provider-wizard memoization when plugin cache opt-outs are set", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); - const config = { - plugins: { - allow: ["sglang"], - }, - }; + const provider = createSglangSetupProvider(); + const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); + resolveWizardOptionsTwice({ config, env }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("skips provider-wizard memoization when discovery cache ttl is zero", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); - const config = { - plugins: { - allow: ["sglang"], - }, - }; + const provider = createSglangSetupProvider(); + const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); + resolveWizardOptionsTwice({ config, env }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("expires provider-wizard memoization after the shortest plugin cache ttl", () => { vi.useFakeTimers(); - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); + const provider = createSglangSetupProvider(); const config = {}; const env = { OPENCLAW_HOME: "/tmp/openclaw-home", @@ -410,18 +376,7 @@ describe("provider wizard boundaries", () => { }); it("invalidates provider-wizard snapshots when cache-control env values change in place", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); + const provider = createSglangSetupProvider(); const config = {}; const env = { OPENCLAW_HOME: "/tmp/openclaw-home", diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 665bfedd98f..f252cbd5ac1 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -3,6 +3,11 @@ import { parseModelRef } from "../agents/model-selection.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { + buildPluginSnapshotCacheEnvKey, + resolvePluginSnapshotCacheTtlMs, + shouldUsePluginSnapshotCache, +} from "./cache-controls.js"; import { resolvePluginProviders } from "./providers.runtime.js"; import type { ProviderAuthMethod, @@ -21,54 +26,6 @@ const providerWizardCache = new WeakMap< WeakMap> >(); -const DEFAULT_DISCOVERY_CACHE_MS = 1000; -const DEFAULT_MANIFEST_CACHE_MS = 1000; - -function shouldUseProviderWizardCache(env: NodeJS.ProcessEnv): boolean { - if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { - return false; - } - if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { - return false; - } - const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); - if (discoveryCacheMs === "0") { - return false; - } - const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); - if (manifestCacheMs === "0") { - return false; - } - return true; -} - -function resolveProviderWizardCacheTtlMs(env: NodeJS.ProcessEnv): number { - const discoveryCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, - DEFAULT_DISCOVERY_CACHE_MS, - ); - const manifestCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, - DEFAULT_MANIFEST_CACHE_MS, - ); - return Math.min(discoveryCacheMs, manifestCacheMs); -} - -function resolveCacheMs(rawValue: string | undefined, defaultMs: number): number { - const raw = rawValue?.trim(); - if (raw === "" || raw === "0") { - return 0; - } - if (!raw) { - return defaultMs; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return defaultMs; - } - return Math.max(0, parsed); -} - function buildProviderWizardCacheKey(params: { config: OpenClawConfig; workspaceDir?: string; @@ -77,21 +34,7 @@ function buildProviderWizardCacheKey(params: { return JSON.stringify({ workspaceDir: params.workspaceDir ?? "", config: params.config, - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: params.env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: params.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: params.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", - OPENCLAW_HOME: params.env.OPENCLAW_HOME ?? "", - OPENCLAW_STATE_DIR: params.env.OPENCLAW_STATE_DIR ?? "", - OPENCLAW_CONFIG_PATH: params.env.OPENCLAW_CONFIG_PATH ?? "", - HOME: params.env.HOME ?? "", - USERPROFILE: params.env.USERPROFILE ?? "", - VITEST: params.env.VITEST ?? "", - }, + env: buildPluginSnapshotCacheEnvKey(params.env), }); } @@ -188,7 +131,7 @@ function resolveProviderWizardProviders(params: { return resolvePluginProviders(params); } const env = params.env ?? process.env; - if (!shouldUseProviderWizardCache(env)) { + if (!shouldUsePluginSnapshotCache(env)) { return resolvePluginProviders({ config: params.config, workspaceDir: params.workspaceDir, @@ -211,7 +154,7 @@ function resolveProviderWizardProviders(params: { workspaceDir: params.workspaceDir, env, }); - const ttlMs = resolveProviderWizardCacheTtlMs(env); + const ttlMs = resolvePluginSnapshotCacheTtlMs(env); let nextConfigCache = configCache; if (!nextConfigCache) { nextConfigCache = new WeakMap>(); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index e4ace41aec7..e3a4e6e2c0c 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -12,6 +12,7 @@ import { import { VERSION } from "../../version.js"; import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js"; import { createRuntimeAgent } from "./runtime-agent.js"; +import { defineCachedValue } from "./runtime-cache.js"; import { createRuntimeChannel } from "./runtime-channel.js"; import { createRuntimeConfig } from "./runtime-config.js"; import { createRuntimeEvents } from "./runtime-events.js"; @@ -21,26 +22,6 @@ import { createRuntimeSystem } from "./runtime-system.js"; import { createRuntimeTools } from "./runtime-tools.js"; import type { PluginRuntime } from "./types.js"; -function defineCachedValue( - target: T, - key: K, - create: () => unknown, -): void { - let cached: unknown; - let ready = false; - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - get() { - if (!ready) { - cached = create(); - ready = true; - } - return cached; - }, - }); -} - const loadTtsRuntime = createLazyRuntimeModule(() => import("./runtime-tts.runtime.js")); const loadMediaUnderstandingRuntime = createLazyRuntimeModule( () => import("./runtime-media-understanding.runtime.js"), diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index c8c3c0d97e1..cd5868d5de0 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -7,28 +7,9 @@ import { ensureAgentWorkspace } from "../../agents/workspace.js"; import { resolveSessionFilePath, resolveStorePath } from "../../config/sessions/paths.js"; import { loadSessionStore, saveSessionStore } from "../../config/sessions/store.js"; import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; +import { defineCachedValue } from "./runtime-cache.js"; import type { PluginRuntime } from "./types.js"; -function defineCachedValue( - target: T, - key: K, - create: () => unknown, -): void { - let cached: unknown; - let ready = false; - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - get() { - if (!ready) { - cached = create(); - ready = true; - } - return cached; - }, - }); -} - const loadEmbeddedPiRuntime = createLazyRuntimeModule( () => import("./runtime-embedded-pi.runtime.js"), ); diff --git a/src/plugins/runtime/runtime-cache.ts b/src/plugins/runtime/runtime-cache.ts new file mode 100644 index 00000000000..20a4a016411 --- /dev/null +++ b/src/plugins/runtime/runtime-cache.ts @@ -0,0 +1,19 @@ +export function defineCachedValue( + target: T, + key: K, + create: () => unknown, +): void { + let cached: unknown; + let ready = false; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get() { + if (!ready) { + cached = create(); + ready = true; + } + return cached; + }, + }); +} diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 8dec82d1485..ba961768645 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -76,6 +76,7 @@ import { resolveLineAccount, } from "../../plugin-sdk/line.js"; import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js"; +import { defineCachedValue } from "./runtime-cache.js"; import { createRuntimeDiscord } from "./runtime-discord.js"; import { createRuntimeIMessage } from "./runtime-imessage.js"; import { createRuntimeMatrix } from "./runtime-matrix.js"; @@ -85,26 +86,6 @@ import { createRuntimeTelegram } from "./runtime-telegram.js"; import { createRuntimeWhatsApp } from "./runtime-whatsapp.js"; import type { PluginRuntime } from "./types.js"; -function defineCachedValue( - target: T, - key: K, - create: () => unknown, -): void { - let cached: unknown; - let ready = false; - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - get() { - if (!ready) { - cached = create(); - ready = true; - } - return cached; - }, - }); -} - export function createRuntimeChannel(): PluginRuntime["channel"] { const channelRuntime = { text: { diff --git a/src/plugins/runtime/runtime-matrix-boundary.ts b/src/plugins/runtime/runtime-matrix-boundary.ts index a122e613c1f..f608fc611bd 100644 --- a/src/plugins/runtime/runtime-matrix-boundary.ts +++ b/src/plugins/runtime/runtime-matrix-boundary.ts @@ -1,14 +1,9 @@ -import fs from "node:fs"; -import path from "node:path"; import { createJiti } from "jiti"; -import { loadConfig } from "../../config/config.js"; -import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { - buildPluginLoaderJitiOptions, - resolvePluginSdkAliasFile, - resolvePluginSdkScopedAliasMap, - shouldPreferNativeJiti, -} from "../sdk-alias.js"; + loadPluginBoundaryModuleWithJiti, + resolvePluginRuntimeModulePath, + resolvePluginRuntimeRecord, +} from "./runtime-plugin-boundary.js"; const MATRIX_PLUGIN_ID = "matrix"; @@ -24,70 +19,12 @@ let cachedModule: MatrixModule | null = null; const jitiLoaders = new Map>(); -function readConfigSafely() { - try { - return loadConfig(); - } catch { - return {}; - } -} - function resolveMatrixPluginRecord(): MatrixPluginRecord | null { - const manifestRegistry = loadPluginManifestRegistry({ - config: readConfigSafely(), - cache: true, - }); - const record = manifestRegistry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID); - if (!record?.source) { - return null; - } - return { - rootDir: record.rootDir, - source: record.source, - }; + return resolvePluginRuntimeRecord(MATRIX_PLUGIN_ID) as MatrixPluginRecord | null; } function resolveMatrixRuntimeModulePath(record: MatrixPluginRecord): string | null { - const candidates = [ - path.join(path.dirname(record.source), "runtime-api.js"), - path.join(path.dirname(record.source), "runtime-api.ts"), - ...(record.rootDir - ? [path.join(record.rootDir, "runtime-api.js"), path.join(record.rootDir, "runtime-api.ts")] - : []), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - return null; -} - -function getJiti(modulePath: string) { - const tryNative = shouldPreferNativeJiti(modulePath); - const cached = jitiLoaders.get(tryNative); - if (cached) { - return cached; - } - const pluginSdkAlias = resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath, - }); - const aliasMap = { - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap({ modulePath }), - }; - const loader = createJiti(import.meta.url, { - ...buildPluginLoaderJitiOptions(aliasMap), - tryNative, - }); - jitiLoaders.set(tryNative, loader); - return loader; -} - -function loadWithJiti(modulePath: string): TModule { - return getJiti(modulePath)(modulePath) as TModule; + return resolvePluginRuntimeModulePath(record, "runtime-api"); } function loadMatrixModule(): MatrixModule | null { @@ -102,7 +39,7 @@ function loadMatrixModule(): MatrixModule | null { if (cachedModule && cachedModulePath === modulePath) { return cachedModule; } - const loaded = loadWithJiti(modulePath); + const loaded = loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); cachedModulePath = modulePath; cachedModule = loaded; return loaded; diff --git a/src/plugins/runtime/runtime-plugin-boundary.ts b/src/plugins/runtime/runtime-plugin-boundary.ts new file mode 100644 index 00000000000..bdca0acd37e --- /dev/null +++ b/src/plugins/runtime/runtime-plugin-boundary.ts @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createJiti } from "jiti"; +import { loadConfig } from "../../config/config.js"; +import { loadPluginManifestRegistry } from "../manifest-registry.js"; +import { + buildPluginLoaderJitiOptions, + resolvePluginSdkAliasFile, + resolvePluginSdkScopedAliasMap, + shouldPreferNativeJiti, +} from "../sdk-alias.js"; + +type PluginRuntimeRecord = { + origin?: string; + rootDir?: string; + source: string; +}; + +export function readPluginBoundaryConfigSafely() { + try { + return loadConfig(); + } catch { + return {}; + } +} + +export function resolvePluginRuntimeRecord( + pluginId: string, + onMissing?: () => never, +): PluginRuntimeRecord | null { + const manifestRegistry = loadPluginManifestRegistry({ + config: readPluginBoundaryConfigSafely(), + cache: true, + }); + const record = manifestRegistry.plugins.find((plugin) => plugin.id === pluginId); + if (!record?.source) { + if (onMissing) { + onMissing(); + } + return null; + } + return { + ...(record.origin ? { origin: record.origin } : {}), + rootDir: record.rootDir, + source: record.source, + }; +} + +export function resolvePluginRuntimeModulePath( + record: Pick, + entryBaseName: string, + onMissing?: () => never, +): string | null { + const candidates = [ + path.join(path.dirname(record.source), `${entryBaseName}.js`), + path.join(path.dirname(record.source), `${entryBaseName}.ts`), + ...(record.rootDir + ? [ + path.join(record.rootDir, `${entryBaseName}.js`), + path.join(record.rootDir, `${entryBaseName}.ts`), + ] + : []), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + if (onMissing) { + onMissing(); + } + return null; +} + +export function getPluginBoundaryJiti( + modulePath: string, + loaders: Map>, +) { + const tryNative = shouldPreferNativeJiti(modulePath); + const cached = loaders.get(tryNative); + if (cached) { + return cached; + } + const pluginSdkAlias = resolvePluginSdkAliasFile({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + modulePath, + }); + const aliasMap = { + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMap({ modulePath }), + }; + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative, + }); + loaders.set(tryNative, loader); + return loader; +} + +export function loadPluginBoundaryModuleWithJiti( + modulePath: string, + loaders: Map>, +): TModule { + return getPluginBoundaryJiti(modulePath, loaders)(modulePath) as TModule; +} diff --git a/src/plugins/runtime/runtime-whatsapp-boundary.ts b/src/plugins/runtime/runtime-whatsapp-boundary.ts index b44856b799a..a1312677f4e 100644 --- a/src/plugins/runtime/runtime-whatsapp-boundary.ts +++ b/src/plugins/runtime/runtime-whatsapp-boundary.ts @@ -1,21 +1,16 @@ -import fs from "node:fs"; -import path from "node:path"; import { createJiti } from "jiti"; import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js"; -import { loadConfig } from "../../config/config.js"; import { getDefaultLocalRoots as getDefaultLocalRootsImpl, loadWebMedia as loadWebMediaImpl, loadWebMediaRaw as loadWebMediaRawImpl, optimizeImageToJpeg as optimizeImageToJpegImpl, } from "../../media/web-media.js"; -import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { - buildPluginLoaderJitiOptions, - resolvePluginSdkAliasFile, - resolvePluginSdkScopedAliasMap, - shouldPreferNativeJiti, -} from "../sdk-alias.js"; + loadPluginBoundaryModuleWithJiti, + resolvePluginRuntimeModulePath, + resolvePluginRuntimeRecord, +} from "./runtime-plugin-boundary.js"; const WHATSAPP_PLUGIN_ID = "whatsapp"; @@ -35,86 +30,34 @@ let cachedLightModule: WhatsAppLightModule | null = null; const jitiLoaders = new Map>(); -function readConfigSafely() { - try { - return loadConfig(); - } catch { - return {}; - } -} - function resolveWhatsAppPluginRecord(): WhatsAppPluginRecord { - const manifestRegistry = loadPluginManifestRegistry({ - config: readConfigSafely(), - cache: true, - }); - const record = manifestRegistry.plugins.find((plugin) => plugin.id === WHATSAPP_PLUGIN_ID); - if (!record?.source) { + return resolvePluginRuntimeRecord(WHATSAPP_PLUGIN_ID, () => { throw new Error( `WhatsApp plugin runtime is unavailable: missing plugin '${WHATSAPP_PLUGIN_ID}'`, ); - } - return { - origin: record.origin, - rootDir: record.rootDir, - source: record.source, - }; + }) as WhatsAppPluginRecord; } function resolveWhatsAppRuntimeModulePath( record: WhatsAppPluginRecord, entryBaseName: "light-runtime-api" | "runtime-api", ): string { - const candidates = [ - path.join(path.dirname(record.source), `${entryBaseName}.js`), - path.join(path.dirname(record.source), `${entryBaseName}.ts`), - ...(record.rootDir - ? [ - path.join(record.rootDir, `${entryBaseName}.js`), - path.join(record.rootDir, `${entryBaseName}.ts`), - ] - : []), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - throw new Error( - `WhatsApp plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WHATSAPP_PLUGIN_ID}'`, - ); -} - -function getJiti(modulePath: string) { - const tryNative = shouldPreferNativeJiti(modulePath); - const cached = jitiLoaders.get(tryNative); - if (cached) { - return cached; - } - const pluginSdkAlias = resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath: modulePath, + const modulePath = resolvePluginRuntimeModulePath(record, entryBaseName, () => { + throw new Error( + `WhatsApp plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WHATSAPP_PLUGIN_ID}'`, + ); }); - const aliasMap = { - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap({ modulePath }), - }; - const loader = createJiti(import.meta.url, { - ...buildPluginLoaderJitiOptions(aliasMap), - tryNative, - }); - jitiLoaders.set(tryNative, loader); - return loader; -} - -function loadWithJiti(modulePath: string): TModule { - return getJiti(modulePath)(modulePath) as TModule; + if (!modulePath) { + throw new Error( + `WhatsApp plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WHATSAPP_PLUGIN_ID}'`, + ); + } + return modulePath; } function loadCurrentHeavyModuleSync(): WhatsAppHeavyModule { const modulePath = resolveWhatsAppRuntimeModulePath(resolveWhatsAppPluginRecord(), "runtime-api"); - return loadWithJiti(modulePath); + return loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); } function loadWhatsAppLightModule(): WhatsAppLightModule { @@ -125,7 +68,7 @@ function loadWhatsAppLightModule(): WhatsAppLightModule { if (cachedLightModule && cachedLightModulePath === modulePath) { return cachedLightModule; } - const loaded = loadWithJiti(modulePath); + const loaded = loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); cachedLightModulePath = modulePath; cachedLightModule = loaded; return loaded; @@ -137,7 +80,7 @@ async function loadWhatsAppHeavyModule(): Promise { if (cachedHeavyModule && cachedHeavyModulePath === modulePath) { return cachedHeavyModule; } - const loaded = loadWithJiti(modulePath); + const loaded = loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); cachedHeavyModulePath = modulePath; cachedHeavyModule = loaded; return loaded; diff --git a/src/plugins/setup-binary.ts b/src/plugins/setup-binary.ts index c1e534c2944..794d93f1448 100644 --- a/src/plugins/setup-binary.ts +++ b/src/plugins/setup-binary.ts @@ -1,36 +1 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { isSafeExecutableValue } from "../infra/exec-safety.js"; -import { runCommandWithTimeout } from "../process/exec.js"; -import { resolveUserPath } from "../utils.js"; - -export async function detectBinary(name: string): Promise { - if (!name?.trim()) { - return false; - } - if (!isSafeExecutableValue(name)) { - return false; - } - const resolved = name.startsWith("~") ? resolveUserPath(name) : name; - if ( - path.isAbsolute(resolved) || - resolved.startsWith(".") || - resolved.includes("/") || - resolved.includes("\\") - ) { - try { - await fs.access(resolved); - return true; - } catch { - return false; - } - } - - const command = process.platform === "win32" ? ["where", name] : ["/usr/bin/env", "which", name]; - try { - const result = await runCommandWithTimeout(command, { timeoutMs: 2000 }); - return result.code === 0 && result.stdout.trim().length > 0; - } catch { - return false; - } -} +export { detectBinary } from "../infra/detect-binary.js"; diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts new file mode 100644 index 00000000000..0704e334fc5 --- /dev/null +++ b/src/plugins/status.test-helpers.ts @@ -0,0 +1,135 @@ +import type { PluginLoadResult } from "./loader.js"; +import type { PluginRecord } from "./registry.js"; +import type { PluginCompatibilityNotice, PluginStatusReport } from "./status.js"; +import type { PluginHookName } from "./types.js"; + +export const LEGACY_BEFORE_AGENT_START_MESSAGE = + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work."; +export const HOOK_ONLY_MESSAGE = + "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet."; + +export function createCompatibilityNotice( + params: Pick, +): PluginCompatibilityNotice { + if (params.code === "legacy-before-agent-start") { + return { + pluginId: params.pluginId, + code: params.code, + severity: "warn", + message: LEGACY_BEFORE_AGENT_START_MESSAGE, + }; + } + + return { + pluginId: params.pluginId, + code: params.code, + severity: "info", + message: HOOK_ONLY_MESSAGE, + }; +} + +export function createPluginRecord( + overrides: Partial & Pick, +): PluginRecord { + const { id, ...rest } = overrides; + return { + id, + name: overrides.name ?? id, + description: overrides.description ?? "", + source: overrides.source ?? `/tmp/${id}/index.ts`, + origin: overrides.origin ?? "workspace", + enabled: overrides.enabled ?? true, + status: overrides.status ?? "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + ...rest, + }; +} + +export function createTypedHook(params: { + pluginId: string; + hookName: PluginHookName; + source?: string; +}): PluginLoadResult["typedHooks"][number] { + return { + pluginId: params.pluginId, + hookName: params.hookName, + handler: () => undefined, + source: params.source ?? `/tmp/${params.pluginId}/index.ts`, + }; +} + +export function createCustomHook(params: { + pluginId: string; + events: string[]; + name?: string; +}): PluginLoadResult["hooks"][number] { + const source = `/tmp/${params.pluginId}/handler.ts`; + return { + pluginId: params.pluginId, + events: params.events, + source, + entry: { + hook: { + name: params.name ?? "legacy", + description: "", + source: "openclaw-plugin", + pluginId: params.pluginId, + filePath: `/tmp/${params.pluginId}/HOOK.md`, + baseDir: `/tmp/${params.pluginId}`, + handlerPath: source, + }, + frontmatter: {}, + }, + }; +} + +export function createPluginLoadResult( + overrides: Partial & Pick = { plugins: [] }, +): PluginLoadResult { + const { plugins, ...rest } = overrides; + return { + plugins, + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + ...rest, + }; +} + +export function createPluginStatusReport( + overrides: Partial & Pick, +): PluginStatusReport { + const { workspaceDir, ...loadResultOverrides } = overrides; + return { + workspaceDir, + ...createPluginLoadResult(loadResultOverrides), + }; +} diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index c2d30a0cb1b..0a9c599cc3e 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -1,4 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createCompatibilityNotice, + createCustomHook, + createPluginLoadResult, + createPluginRecord, + createTypedHook, + HOOK_ONLY_MESSAGE, + LEGACY_BEFORE_AGENT_START_MESSAGE, +} from "./status.test-helpers.js"; const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); @@ -27,31 +36,22 @@ vi.mock("../agents/workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: () => "/default-workspace", })); +function setPluginLoadResult(overrides: Partial>) { + loadOpenClawPluginsMock.mockReturnValue( + createPluginLoadResult({ + plugins: [], + ...overrides, + }), + ); +} + describe("buildPluginStatusReport", () => { beforeEach(async () => { vi.resetModules(); loadConfigMock.mockReset(); loadOpenClawPluginsMock.mockReset(); loadConfigMock.mockReturnValue({}); - loadOpenClawPluginsMock.mockReturnValue({ - plugins: [], - diagnostics: [], - channels: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - channelSetups: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], - }); + setPluginLoadResult({ plugins: [] }); ({ buildAllPluginInspectReports, buildPluginCompatibilityNotices, @@ -82,50 +82,17 @@ describe("buildPluginStatusReport", () => { }); it("normalizes bundled plugin versions to the core base release", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "whatsapp", name: "WhatsApp", description: "Bundled channel plugin", version: "2026.3.22", - source: "/tmp/whatsapp/index.ts", origin: "bundled", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], channelIds: ["whatsapp"], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - channelSetups: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], }); const report = buildPluginStatusReport({ @@ -152,56 +119,21 @@ describe("buildPluginStatusReport", () => { }, }, }); - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "google", name: "Google", description: "Google provider plugin", - source: "/tmp/google/index.ts", origin: "bundled", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["google"], - speechProviderIds: [], mediaUnderstandingProviderIds: ["google"], imageGenerationProviderIds: ["google"], webSearchProviderIds: ["google"], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], diagnostics: [{ level: "warn", pluginId: "google", message: "watch this surface" }], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [ - { - pluginId: "google", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/google/index.ts", - }, - ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + typedHooks: [createTypedHook({ pluginId: "google", hookName: "before_agent_start" })], }); const inspect = buildPluginInspectReport({ id: "google" }); @@ -217,13 +149,7 @@ describe("buildPluginStatusReport", () => { ]); expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); expect(inspect?.compatibility).toEqual([ - { - pluginId: "google", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }, + createCompatibilityNotice({ pluginId: "google", code: "legacy-before-agent-start" }), ]); expect(inspect?.policy).toEqual({ allowPromptInjection: false, @@ -237,91 +163,25 @@ describe("buildPluginStatusReport", () => { }); it("builds inspect reports for every loaded plugin", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "lca", name: "LCA", description: "Legacy hook plugin", - source: "/tmp/lca/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, - { + }), + createPluginRecord({ id: "microsoft", name: "Microsoft", description: "Hybrid capability plugin", - source: "/tmp/microsoft/index.ts", origin: "bundled", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["microsoft"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], webSearchProviderIds: ["microsoft"], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [ - { - pluginId: "lca", - events: ["message"], - entry: { - hook: { - name: "legacy", - handler: () => undefined, - }, - }, - }, - ], - typedHooks: [ - { - pluginId: "lca", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/lca/index.ts", - }, - ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + hooks: [createCustomHook({ pluginId: "lca", events: ["message"] })], + typedHooks: [createTypedHook({ pluginId: "lca", hookName: "before_agent_start" })], }); const inspect = buildAllPluginInspectReports(); @@ -336,214 +196,58 @@ describe("buildPluginStatusReport", () => { }); it("builds compatibility warnings for legacy compatibility paths", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "lca", name: "LCA", description: "Legacy hook plugin", - source: "/tmp/lca/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [ - { - pluginId: "lca", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/lca/index.ts", - }, - ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + typedHooks: [createTypedHook({ pluginId: "lca", hookName: "before_agent_start" })], }); expect(buildPluginCompatibilityWarnings()).toEqual([ - "lca still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - "lca is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", + `lca ${LEGACY_BEFORE_AGENT_START_MESSAGE}`, + `lca ${HOOK_ONLY_MESSAGE}`, ]); }); it("builds structured compatibility notices with deterministic ordering", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "hook-only", name: "Hook Only", - description: "", - source: "/tmp/hook-only/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, - { + }), + createPluginRecord({ id: "legacy-only", name: "Legacy Only", - description: "", - source: "/tmp/legacy-only/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["legacy-only"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [ - { - pluginId: "hook-only", - events: ["message"], - entry: { - hook: { - name: "legacy", - handler: () => undefined, - }, - }, - }, - ], - typedHooks: [ - { - pluginId: "legacy-only", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/legacy-only/index.ts", - }, - ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + hooks: [createCustomHook({ pluginId: "hook-only", events: ["message"] })], + typedHooks: [createTypedHook({ pluginId: "legacy-only", hookName: "before_agent_start" })], }); expect(buildPluginCompatibilityNotices()).toEqual([ - { - pluginId: "hook-only", - code: "hook-only", - severity: "info", - message: - "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", - }, - { - pluginId: "legacy-only", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }, + createCompatibilityNotice({ pluginId: "hook-only", code: "hook-only" }), + createCompatibilityNotice({ pluginId: "legacy-only", code: "legacy-before-agent-start" }), ]); }); it("returns no compatibility warnings for modern capability plugins", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "modern", name: "Modern", - description: "", - source: "/tmp/modern/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["modern"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], }); expect(buildPluginCompatibilityNotices()).toEqual([]); @@ -551,53 +255,19 @@ describe("buildPluginStatusReport", () => { }); it("populates bundleCapabilities from plugin record", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "claude-bundle", name: "Claude Bundle", description: "A bundle plugin with skills and commands", source: "/tmp/claude-bundle/.claude-plugin/plugin.json", - origin: "workspace", - enabled: true, - status: "loaded", format: "bundle", bundleFormat: "claude", bundleCapabilities: ["skills", "commands", "agents", "settings"], rootDir: "/tmp/claude-bundle", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], }); const inspect = buildPluginInspectReport({ id: "claude-bundle" }); @@ -609,49 +279,15 @@ describe("buildPluginStatusReport", () => { }); it("returns empty bundleCapabilities and mcpServers for non-bundle plugins", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "plain-plugin", name: "Plain Plugin", description: "A regular plugin", - source: "/tmp/plain-plugin/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["plain"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], }); const inspect = buildPluginInspectReport({ id: "plain-plugin" }); @@ -662,27 +298,18 @@ describe("buildPluginStatusReport", () => { }); it("formats and summarizes compatibility notices", () => { - const notice = { + const notice = createCompatibilityNotice({ pluginId: "legacy-plugin", - code: "legacy-before-agent-start" as const, - severity: "warn" as const, - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }; + code: "legacy-before-agent-start", + }); expect(formatPluginCompatibilityNotice(notice)).toBe( - "legacy-plugin still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", + `legacy-plugin ${LEGACY_BEFORE_AGENT_START_MESSAGE}`, ); expect( summarizePluginCompatibility([ notice, - { - pluginId: "legacy-plugin", - code: "hook-only", - severity: "info", - message: - "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", - }, + createCompatibilityNotice({ pluginId: "legacy-plugin", code: "hook-only" }), ]), ).toEqual({ noticeCount: 2, diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 46b5a0c9fed..fcb37fcc153 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -93,6 +93,7 @@ describe("resolvePluginWebSearchProviders", () => { loadPluginManifestRegistryMock = vi .spyOn(manifestRegistryModule, "loadPluginManifestRegistry") .mockReturnValue({ + diagnostics: [], plugins: [ { id: "brave", diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 609527d5970..631437f008d 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -1,6 +1,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isRecord } from "../utils.js"; +import { + buildPluginSnapshotCacheEnvKey, + resolvePluginSnapshotCacheTtlMs, + shouldUsePluginSnapshotCache, +} from "./cache-controls.js"; import { loadOpenClawPlugins } from "./loader.js"; import type { PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -32,55 +37,6 @@ function resetWebSearchProviderSnapshotCacheForTests() { export const __testing = { resetWebSearchProviderSnapshotCacheForTests, } as const; - -const DEFAULT_DISCOVERY_CACHE_MS = 1000; -const DEFAULT_MANIFEST_CACHE_MS = 1000; - -function shouldUseWebSearchProviderSnapshotCache(env: NodeJS.ProcessEnv): boolean { - if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { - return false; - } - if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { - return false; - } - const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); - if (discoveryCacheMs === "0") { - return false; - } - const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); - if (manifestCacheMs === "0") { - return false; - } - return true; -} - -function resolveWebSearchProviderSnapshotCacheTtlMs(env: NodeJS.ProcessEnv): number { - const discoveryCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, - DEFAULT_DISCOVERY_CACHE_MS, - ); - const manifestCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, - DEFAULT_MANIFEST_CACHE_MS, - ); - return Math.min(discoveryCacheMs, manifestCacheMs); -} - -function resolveCacheMs(rawValue: string | undefined, defaultMs: number): number { - const raw = rawValue?.trim(); - if (raw === "" || raw === "0") { - return 0; - } - if (!raw) { - return defaultMs; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return defaultMs; - } - return Math.max(0, parsed); -} - function buildWebSearchSnapshotCacheKey(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -92,21 +48,9 @@ function buildWebSearchSnapshotCacheKey(params: { workspaceDir: params.workspaceDir ?? "", bundledAllowlistCompat: params.bundledAllowlistCompat === true, config: params.config ?? null, - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: params.env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: params.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: params.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", - OPENCLAW_HOME: params.env.OPENCLAW_HOME ?? "", - OPENCLAW_STATE_DIR: params.env.OPENCLAW_STATE_DIR ?? "", - OPENCLAW_CONFIG_PATH: params.env.OPENCLAW_CONFIG_PATH ?? "", - HOME: params.env.HOME ?? "", - USERPROFILE: params.env.USERPROFILE ?? "", - VITEST: effectiveVitest, - }, + env: buildPluginSnapshotCacheEnvKey(params.env, { + includeProcessVitestFallback: effectiveVitest !== (params.env.VITEST ?? ""), + }), }); } @@ -150,9 +94,7 @@ export function resolvePluginWebSearchProviders(params: { const env = params.env ?? process.env; const cacheOwnerConfig = params.config; const shouldMemoizeSnapshot = - params.activate !== true && - params.cache !== true && - shouldUseWebSearchProviderSnapshotCache(env); + params.activate !== true && params.cache !== true && shouldUsePluginSnapshotCache(env); const cacheKey = buildWebSearchSnapshotCacheKey({ config: cacheOwnerConfig, workspaceDir: params.workspaceDir, @@ -193,7 +135,7 @@ export function resolvePluginWebSearchProviders(params: { })), ); if (cacheOwnerConfig && shouldMemoizeSnapshot) { - const ttlMs = resolveWebSearchProviderSnapshotCacheTtlMs(env); + const ttlMs = resolvePluginSnapshotCacheTtlMs(env); let configCache = webSearchProviderSnapshotCache.get(cacheOwnerConfig); if (!configCache) { configCache = new WeakMap<