diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index a9aa66ac992c..5b2ee8cf5e90 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -70,6 +70,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["setup"], policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false }, }, + { + commandPath: ["qa"], + // Private QA commands create or inspect repo-owned fixtures. They must not + // read, validate, migrate, or inherit proxy policy from operator state. + policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" }, + }, { commandPath: ["crestodian"], // hidden alias policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false }, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 5073859694fe..9b8e91796d3c 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -186,6 +186,11 @@ describe("command-path-policy", () => { }); it("resolves mixed startup-only rules", () => { + expectResolvedPolicy(["qa", "suite"], { + bypassConfigGuard: true, + loadPlugins: "never", + networkProxy: "bypass", + }); expectResolvedPolicy(["worker"], { bypassConfigGuard: true, loadPlugins: "never", diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index f5b3eba29b2f..15750bea9005 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -189,6 +189,10 @@ describe("registerPreActionHooks", () => { .action(() => {}); programLocal.command("completion").action(() => {}); programLocal.command("secrets").action(() => {}); + programLocal + .command("qa") + .command("suite") + .action(() => {}); programLocal .command("agents") .command("list") @@ -441,6 +445,16 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); + it("keeps private QA commands isolated from operator config bootstrap", async () => { + await runPreAction({ + parseArgv: ["qa", "suite"], + processArgv: ["node", "openclaw", "qa", "suite"], + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + it("lets bare config own config validation and plugin loading", async () => { await runPreAction({ parseArgv: ["config"], diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index d1266ba0ddf9..c0dd85e34375 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -222,6 +222,7 @@ describe("shouldStartProxyForCli", () => { }); it("skips managed proxy routing for bare parent default help", () => { + expect(shouldStartProxyForCli(["node", "openclaw", "qa", "suite"])).toBe(false); expect(shouldStartProxyForCli(["node", "openclaw", "plugins"])).toBe(false); expect(shouldStartProxyForCli(["node", "openclaw", "channels"])).toBe(false); expect(shouldStartProxyForCli(["node", "openclaw", "cron"])).toBe(false); diff --git a/src/plugin-sdk/qa-runner-runtime.integration.test.ts b/src/plugin-sdk/qa-runner-runtime.integration.test.ts index 14162054e298..f7216eb23e36 100644 --- a/src/plugin-sdk/qa-runner-runtime.integration.test.ts +++ b/src/plugin-sdk/qa-runner-runtime.integration.test.ts @@ -14,6 +14,7 @@ import { import { listQaRunnerCliContributions } from "./qa-runner-runtime.js"; const ORIGINAL_ENV = { + OPENCLAW_ENABLE_PRIVATE_QA_CLI: process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI, OPENCLAW_DISABLE_BUNDLED_PLUGINS: process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS, OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH, OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, @@ -141,4 +142,47 @@ describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => { }, ]); }); + + it("ignores operator runner metadata and state during private QA discovery", () => { + const stateDir = makeTempDir("openclaw-private-qa-operator-state-"); + const pluginDir = path.join(stateDir, "extensions", "operator-runner"); + const stateDatabasePath = path.join(stateDir, "openclaw.sqlite"); + const stateDatabaseSentinel = "operator-state-must-remain-unopened"; + + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(stateDatabasePath, stateDatabaseSentinel, "utf8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "operator-runner", + qaRunners: [{ commandName: "operator-sentinel" }], + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/operator-runner", + type: "module", + openclaw: { extensions: ["./index.js"] }, + }), + "utf8", + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export default {};\n", "utf8"); + + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "0"; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); + + const contributions = listQaRunnerCliContributions(); + + expect(contributions.some((runner) => runner.pluginId === "operator-runner")).toBe(false); + expect(fs.readFileSync(stateDatabasePath, "utf8")).toBe(stateDatabaseSentinel); + }); }); diff --git a/src/plugin-sdk/qa-runner-runtime.test.ts b/src/plugin-sdk/qa-runner-runtime.test.ts index 70e2864e9117..db58d75d1c80 100644 --- a/src/plugin-sdk/qa-runner-runtime.test.ts +++ b/src/plugin-sdk/qa-runner-runtime.test.ts @@ -13,11 +13,13 @@ import { } from "./qa-runtime.test-helpers.js"; const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); +const loadBundledPluginManifestRegistry = vi.hoisted(() => vi.fn()); const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); const tryLoadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); const resolveOpenClawPackageRootSync = vi.hoisted(() => vi.fn()); vi.mock("../plugins/manifest-registry.js", () => ({ + loadBundledPluginManifestRegistry, loadPluginManifestRegistry, })); @@ -37,10 +39,6 @@ type PublicSurfaceCall = { env?: NodeJS.ProcessEnv; }; -function firstManifestRegistryCall(): ManifestRegistryCall | undefined { - return loadPluginManifestRegistry.mock.calls[0]?.[0] as ManifestRegistryCall | undefined; -} - function firstPublicSurfaceCall(): PublicSurfaceCall | undefined { return loadBundledPluginPublicSurfaceModuleSync.mock.calls[0]?.[0] as | PublicSurfaceCall @@ -58,6 +56,10 @@ describe("plugin-sdk qa-runner-runtime", () => { plugins: [], diagnostics: [], }); + loadBundledPluginManifestRegistry.mockReset().mockReturnValue({ + plugins: [], + diagnostics: [], + }); loadBundledPluginPublicSurfaceModuleSync.mockReset(); tryLoadActivatedBundledPluginPublicSurfaceModuleSync.mockReset(); resolveOpenClawPackageRootSync.mockReset().mockReturnValue(null); @@ -232,7 +234,7 @@ describe("plugin-sdk qa-runner-runtime", () => { const register = vi.fn((qa: Command) => qa); const adapterFactory = { id: "example", matches: vi.fn(), create: vi.fn() }; - loadPluginManifestRegistry.mockReturnValue({ + loadBundledPluginManifestRegistry.mockReturnValue({ plugins: [ { id: "qa-example", @@ -261,7 +263,9 @@ describe("plugin-sdk qa-runner-runtime", () => { }, }, ]); - const manifestCall = firstManifestRegistryCall(); + const manifestCall = loadBundledPluginManifestRegistry.mock.calls[0]?.[0] as + | ManifestRegistryCall + | undefined; expect(manifestCall?.env?.OPENCLAW_ENABLE_PRIVATE_QA_CLI).toBe("1"); expect(manifestCall?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR).toBe( path.join(sourceRoot, "extensions"), diff --git a/src/plugin-sdk/qa-runner-runtime.ts b/src/plugin-sdk/qa-runner-runtime.ts index cabe60d77bda..840ef2b12f35 100644 --- a/src/plugin-sdk/qa-runner-runtime.ts +++ b/src/plugin-sdk/qa-runner-runtime.ts @@ -1,7 +1,10 @@ // QA runner runtime helpers expose plugin QA scenarios through the CLI command surface. import type { Command } from "commander"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { + loadBundledPluginManifestRegistry, + loadPluginManifestRegistry, +} from "../plugins/manifest-registry.js"; import type { OpenClawConfig } from "./config-contracts.js"; import { loadBundledPluginPublicSurfaceModuleSync, @@ -235,8 +238,11 @@ function listDeclaredQaRunnerPlugins( qaRunners: NonNullable; } > { - return loadPluginManifestRegistry(env ? { env } : {}) - .plugins.filter( + // Private QA is a source-checkout harness. Its command tree must be derived + // from repo-owned manifests before Commander pre-action hooks can run. + const registry = env ? loadBundledPluginManifestRegistry({ env }) : loadPluginManifestRegistry(); + return registry.plugins + .filter( ( plugin, ): plugin is PluginManifestRecord & { diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index a0ed9d7d5a7c..aaad1c974e29 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -96,6 +96,8 @@ export type PluginDiscoveryResult = { diagnostics: PluginDiagnostic[]; }; +type PluginDiscoveryRootScope = "all" | "bundled"; + function currentUid(overrideUid?: number | null): number | null { if (overrideUid !== undefined) { return overrideUid; @@ -1526,6 +1528,7 @@ export function discoverOpenClawPlugins(params: { installRecords?: Record; ownershipUid?: number | null; env?: NodeJS.ProcessEnv; + rootScope?: PluginDiscoveryRootScope; }): PluginDiscoveryResult { const env = params.env ?? process.env; const workspaceDir = normalizeOptionalString(params.workspaceDir); @@ -1533,48 +1536,51 @@ export function discoverOpenClawPlugins(params: { const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env }); const realpathCache = new Map(); const packageManifestCache = new Map(); - const scopedResult = tracePluginLifecyclePhase( - "discovery scan", - () => { - const result = createDiscoveryResult(); - const seen = new Set(); - discoverConfiguredPluginLoadPathsInto({ - loadPaths: params.extraPaths ?? [], - bundledRoot: roots.stock, - ownershipUid: params.ownershipUid, - workspaceDir, - env, - result, - seen, - realpathCache, - packageManifestCache, - }); - const workspaceMatchesBundledRoot = resolvesToSameDirectory( - workspaceRoot, - roots.stock, - realpathCache, - ); - if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) { - // Keep workspace auto-discovery constrained to the OpenClaw extensions root. - // Recursively scanning the full workspace treats arbitrary project folders as - // plugin candidates and causes noisy "plugin manifest not found" validation failures. - discoverInDirectory({ - dir: roots.workspace, - origin: "workspace", - env, - ownershipUid: params.ownershipUid, - workspaceDir: workspaceRoot, - candidates: result.candidates, - diagnostics: result.diagnostics, - seen, - realpathCache, - packageManifestCache, - }); - } - return result; - }, - { scope: "scoped", extraPathCount: params.extraPaths?.length ?? 0 }, - ); + const scopedResult = + params.rootScope === "bundled" + ? createDiscoveryResult() + : tracePluginLifecyclePhase( + "discovery scan", + () => { + const result = createDiscoveryResult(); + const seen = new Set(); + discoverConfiguredPluginLoadPathsInto({ + loadPaths: params.extraPaths ?? [], + bundledRoot: roots.stock, + ownershipUid: params.ownershipUid, + workspaceDir, + env, + result, + seen, + realpathCache, + packageManifestCache, + }); + const workspaceMatchesBundledRoot = resolvesToSameDirectory( + workspaceRoot, + roots.stock, + realpathCache, + ); + if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) { + // Keep workspace auto-discovery constrained to the OpenClaw extensions root. + // Recursively scanning the full workspace treats arbitrary project folders as + // plugin candidates and causes noisy "plugin manifest not found" validation failures. + discoverInDirectory({ + dir: roots.workspace, + origin: "workspace", + env, + ownershipUid: params.ownershipUid, + workspaceDir: workspaceRoot, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + packageManifestCache, + }); + } + return result; + }, + { scope: "scoped", extraPathCount: params.extraPaths?.length ?? 0 }, + ); const sharedResult = tracePluginLifecyclePhase( "discovery scan", () => { @@ -1664,29 +1670,46 @@ export function discoverOpenClawPlugins(params: { skipDirectories: readChildDirectoryNames(roots.stock), }); } - const installedPaths = collectInstalledPluginRecordPaths( - params.installRecords, - env, - realpathCache, - ); - const installedPluginDirKeys = collectManagedPluginDirKeys( - installedPaths.map((installedPath) => installedPath.path), - realpathCache, - ); - const managedPluginDirs = collectManagedPluginDirKeys( - collectManagedPluginRecordPaths(params.installRecords, env), - realpathCache, - ); - for (const installedPath of installedPaths) { - discoverFromPath({ - rawPath: installedPath.path, - origin: "global", - ownershipUid: params.ownershipUid, - workspaceDir, - requireBuiltRuntimeEntry: installedPath.requireBuiltRuntimeEntry, - managedPluginDirs, - scanFiles: true, + if (params.rootScope !== "bundled") { + const installedPaths = collectInstalledPluginRecordPaths( + params.installRecords, env, + realpathCache, + ); + const installedPluginDirKeys = collectManagedPluginDirKeys( + installedPaths.map((installedPath) => installedPath.path), + realpathCache, + ); + const managedPluginDirs = collectManagedPluginDirKeys( + collectManagedPluginRecordPaths(params.installRecords, env), + realpathCache, + ); + for (const installedPath of installedPaths) { + discoverFromPath({ + rawPath: installedPath.path, + origin: "global", + ownershipUid: params.ownershipUid, + workspaceDir, + requireBuiltRuntimeEntry: installedPath.requireBuiltRuntimeEntry, + managedPluginDirs, + scanFiles: true, + env, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + packageManifestCache, + }); + } + // Keep auto-discovered global extensions behind bundled plugins. + // Users can still intentionally override via plugins.load.paths (origin=config). + discoverInDirectory({ + dir: roots.global, + origin: "global", + env, + ownershipUid: params.ownershipUid, + managedPluginDirs, + skipRootDirKeys: installedPluginDirKeys, candidates: result.candidates, diagnostics: result.diagnostics, seen, @@ -1694,21 +1717,6 @@ export function discoverOpenClawPlugins(params: { packageManifestCache, }); } - // Keep auto-discovered global extensions behind bundled plugins. - // Users can still intentionally override via plugins.load.paths (origin=config). - discoverInDirectory({ - dir: roots.global, - origin: "global", - env, - ownershipUid: params.ownershipUid, - managedPluginDirs, - skipRootDirKeys: installedPluginDirKeys, - candidates: result.candidates, - diagnostics: result.diagnostics, - seen, - realpathCache, - packageManifestCache, - }); return result; }, { scope: "shared" }, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index d4316e12f630..daec4f8729ac 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -1258,4 +1258,21 @@ export function loadPluginManifestRegistry( const registry = { plugins, diagnostics: dedupePluginDiagnostics(diagnostics) }; return registry; } + +/** Load manifest metadata from the bundled/source plugin tree without consulting operator state. */ +export function loadBundledPluginManifestRegistry( + params: { env?: NodeJS.ProcessEnv } = {}, +): PluginManifestRegistry { + const env = params.env ?? process.env; + const installRecords: Record = {}; + return loadPluginManifestRegistry({ + env, + installRecords, + discovery: discoverOpenClawPlugins({ + env, + installRecords, + rootScope: "bundled", + }), + }); +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */