From 1e6e68534786ad0ad5b5ababce1e5c19df5ce67d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 02:35:23 +0100 Subject: [PATCH] fix: unblock cli startup metadata --- extensions/browser/cli-metadata.ts | 16 ++ extensions/discord/contract-surfaces.ts | 10 + extensions/feishu/contract-surfaces.ts | 5 + extensions/googlechat/contract-surfaces.ts | 4 + extensions/googlechat/src/secret-contract.ts | 2 +- extensions/imessage/contract-surfaces.ts | 103 +++++++++ extensions/line/contract-surfaces.ts | 1 + extensions/matrix/cli-metadata.ts | 9 + extensions/matrix/contract-surfaces.ts | 9 + extensions/matrix/index.ts | 19 +- extensions/matrix/src/cli-metadata.ts | 19 ++ extensions/mattermost/contract-surfaces.ts | 4 + extensions/memory-core/cli-metadata.ts | 24 ++ extensions/memory-lancedb/cli-metadata.ts | 10 + extensions/signal/contract-surfaces.ts | 1 + extensions/slack/contract-surfaces.ts | 5 + extensions/synology-chat/contract-surfaces.ts | 1 + extensions/telegram/contract-surfaces.ts | 6 + extensions/voice-call/cli-metadata.ts | 10 + extensions/whatsapp/contract-surfaces.ts | 49 ++++ extensions/zalo/contract-surfaces.ts | 4 + extensions/zalouser/contract-surfaces.ts | 1 + scripts/write-cli-startup-metadata.ts | 37 +-- src/channels/plugins/contract-surfaces.ts | 21 +- src/cli/program/root-help.ts | 21 +- src/cli/root-help-metadata.ts | 43 ++++ src/cli/run-main.exit.test.ts | 7 + src/cli/run-main.ts | 7 +- src/entry.test.ts | 18 ++ src/entry.ts | 10 +- src/plugins/cli.ts | 10 +- src/plugins/loader.cli-metadata.test.ts | 218 ++++++++++++++++++ src/plugins/loader.ts | 28 ++- src/secrets/credential-matrix.ts | 4 +- .../unsupported-surface-policy.test.ts | 4 +- src/secrets/unsupported-surface-policy.ts | 13 +- 36 files changed, 674 insertions(+), 79 deletions(-) create mode 100644 extensions/browser/cli-metadata.ts create mode 100644 extensions/discord/contract-surfaces.ts create mode 100644 extensions/feishu/contract-surfaces.ts create mode 100644 extensions/googlechat/contract-surfaces.ts create mode 100644 extensions/imessage/contract-surfaces.ts create mode 100644 extensions/line/contract-surfaces.ts create mode 100644 extensions/matrix/cli-metadata.ts create mode 100644 extensions/matrix/contract-surfaces.ts create mode 100644 extensions/matrix/src/cli-metadata.ts create mode 100644 extensions/mattermost/contract-surfaces.ts create mode 100644 extensions/memory-core/cli-metadata.ts create mode 100644 extensions/memory-lancedb/cli-metadata.ts create mode 100644 extensions/signal/contract-surfaces.ts create mode 100644 extensions/slack/contract-surfaces.ts create mode 100644 extensions/synology-chat/contract-surfaces.ts create mode 100644 extensions/telegram/contract-surfaces.ts create mode 100644 extensions/voice-call/cli-metadata.ts create mode 100644 extensions/whatsapp/contract-surfaces.ts create mode 100644 extensions/zalo/contract-surfaces.ts create mode 100644 extensions/zalouser/contract-surfaces.ts create mode 100644 src/cli/root-help-metadata.ts diff --git a/extensions/browser/cli-metadata.ts b/extensions/browser/cli-metadata.ts new file mode 100644 index 00000000000..bf1452f77e4 --- /dev/null +++ b/extensions/browser/cli-metadata.ts @@ -0,0 +1,16 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; + +export default definePluginEntry({ + id: "browser", + name: "Browser", + description: "Default browser tool plugin", + register(api) { + api.registerCli( + async ({ program }) => { + const { registerBrowserCli } = await import("./runtime-api.js"); + registerBrowserCli(program); + }, + { commands: ["browser"] }, + ); + }, +}); diff --git a/extensions/discord/contract-surfaces.ts b/extensions/discord/contract-surfaces.ts new file mode 100644 index 00000000000..b271964d1e6 --- /dev/null +++ b/extensions/discord/contract-surfaces.ts @@ -0,0 +1,10 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-config-contract.js"; +export { + unsupportedSecretRefSurfacePatterns, + collectUnsupportedSecretRefConfigCandidates, +} from "./src/security-contract.js"; +export { deriveLegacySessionChatType } from "./src/session-contract.js"; diff --git a/extensions/feishu/contract-surfaces.ts b/extensions/feishu/contract-surfaces.ts new file mode 100644 index 00000000000..0334b87cbc9 --- /dev/null +++ b/extensions/feishu/contract-surfaces.ts @@ -0,0 +1,5 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; +export { messageActionTargetAliases } from "./src/message-action-contract.js"; diff --git a/extensions/googlechat/contract-surfaces.ts b/extensions/googlechat/contract-surfaces.ts new file mode 100644 index 00000000000..bc8f64f050f --- /dev/null +++ b/extensions/googlechat/contract-surfaces.ts @@ -0,0 +1,4 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/googlechat/src/secret-contract.ts b/extensions/googlechat/src/secret-contract.ts index 188486d6104..e3d3d04ee7c 100644 --- a/extensions/googlechat/src/secret-contract.ts +++ b/extensions/googlechat/src/secret-contract.ts @@ -1,4 +1,4 @@ -import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime"; +import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth"; import { getChannelSurface, hasOwnProperty, diff --git a/extensions/imessage/contract-surfaces.ts b/extensions/imessage/contract-surfaces.ts new file mode 100644 index 00000000000..52b3799edff --- /dev/null +++ b/extensions/imessage/contract-surfaces.ts @@ -0,0 +1,103 @@ +import path from "node:path"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { resolveIMessageAccount } from "./src/accounts.js"; + +const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const; +const WILDCARD_SEGMENT = "*"; +const WINDOWS_DRIVE_ABS_RE = /^[A-Za-z]:\//; +const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:$/; + +function normalizePosixAbsolutePath(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed || trimmed.includes("\0")) { + return undefined; + } + const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/")); + const isAbsolute = normalized.startsWith("/") || WINDOWS_DRIVE_ABS_RE.test(normalized); + if (!isAbsolute || normalized === "/") { + return undefined; + } + const withoutTrailingSlash = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; + if (WINDOWS_DRIVE_ROOT_RE.test(withoutTrailingSlash)) { + return undefined; + } + return withoutTrailingSlash; +} + +function splitPathSegments(value: string): string[] { + return value.split("/").filter(Boolean); +} + +function isValidInboundPathRootPattern(value: string): boolean { + const normalized = normalizePosixAbsolutePath(value); + if (!normalized) { + return false; + } + const segments = splitPathSegments(normalized); + if (segments.length === 0) { + return false; + } + return segments.every((segment) => segment === WILDCARD_SEGMENT || !segment.includes("*")); +} + +function normalizeInboundPathRoots(roots?: readonly string[]): string[] { + const normalized: string[] = []; + const seen = new Set(); + for (const root of roots ?? []) { + if (typeof root !== "string") { + continue; + } + if (!isValidInboundPathRootPattern(root)) { + continue; + } + const candidate = normalizePosixAbsolutePath(root); + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + normalized.push(candidate); + } + return normalized; +} + +function mergeInboundPathRoots(...rootsLists: Array): string[] { + const merged: string[] = []; + const seen = new Set(); + for (const roots of rootsLists) { + const normalized = normalizeInboundPathRoots(roots); + for (const root of normalized) { + if (seen.has(root)) { + continue; + } + seen.add(root); + merged.push(root); + } + } + return merged; +} + +export function resolveInboundAttachmentRoots(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + const account = resolveIMessageAccount(params); + return mergeInboundPathRoots( + account.config.attachmentRoots, + params.cfg.channels?.imessage?.attachmentRoots, + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + ); +} + +export function resolveRemoteInboundAttachmentRoots(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + const account = resolveIMessageAccount(params); + return mergeInboundPathRoots( + account.config.remoteAttachmentRoots, + params.cfg.channels?.imessage?.remoteAttachmentRoots, + account.config.attachmentRoots, + params.cfg.channels?.imessage?.attachmentRoots, + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + ); +} diff --git a/extensions/line/contract-surfaces.ts b/extensions/line/contract-surfaces.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/extensions/line/contract-surfaces.ts @@ -0,0 +1 @@ +export {}; diff --git a/extensions/matrix/cli-metadata.ts b/extensions/matrix/cli-metadata.ts new file mode 100644 index 00000000000..aad8ffba7ef --- /dev/null +++ b/extensions/matrix/cli-metadata.ts @@ -0,0 +1,9 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { registerMatrixCliMetadata } from "./src/cli-metadata.js"; + +export default definePluginEntry({ + id: "matrix", + name: "Matrix", + description: "Matrix channel plugin (matrix-js-sdk)", + register: registerMatrixCliMetadata, +}); diff --git a/extensions/matrix/contract-surfaces.ts b/extensions/matrix/contract-surfaces.ts new file mode 100644 index 00000000000..ad6efb034ad --- /dev/null +++ b/extensions/matrix/contract-surfaces.ts @@ -0,0 +1,9 @@ +export { + namedAccountPromotionKeys, + resolveSingleAccountPromotionTarget, + singleAccountKeysToMove, +} from "./src/setup-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index fdbeaa2bc61..e458e2c6bcd 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -1,5 +1,6 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { matrixPlugin } from "./src/channel.js"; +import { registerMatrixCliMetadata } from "./src/cli-metadata.js"; import { setMatrixRuntime } from "./src/runtime.js"; export { matrixPlugin } from "./src/channel.js"; @@ -11,23 +12,7 @@ export default defineChannelPluginEntry({ description: "Matrix channel plugin (matrix-js-sdk)", plugin: matrixPlugin, setRuntime: setMatrixRuntime, - registerCliMetadata(api) { - api.registerCli( - async ({ program }) => { - const { registerMatrixCli } = await import("./src/cli.js"); - registerMatrixCli({ program }); - }, - { - descriptors: [ - { - name: "matrix", - description: "Manage Matrix accounts, verification, devices, and profile state", - hasSubcommands: true, - }, - ], - }, - ); - }, + registerCliMetadata: registerMatrixCliMetadata, registerFull(api) { void import("./src/plugin-entry.runtime.js") .then(({ ensureMatrixCryptoRuntime }) => diff --git a/extensions/matrix/src/cli-metadata.ts b/extensions/matrix/src/cli-metadata.ts new file mode 100644 index 00000000000..30767f3b807 --- /dev/null +++ b/extensions/matrix/src/cli-metadata.ts @@ -0,0 +1,19 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +export function registerMatrixCliMetadata(api: OpenClawPluginApi) { + api.registerCli( + async ({ program }) => { + const { registerMatrixCli } = await import("./cli.js"); + registerMatrixCli({ program }); + }, + { + descriptors: [ + { + name: "matrix", + description: "Manage Matrix accounts, verification, devices, and profile state", + hasSubcommands: true, + }, + ], + }, + ); +} diff --git a/extensions/mattermost/contract-surfaces.ts b/extensions/mattermost/contract-surfaces.ts new file mode 100644 index 00000000000..bc8f64f050f --- /dev/null +++ b/extensions/mattermost/contract-surfaces.ts @@ -0,0 +1,4 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/memory-core/cli-metadata.ts b/extensions/memory-core/cli-metadata.ts new file mode 100644 index 00000000000..677d819c0e1 --- /dev/null +++ b/extensions/memory-core/cli-metadata.ts @@ -0,0 +1,24 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; + +export default definePluginEntry({ + id: "memory-core", + name: "Memory (Core)", + description: "File-backed memory search tools and CLI", + register(api) { + api.registerCli( + async ({ program }) => { + const { registerMemoryCli } = await import("./src/cli.js"); + registerMemoryCli(program); + }, + { + descriptors: [ + { + name: "memory", + description: "Search, inspect, and reindex memory files", + hasSubcommands: true, + }, + ], + }, + ); + }, +}); diff --git a/extensions/memory-lancedb/cli-metadata.ts b/extensions/memory-lancedb/cli-metadata.ts new file mode 100644 index 00000000000..ecee32649c6 --- /dev/null +++ b/extensions/memory-lancedb/cli-metadata.ts @@ -0,0 +1,10 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; + +export default definePluginEntry({ + id: "memory-lancedb", + name: "Memory LanceDB", + description: "LanceDB-backed memory provider", + register(api) { + api.registerCli(() => {}, { commands: ["ltm"] }); + }, +}); diff --git a/extensions/signal/contract-surfaces.ts b/extensions/signal/contract-surfaces.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/extensions/signal/contract-surfaces.ts @@ -0,0 +1 @@ +export {}; diff --git a/extensions/slack/contract-surfaces.ts b/extensions/slack/contract-surfaces.ts new file mode 100644 index 00000000000..274710269c3 --- /dev/null +++ b/extensions/slack/contract-surfaces.ts @@ -0,0 +1,5 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/synology-chat/contract-surfaces.ts b/extensions/synology-chat/contract-surfaces.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/extensions/synology-chat/contract-surfaces.ts @@ -0,0 +1 @@ +export {}; diff --git a/extensions/telegram/contract-surfaces.ts b/extensions/telegram/contract-surfaces.ts new file mode 100644 index 00000000000..ff4d211e34e --- /dev/null +++ b/extensions/telegram/contract-surfaces.ts @@ -0,0 +1,6 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; +export { singleAccountKeysToMove } from "./src/setup-contract.js"; diff --git a/extensions/voice-call/cli-metadata.ts b/extensions/voice-call/cli-metadata.ts new file mode 100644 index 00000000000..dc1519256cb --- /dev/null +++ b/extensions/voice-call/cli-metadata.ts @@ -0,0 +1,10 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; + +export default definePluginEntry({ + id: "voice-call", + name: "Voice Call", + description: "Voice call channel plugin", + register(api) { + api.registerCli(() => {}, { commands: ["voicecall"] }); + }, +}); diff --git a/extensions/whatsapp/contract-surfaces.ts b/extensions/whatsapp/contract-surfaces.ts new file mode 100644 index 00000000000..421fd8d6ac4 --- /dev/null +++ b/extensions/whatsapp/contract-surfaces.ts @@ -0,0 +1,49 @@ +type UnsupportedSecretRefConfigCandidate = { + path: string; + value: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export const unsupportedSecretRefSurfacePatterns = [ + "channels.whatsapp.creds.json", + "channels.whatsapp.accounts.*.creds.json", +] as const; + +export function collectUnsupportedSecretRefConfigCandidates( + raw: unknown, +): UnsupportedSecretRefConfigCandidate[] { + if (!isRecord(raw)) { + return []; + } + if (!isRecord(raw.channels) || !isRecord(raw.channels.whatsapp)) { + return []; + } + + const candidates: UnsupportedSecretRefConfigCandidate[] = []; + const whatsapp = raw.channels.whatsapp; + const creds = isRecord(whatsapp.creds) ? whatsapp.creds : null; + if (creds) { + candidates.push({ + path: "channels.whatsapp.creds.json", + value: creds.json, + }); + } + + const accounts = isRecord(whatsapp.accounts) ? whatsapp.accounts : null; + if (!accounts) { + return candidates; + } + for (const [accountId, account] of Object.entries(accounts)) { + if (!isRecord(account) || !isRecord(account.creds)) { + continue; + } + candidates.push({ + path: `channels.whatsapp.accounts.${accountId}.creds.json`, + value: account.creds.json, + }); + } + return candidates; +} diff --git a/extensions/zalo/contract-surfaces.ts b/extensions/zalo/contract-surfaces.ts new file mode 100644 index 00000000000..bc8f64f050f --- /dev/null +++ b/extensions/zalo/contract-surfaces.ts @@ -0,0 +1,4 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/zalouser/contract-surfaces.ts b/extensions/zalouser/contract-surfaces.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/extensions/zalouser/contract-surfaces.ts @@ -0,0 +1 @@ +export {}; diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index c2103dad6ce..402577ca8c0 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -1,6 +1,7 @@ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; +import { renderRootHelpText } from "../src/cli/program/root-help.ts"; function dedupe(values: string[]): string[] { const seen = new Set(); @@ -78,40 +79,10 @@ export function readBundledChannelCatalogIds( .map((entry) => entry.id); } -async function captureStdout(action: () => void | Promise): Promise { - let output = ""; - const originalWrite = process.stdout.write.bind(process.stdout); - const captureWrite: typeof process.stdout.write = ((chunk: string | Uint8Array) => { - output += String(chunk); - return true; - }) as typeof process.stdout.write; - process.stdout.write = captureWrite; - try { - await action(); - } finally { - process.stdout.write = originalWrite; - } - return output; -} - export async function renderBundledRootHelpText( - distDirOverride: string = distDir, + _distDirOverride: string = distDir, ): Promise { - const bundleName = readdirSync(distDirOverride).find( - (entry) => entry.startsWith("root-help-") && entry.endsWith(".js"), - ); - if (!bundleName) { - throw new Error("No root-help bundle found in dist; cannot write CLI startup metadata."); - } - const moduleUrl = pathToFileURL(path.join(distDirOverride, bundleName)).href; - const mod = (await import(moduleUrl)) as { outputRootHelp?: () => void | Promise }; - if (typeof mod.outputRootHelp !== "function") { - throw new Error(`Bundle ${bundleName} does not export outputRootHelp.`); - } - - return captureStdout(async () => { - await mod.outputRootHelp?.(); - }); + return await renderRootHelpText({ pluginDescriptors: [] }); } export async function writeCliStartupMetadata(options?: { diff --git a/src/channels/plugins/contract-surfaces.ts b/src/channels/plugins/contract-surfaces.ts index b828ce36b0d..591e47dde86 100644 --- a/src/channels/plugins/contract-surfaces.ts +++ b/src/channels/plugins/contract-surfaces.ts @@ -9,7 +9,12 @@ import { shouldPreferNativeJiti, } from "../../plugins/sdk-alias.js"; -const CONTRACT_BASENAME = "contract-api.ts"; +const CONTRACT_SURFACE_BASENAMES = [ + "contract-surfaces.ts", + "contract-surfaces.js", + "contract-api.ts", + "contract-api.js", +] as const; let cachedSurfaces: unknown[] | null = null; let cachedSurfaceEntries: Array<{ @@ -41,6 +46,16 @@ function createModuleLoader() { const loadModule = createModuleLoader(); +function resolveContractSurfaceModulePath(rootDir: string): string | null { + for (const basename of CONTRACT_SURFACE_BASENAMES) { + const modulePath = path.join(rootDir, basename); + if (fs.existsSync(modulePath)) { + return modulePath; + } + } + return null; +} + function loadBundledChannelContractSurfaces(): unknown[] { return loadBundledChannelContractSurfaceEntries().map((entry) => entry.surface); } @@ -61,8 +76,8 @@ function loadBundledChannelContractSurfaceEntries(): Array<{ if (manifest.origin !== "bundled" || manifest.channels.length === 0) { continue; } - const modulePath = path.join(manifest.rootDir, CONTRACT_BASENAME); - if (!fs.existsSync(modulePath)) { + const modulePath = resolveContractSurfaceModulePath(manifest.rootDir); + if (!modulePath) { continue; } try { diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index 3c6504c7039..9322d47c607 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,11 +1,16 @@ import { Command } from "commander"; import { getPluginCliCommandDescriptors } from "../../plugins/cli.js"; +import type { OpenClawPluginCliCommandDescriptor } from "../../plugins/types.js"; import { VERSION } from "../../version.js"; import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; import { getSubCliEntries } from "./subcli-descriptors.js"; -async function buildRootHelpProgram(): Promise { +type RootHelpRenderOptions = { + pluginDescriptors?: OpenClawPluginCliCommandDescriptor[] | null; +}; + +async function buildRootHelpProgram(options?: RootHelpRenderOptions): Promise { const program = new Command(); configureProgramHelp(program, { programVersion: VERSION, @@ -26,7 +31,11 @@ async function buildRootHelpProgram(): Promise { program.command(command.name).description(command.description); existingCommands.add(command.name); } - for (const command of await getPluginCliCommandDescriptors()) { + const pluginDescriptors = + options && "pluginDescriptors" in options + ? (options.pluginDescriptors ?? []) + : await getPluginCliCommandDescriptors(); + for (const command of pluginDescriptors) { if (existingCommands.has(command.name)) { continue; } @@ -37,8 +46,8 @@ async function buildRootHelpProgram(): Promise { return program; } -export async function renderRootHelpText(): Promise { - const program = await buildRootHelpProgram(); +export async function renderRootHelpText(options?: RootHelpRenderOptions): Promise { + const program = await buildRootHelpProgram(options); let output = ""; const originalWrite = process.stdout.write.bind(process.stdout); const captureWrite: typeof process.stdout.write = ((chunk: string | Uint8Array) => { @@ -54,6 +63,6 @@ export async function renderRootHelpText(): Promise { return output; } -export async function outputRootHelp(): Promise { - process.stdout.write(await renderRootHelpText()); +export async function outputRootHelp(options?: RootHelpRenderOptions): Promise { + process.stdout.write(await renderRootHelpText(options)); } diff --git a/src/cli/root-help-metadata.ts b/src/cli/root-help-metadata.ts new file mode 100644 index 00000000000..5089790a924 --- /dev/null +++ b/src/cli/root-help-metadata.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +let precomputedRootHelpText: string | null | undefined; + +export function loadPrecomputedRootHelpText(): string | null { + if (precomputedRootHelpText !== undefined) { + return precomputedRootHelpText; + } + try { + const metadataPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "cli-startup-metadata.json", + ); + const raw = fs.readFileSync(metadataPath, "utf8"); + const parsed = JSON.parse(raw) as { rootHelpText?: unknown }; + if (typeof parsed.rootHelpText === "string" && parsed.rootHelpText.length > 0) { + precomputedRootHelpText = parsed.rootHelpText; + return precomputedRootHelpText; + } + } catch { + // Fall back to live root-help rendering. + } + precomputedRootHelpText = null; + return null; +} + +export function outputPrecomputedRootHelpText(): boolean { + const rootHelpText = loadPrecomputedRootHelpText(); + if (!rootHelpText) { + return false; + } + process.stdout.write(rootHelpText); + return true; +} + +export const __testing = { + resetPrecomputedRootHelpTextForTests(): void { + precomputedRootHelpText = undefined; + }, +}; diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index dd29b76bb1d..4f3810ca4a8 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -12,6 +12,7 @@ const hasMemoryRuntimeMock = vi.hoisted(() => vi.fn(() => false)); const ensureTaskRegistryReadyMock = vi.hoisted(() => vi.fn()); const startTaskRegistryMaintenanceMock = vi.hoisted(() => vi.fn()); const outputRootHelpMock = vi.hoisted(() => vi.fn()); +const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false)); const buildProgramMock = vi.hoisted(() => vi.fn()); const maybeRunCliInContainerMock = vi.hoisted(() => vi.fn< @@ -64,6 +65,10 @@ vi.mock("./program/root-help.js", () => ({ outputRootHelp: outputRootHelpMock, })); +vi.mock("./root-help-metadata.js", () => ({ + outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock, +})); + vi.mock("./program.js", () => ({ buildProgram: buildProgramMock, })); @@ -72,6 +77,7 @@ describe("runCli exit behavior", () => { beforeEach(() => { vi.clearAllMocks(); hasMemoryRuntimeMock.mockReturnValue(false); + outputPrecomputedRootHelpTextMock.mockReturnValue(false); }); it("does not force process.exit after successful routed command", async () => { @@ -100,6 +106,7 @@ describe("runCli exit behavior", () => { expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "--help"]); expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1); expect(outputRootHelpMock).toHaveBeenCalledTimes(1); expect(buildProgramMock).not.toHaveBeenCalled(); expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled(); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 63eb0167d63..eba7718ab84 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -158,8 +158,11 @@ export async function runCli(argv: string[] = process.argv) { try { if (shouldUseRootHelpFastPath(normalizedArgv)) { - const { outputRootHelp } = await import("./program/root-help.js"); - await outputRootHelp(); + const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js"); + if (!outputPrecomputedRootHelpText()) { + const { outputRootHelp } = await import("./program/root-help.js"); + await outputRootHelp(); + } return; } diff --git a/src/entry.test.ts b/src/entry.test.ts index f02b119c627..e6ff5adda88 100644 --- a/src/entry.test.ts +++ b/src/entry.test.ts @@ -1,7 +1,25 @@ import { describe, expect, it, vi } from "vitest"; import { tryHandleRootHelpFastPath } from "./entry.js"; +const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false)); + +vi.mock("./cli/root-help-metadata.js", () => ({ + outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock, +})); + describe("entry root help fast path", () => { + it("prefers precomputed root help text when available", async () => { + outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true); + + const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { + env: {}, + }); + await vi.dynamicImportSettled(); + + expect(handled).toBe(true); + expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1); + }); + it("renders root help without importing the full program", async () => { const outputRootHelpMock = vi.fn(); diff --git a/src/entry.ts b/src/entry.ts index 156e6fcf5fb..d8fd2bc718a 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -185,9 +185,13 @@ export function tryHandleRootHelpFastPath( .catch(handleError); return true; } - import("./cli/program/root-help.js") - .then(({ outputRootHelp }) => { - return outputRootHelp(); + import("./cli/root-help-metadata.js") + .then(async ({ outputPrecomputedRootHelpText }) => { + if (outputPrecomputedRootHelpText()) { + return; + } + const { outputRootHelp } = await import("./cli/program/root-help.js"); + await outputRootHelp(); }) .catch(handleError); return true; diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 2ecdfd7d87b..3d8f639ac87 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -46,13 +46,13 @@ function mergeCliRegistrars(params: { runtimeRegistry: PluginRegistry; metadataRegistry: PluginRegistry; }) { - const metadataCommands = new Set( - params.metadataRegistry.cliRegistrars.flatMap((entry) => entry.commands), + const runtimeCommands = new Set( + params.runtimeRegistry.cliRegistrars.flatMap((entry) => entry.commands), ); return [ - ...params.metadataRegistry.cliRegistrars, - ...params.runtimeRegistry.cliRegistrars.filter( - (entry) => !entry.commands.some((command) => metadataCommands.has(command)), + ...params.runtimeRegistry.cliRegistrars, + ...params.metadataRegistry.cliRegistrars.filter( + (entry) => !entry.commands.some((command) => runtimeCommands.has(command)), ), ]; } diff --git a/src/plugins/loader.cli-metadata.test.ts b/src/plugins/loader.cli-metadata.test.ts index 30df6fdd9a8..b2af1e507dc 100644 --- a/src/plugins/loader.cli-metadata.test.ts +++ b/src/plugins/loader.cli-metadata.test.ts @@ -189,6 +189,224 @@ module.exports = { ); }); + it("skips bundled channel full entries that do not provide a dedicated cli-metadata entry", async () => { + const bundledRoot = makeTempDir(); + const pluginDir = path.join(bundledRoot, "bundled-skip-channel"); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + + fs.mkdirSync(pluginDir, { recursive: true }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot; + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/bundled-skip-channel", + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "bundled-skip-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["bundled-skip-channel"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "bundled-skip-channel", + register() { + throw new Error("bundled channel full entry should not load during CLI metadata capture"); + }, +};`, + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + allow: ["bundled-skip-channel"], + entries: { + "bundled-skip-channel": { + enabled: true, + }, + }, + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain( + "bundled-skip-channel", + ); + expect(registry.plugins.find((entry) => entry.id === "bundled-skip-channel")?.status).toBe( + "loaded", + ); + }); + + it("prefers bundled channel cli-metadata entries over full channel entries", async () => { + const bundledRoot = makeTempDir(); + const pluginDir = path.join(bundledRoot, "bundled-cli-channel"); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const cliMarker = path.join(pluginDir, "cli-loaded.txt"); + + fs.mkdirSync(pluginDir, { recursive: true }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot; + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/bundled-cli-channel", + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "bundled-cli-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["bundled-cli-channel"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "bundled-cli-channel", + register() { + throw new Error("bundled channel full entry should not load during CLI metadata capture"); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "cli-metadata.cjs"), + `module.exports = { + id: "bundled-cli-channel", + register(api) { + require("node:fs").writeFileSync(${JSON.stringify(cliMarker)}, "loaded", "utf-8"); + api.registerCli(() => {}, { + descriptors: [ + { + name: "bundled-cli-channel", + description: "Bundled channel CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, +};`, + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + allow: ["bundled-cli-channel"], + entries: { + "bundled-cli-channel": { + enabled: true, + }, + }, + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(false); + expect(fs.existsSync(cliMarker)).toBe(true); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( + "bundled-cli-channel", + ); + }); + + it("skips bundled non-channel full entries that do not provide a dedicated cli-metadata entry", async () => { + const bundledRoot = makeTempDir(); + const pluginDir = path.join(bundledRoot, "bundled-skip-provider"); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + + fs.mkdirSync(pluginDir, { recursive: true }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot; + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/bundled-skip-provider", + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "bundled-skip-provider", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "bundled-skip-provider", + register() { + throw new Error("bundled provider full entry should not load during CLI metadata capture"); + }, +};`, + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + allow: ["bundled-skip-provider"], + entries: { + "bundled-skip-provider": { + enabled: true, + }, + }, + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain( + "bundled-skip-provider", + ); + expect(registry.plugins.find((entry) => entry.id === "bundled-skip-provider")?.status).toBe( + "loaded", + ); + }); + it("collects channel CLI metadata during full plugin loads", () => { useNoBundledPlugins(); const pluginDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 312c283348f..e1625766b06 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -103,6 +103,13 @@ export type PluginLoadOptions = { throwOnLoadError?: boolean; }; +const CLI_METADATA_ENTRY_BASENAMES = [ + "cli-metadata.ts", + "cli-metadata.js", + "cli-metadata.mjs", + "cli-metadata.cjs", +] as const; + export class PluginLoadFailureError extends Error { readonly pluginIds: string[]; readonly registry: PluginRegistry; @@ -1810,8 +1817,17 @@ export async function loadOpenClawPluginCliRegistry( } const pluginRoot = safeRealpathOrResolve(candidate.rootDir); + const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir); + const sourceForCliMetadata = + candidate.origin === "bundled" ? cliMetadataSource : (cliMetadataSource ?? candidate.source); + if (!sourceForCliMetadata) { + record.status = "loaded"; + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } const opened = openBoundaryFileSync({ - absolutePath: candidate.source, + absolutePath: sourceForCliMetadata, rootPath: pluginRoot, boundaryLabel: "plugin root", rejectHardlinks: candidate.origin !== "bundled", @@ -1943,3 +1959,13 @@ function safeRealpathOrResolve(value: string): string { return path.resolve(value); } } + +function resolveCliMetadataEntrySource(rootDir: string): string | null { + for (const basename of CLI_METADATA_ENTRY_BASENAMES) { + const candidate = path.join(rootDir, basename); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} diff --git a/src/secrets/credential-matrix.ts b/src/secrets/credential-matrix.ts index 1c4bd49bf98..1ee443ed16e 100644 --- a/src/secrets/credential-matrix.ts +++ b/src/secrets/credential-matrix.ts @@ -1,5 +1,5 @@ import { listSecretTargetRegistryEntries } from "./target-registry.js"; -import { UNSUPPORTED_SECRETREF_SURFACE_PATTERNS } from "./unsupported-surface-policy.js"; +import { getUnsupportedSecretRefSurfacePatterns } from "./unsupported-surface-policy.js"; type CredentialMatrixEntry = { id: string; @@ -54,7 +54,7 @@ export function buildSecretRefCredentialMatrix(): SecretRefCredentialMatrixDocum pathSyntax: 'Dot path with "*" for map keys and "[]" for arrays.', scope: "Credentials that are strictly user-supplied and not minted/rotated by OpenClaw runtime.", - excludedMutableOrRuntimeManaged: [...UNSUPPORTED_SECRETREF_SURFACE_PATTERNS], + excludedMutableOrRuntimeManaged: getUnsupportedSecretRefSurfacePatterns(), entries, }; } diff --git a/src/secrets/unsupported-surface-policy.test.ts b/src/secrets/unsupported-surface-policy.test.ts index 9414179311a..b0b9db90c2b 100644 --- a/src/secrets/unsupported-surface-policy.test.ts +++ b/src/secrets/unsupported-surface-policy.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { collectUnsupportedSecretRefConfigCandidates, - UNSUPPORTED_SECRETREF_SURFACE_PATTERNS, + getUnsupportedSecretRefSurfacePatterns, } from "./unsupported-surface-policy.js"; describe("unsupported SecretRef surface policy metadata", () => { it("exposes the canonical unsupported surface patterns", () => { - expect(UNSUPPORTED_SECRETREF_SURFACE_PATTERNS).toEqual([ + expect(getUnsupportedSecretRefSurfacePatterns()).toEqual([ "commands.ownerDisplaySecret", "hooks.token", "hooks.gmail.pushToken", diff --git a/src/secrets/unsupported-surface-policy.ts b/src/secrets/unsupported-surface-policy.ts index 8099211edb9..85e3c0eca2e 100644 --- a/src/secrets/unsupported-surface-policy.ts +++ b/src/secrets/unsupported-surface-policy.ts @@ -26,10 +26,15 @@ function collectChannelUnsupportedSecretRefSurfacePatterns(): string[] { ); } -export const UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ - ...CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS, - ...collectChannelUnsupportedSecretRefSurfacePatterns(), -] as const; +let cachedUnsupportedSecretRefSurfacePatterns: string[] | null = null; + +export function getUnsupportedSecretRefSurfacePatterns(): string[] { + cachedUnsupportedSecretRefSurfacePatterns ??= [ + ...CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS, + ...collectChannelUnsupportedSecretRefSurfacePatterns(), + ]; + return cachedUnsupportedSecretRefSurfacePatterns; +} export type UnsupportedSecretRefConfigCandidate = { path: string;