diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index 3df18ea19f5..7ebf6c756b1 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -1,8 +1,10 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import type { RootHelpRenderOptions } from "../src/cli/program/root-help.js"; +import type { OpenClawConfig } from "../src/config/config.js"; function dedupe(values: string[]): string[] { const seen = new Set(); @@ -45,6 +47,8 @@ type BundledChannelCatalog = { signature: string; }; +type RootHelpRenderContext = Pick; + function resolveRootHelpBundleIdentity( distDirOverride: string = distDir, ): { bundleName: string; signature: string } | null { @@ -118,25 +122,72 @@ export function readBundledChannelCatalogIds( return readBundledChannelCatalog(extensionsDirOverride).ids; } +function createIsolatedRootHelpRenderContext( + bundledPluginsDir: string = extensionsDir, +): RootHelpRenderContext { + const stateDir = path.join(rootDir, ".openclaw-build-root-help"); + const workspaceDir = path.join(stateDir, "workspace"); + const homeDir = path.join(stateDir, "home"); + const env: NodeJS.ProcessEnv = { + HOME: homeDir, + LOGNAME: process.env.LOGNAME ?? process.env.USER ?? "openclaw-build", + USER: process.env.USER ?? process.env.LOGNAME ?? "openclaw-build", + PATH: process.env.PATH ?? "", + TMPDIR: process.env.TMPDIR ?? "/tmp", + LANG: process.env.LANG ?? "C.UTF-8", + LC_ALL: process.env.LC_ALL ?? "C.UTF-8", + TERM: process.env.TERM ?? "dumb", + NO_COLOR: "1", + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "0", + OPENCLAW_STATE_DIR: stateDir, + }; + const config: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + plugins: { + loadPaths: [], + }, + }; + return { config, env }; +} + export async function renderBundledRootHelpText( _distDirOverride: string = distDir, + renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext( + existsSync(path.join(_distDirOverride, "extensions")) + ? path.join(_distDirOverride, "extensions") + : extensionsDir, + ), ): Promise { const bundleIdentity = resolveRootHelpBundleIdentity(_distDirOverride); if (!bundleIdentity) { throw new Error("No root-help bundle found in dist; cannot write CLI startup metadata."); } const moduleUrl = pathToFileURL(path.join(_distDirOverride, bundleIdentity.bundleName)).href; + const renderOptions = { + config: renderContext.config, + env: renderContext.env, + } satisfies RootHelpRenderOptions; const inlineModule = [ `const mod = await import(${JSON.stringify(moduleUrl)});`, "if (typeof mod.outputRootHelp !== 'function') {", ` throw new Error(${JSON.stringify(`Bundle ${bundleIdentity.bundleName} does not export outputRootHelp.`)});`, "}", - "await mod.outputRootHelp();", + `await mod.outputRootHelp(${JSON.stringify(renderOptions)});`, "process.exit(0);", ].join("\n"); const result = spawnSync(process.execPath, ["--input-type=module", "--eval", inlineModule], { cwd: _distDirOverride, encoding: "utf8", + env: renderContext.env, timeout: 30_000, }); if (result.error) { @@ -152,14 +203,21 @@ export async function renderBundledRootHelpText( return result.stdout ?? ""; } -function renderSourceRootHelpText(): string { +function renderSourceRootHelpText( + renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), +): string { const moduleUrl = pathToFileURL(path.join(rootDir, "src/cli/program/root-help.ts")).href; + const renderOptions = { + pluginSdkResolution: "src", + config: renderContext.config, + env: renderContext.env, + } satisfies RootHelpRenderOptions; const inlineModule = [ `const mod = await import(${JSON.stringify(moduleUrl)});`, "if (typeof mod.renderRootHelpText !== 'function') {", ` throw new Error(${JSON.stringify("Source root-help module does not export renderRootHelpText.")});`, "}", - "const output = await mod.renderRootHelpText({ pluginSdkResolution: 'src' });", + `const output = await mod.renderRootHelpText(${JSON.stringify(renderOptions)});`, "process.stdout.write(output);", "process.exit(0);", ].join("\n"); @@ -169,6 +227,7 @@ function renderSourceRootHelpText(): string { { cwd: rootDir, encoding: "utf8", + env: renderContext.env, timeout: 30_000, }, ); @@ -195,6 +254,10 @@ export async function writeCliStartupMetadata(options?: { const resolvedExtensionsDir = options?.extensionsDir ?? extensionsDir; const channelCatalog = readBundledChannelCatalog(resolvedExtensionsDir); const bundleIdentity = resolveRootHelpBundleIdentity(resolvedDistDir); + const bundledPluginsDir = path.join(resolvedDistDir, "extensions"); + const renderContext = createIsolatedRootHelpRenderContext( + existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir, + ); const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...channelCatalog.ids]); try { @@ -215,9 +278,9 @@ export async function writeCliStartupMetadata(options?: { let rootHelpText: string; try { - rootHelpText = await renderBundledRootHelpText(resolvedDistDir); + rootHelpText = await renderBundledRootHelpText(resolvedDistDir, renderContext); } catch { - rootHelpText = renderSourceRootHelpText(); + rootHelpText = renderSourceRootHelpText(renderContext); } mkdirSync(resolvedDistDir, { recursive: true }); diff --git a/src/cli/program/root-help.test.ts b/src/cli/program/root-help.test.ts index a2dabfdde8c..303ecda87b2 100644 --- a/src/cli/program/root-help.test.ts +++ b/src/cli/program/root-help.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import { renderRootHelpText } from "./root-help.js"; +const getPluginCliCommandDescriptorsMock = vi.fn( + async (_config?: unknown, _env?: unknown, _loaderOptions?: unknown) => [ + { + name: "matrix", + description: "Matrix channel utilities", + hasSubcommands: true, + }, + ], +); + vi.mock("./core-command-descriptors.js", () => ({ getCoreCliCommandDescriptors: () => [ { @@ -24,16 +34,28 @@ vi.mock("./subcli-descriptors.js", () => ({ })); vi.mock("../../plugins/cli.js", () => ({ - getPluginCliCommandDescriptors: async () => [ - { - name: "matrix", - description: "Matrix channel utilities", - hasSubcommands: true, - }, - ], + getPluginCliCommandDescriptors: (...args: [unknown?, unknown?, unknown?]) => + getPluginCliCommandDescriptorsMock(...args), })); describe("root help", () => { + it("passes isolated config and env through to plugin CLI descriptor loading", async () => { + const config = { + agents: { + defaults: { + workspace: "/tmp/openclaw-root-help-workspace", + }, + }, + }; + const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-root-help-state" } as NodeJS.ProcessEnv; + + await renderRootHelpText({ config, env, pluginSdkResolution: "src" }); + + expect(getPluginCliCommandDescriptorsMock).toHaveBeenCalledWith(config, env, { + pluginSdkResolution: "src", + }); + }); + it("includes plugin CLI descriptors alongside core and sub-CLI commands", async () => { const text = await renderRootHelpText(); diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index 4328380f7ba..a7f1e5d1387 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import type { OpenClawConfig } from "../../config/config.js"; import { getPluginCliCommandDescriptors } from "../../plugins/cli.js"; import type { PluginLoadOptions } from "../../plugins/loader.js"; import { VERSION } from "../../version.js"; @@ -6,9 +7,12 @@ import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; import { getSubCliEntries } from "./subcli-descriptors.js"; -type RootHelpLoaderOptions = Pick; +export type RootHelpRenderOptions = Pick & { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}; -async function buildRootHelpProgram(loaderOptions?: RootHelpLoaderOptions): Promise { +async function buildRootHelpProgram(renderOptions?: RootHelpRenderOptions): Promise { const program = new Command(); configureProgramHelp(program, { programVersion: VERSION, @@ -29,7 +33,11 @@ async function buildRootHelpProgram(loaderOptions?: RootHelpLoaderOptions): Prom program.command(command.name).description(command.description); existingCommands.add(command.name); } - for (const command of await getPluginCliCommandDescriptors(undefined, undefined, loaderOptions)) { + for (const command of await getPluginCliCommandDescriptors( + renderOptions?.config, + renderOptions?.env, + { pluginSdkResolution: renderOptions?.pluginSdkResolution }, + )) { if (existingCommands.has(command.name)) { continue; } @@ -40,8 +48,8 @@ async function buildRootHelpProgram(loaderOptions?: RootHelpLoaderOptions): Prom return program; } -export async function renderRootHelpText(loaderOptions?: RootHelpLoaderOptions): Promise { - const program = await buildRootHelpProgram(loaderOptions); +export async function renderRootHelpText(renderOptions?: RootHelpRenderOptions): Promise { + const program = await buildRootHelpProgram(renderOptions); let output = ""; const originalWrite = process.stdout.write.bind(process.stdout); const captureWrite: typeof process.stdout.write = ((chunk: string | Uint8Array) => { @@ -57,6 +65,6 @@ export async function renderRootHelpText(loaderOptions?: RootHelpLoaderOptions): return output; } -export async function outputRootHelp(loaderOptions?: RootHelpLoaderOptions): Promise { - process.stdout.write(await renderRootHelpText(loaderOptions)); +export async function outputRootHelp(renderOptions?: RootHelpRenderOptions): Promise { + process.stdout.write(await renderRootHelpText(renderOptions)); } diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 8ccc9960d8c..909bb335faa 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -16,7 +16,7 @@ import type { ChannelThreadingAdapter, } from "../channels/plugins/types.core.js"; import type { ChannelMeta } from "../channels/plugins/types.js"; -import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ReplyToMode } from "../config/types.base.js"; import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; @@ -25,7 +25,7 @@ import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js import { emptyPluginConfigSchema } from "../plugins/config-schema.js"; import type { PluginPackageChannel } from "../plugins/manifest.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; -import type { OpenClawPluginApi, OpenClawPluginConfigSchema } from "../plugins/types.js"; +import type { OpenClawPluginApi } from "../plugins/types.js"; export type { AnyAgentTool, @@ -356,13 +356,35 @@ export function buildChannelOutboundSessionRoute(params: { }; } +const emptyChannelConfigSchema: ChannelConfigSchema = { + schema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + runtime: { + safeParse(value: unknown) { + if (value === undefined) { + return { success: true, data: undefined }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { success: false, issues: [{ path: [], message: "expected config object" }] }; + } + if (Object.keys(value as Record).length > 0) { + return { success: false, issues: [{ path: [], message: "config must be empty" }] }; + } + return { success: true, data: value }; + }, + }, +}; + /** Options for a channel plugin entry that should register a channel capability. */ type DefineChannelPluginEntryOptions = { id: string; name: string; description: string; plugin: TPlugin; - configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema); + configSchema?: ChannelConfigSchema | (() => ChannelConfigSchema); setRuntime?: (runtime: PluginRuntime) => void; registerCliMetadata?: (api: OpenClawPluginApi) => void; registerFull?: (api: OpenClawPluginApi) => void; @@ -372,7 +394,7 @@ type DefinedChannelPluginEntry = { id: string; name: string; description: string; - configSchema: OpenClawPluginConfigSchema; + configSchema: ChannelConfigSchema; register: (api: OpenClawPluginApi) => void; channelPlugin: TPlugin; setChannelRuntime?: (runtime: PluginRuntime) => void; @@ -430,15 +452,16 @@ export function defineChannelPluginEntry({ name, description, plugin, - configSchema = emptyPluginConfigSchema, + configSchema = emptyChannelConfigSchema, setRuntime, registerCliMetadata, registerFull, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry { - let resolvedConfigSchema: ChannelPlugin["configSchema"] | undefined; - const getConfigSchema = (): ChannelPlugin["configSchema"] => { + let resolvedConfigSchema: ChannelConfigSchema | undefined; + const getConfigSchema = (): ChannelConfigSchema => { resolvedConfigSchema ??= - typeof configSchema === "function" ? configSchema() : configSchema; + (typeof configSchema === "function" ? configSchema() : configSchema) ?? + emptyChannelConfigSchema; return resolvedConfigSchema; }; const entry = {