From 741bac9fdf4d5d64a82f7ecf8bd21539c48e2501 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:34:55 +0800 Subject: [PATCH] fix(agents): load manifest-owned harnesses --- src/agents/btw.test.ts | 58 +++++++++++- src/agents/btw.ts | 47 ++++++--- src/agents/harness/runtime-plugin.test.ts | 110 ++++++++++++++++------ src/agents/harness/runtime-plugin.ts | 54 ++++++++--- src/plugins/activation-planner.test.ts | 39 ++++++++ 5 files changed, 249 insertions(+), 59 deletions(-) diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 23a9d9ef09ba..12f64b971c40 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -29,6 +29,7 @@ const resolveEmbeddedAgentStreamFnMock = vi.fn(); const prepareCliRunContextMock = vi.fn(); const executePreparedCliRunMock = vi.fn(); const diagDebugMock = vi.fn(); +const ensureSelectedAgentHarnessPluginMock = vi.fn(); vi.mock("../llm/stream.js", async () => { const original = await vi.importActual("../llm/stream.js"); @@ -119,7 +120,7 @@ vi.mock("./model-runtime-aliases.js", () => ({ } } } - return runtime || undefined; + return runtime === "claude-cli" ? runtime : undefined; }, })); @@ -131,6 +132,11 @@ vi.mock("./cli-runner/execute.runtime.js", () => ({ executePreparedCliRun: (...args: unknown[]) => executePreparedCliRunMock(...args), })); +vi.mock("./harness/runtime-plugin.js", () => ({ + ensureSelectedAgentHarnessPlugin: (...args: unknown[]) => + ensureSelectedAgentHarnessPluginMock(...args), +})); + vi.mock("./embedded-agent-runner/runs.js", () => ({ getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args), })); @@ -455,6 +461,7 @@ describe("runBtwSideQuestion", () => { prepareCliRunContextMock.mockReset(); executePreparedCliRunMock.mockReset(); diagDebugMock.mockReset(); + ensureSelectedAgentHarnessPluginMock.mockReset(); clearAgentHarnesses(); readFileMock.mockResolvedValue("mock transcript"); @@ -835,6 +842,55 @@ describe("runBtwSideQuestion", () => { expect(streamSimpleMock).toHaveBeenCalledTimes(1); }); + it("loads a cold Copilot harness before selecting the /btw provider fallback", async () => { + let loaded = false; + ensureSelectedAgentHarnessPluginMock.mockImplementation(async () => { + if (loaded) { + return; + } + loaded = true; + registerAgentHarness({ + id: "copilot", + label: "Copilot test harness", + supports: () => ({ supported: true, priority: 100 }), + runAttempt: vi.fn(), + }); + }); + resolveModelWithRegistryMock.mockReturnValue({ + provider: "github-copilot", + id: "gpt-4o", + api: "openai-completions", + }); + mockDoneAnswer("Copilot fallback answer."); + + const result = await runSideQuestion({ + cfg: { + agents: { + defaults: { + models: { + "github-copilot/gpt-4o": { agentRuntime: { id: "copilot" } }, + }, + }, + }, + } as never, + provider: "github-copilot", + model: "gpt-4o", + sessionKey: DEFAULT_SESSION_KEY, + }); + + expect(result).toEqual({ text: "Copilot fallback answer." }); + expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledOnce(); + expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith({ + provider: "github-copilot", + modelId: "gpt-4o", + config: expect.any(Object), + agentId: "main", + sessionKey: DEFAULT_SESSION_KEY, + workspaceDir: "/tmp/workspace", + }); + expect(streamSimpleMock).toHaveBeenCalledOnce(); + }); + it("runs CLI-runtime alias BTW as an ephemeral CLI side question", async () => { const cleanup = vi.fn(async () => undefined); prepareCliRunContextMock.mockResolvedValueOnce({ diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 1d98f2869ada..076e5feeab2b 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -30,6 +30,7 @@ import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent- import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js"; import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js"; import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js"; +import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js"; import { resolveAvailableAgentHarnessPolicy, resolvePluginHarnessPolicyToolsAllow, @@ -479,13 +480,32 @@ export async function runBtwSideQuestion( config: params.cfg, }); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); - const harness = selectAgentHarness({ - provider: params.provider, - modelId: params.model, - config: params.cfg, - agentId: sessionAgentId, - sessionKey: params.sessionKey, - }); + const preparedHarnesses = new Map(); + const prepareHarness = async (provider: string, modelId: string): Promise => { + const key = `${provider}/${modelId}`; + const cached = preparedHarnesses.get(key); + if (cached) { + return cached; + } + await ensureSelectedAgentHarnessPlugin({ + provider, + modelId, + config: params.cfg, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + workspaceDir, + }); + const harness = selectAgentHarness({ + provider, + modelId, + config: params.cfg, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + }); + preparedHarnesses.set(key, harness); + return harness; + }; + const harness = await prepareHarness(params.provider, params.model); let runtimeSelection: Awaited> | undefined; const resolveRuntimeSelection = async () => { if (!runtimeSelection) { @@ -647,13 +667,12 @@ export async function runBtwSideQuestion( } const runtimeSelectionForHarness = await resolveRuntimeSelection(); - const runtimeHarness = selectAgentHarness({ - provider: runtimeSelectionForHarness.model.provider, - modelId: runtimeSelectionForHarness.model.id, - config: params.cfg, - agentId: sessionAgentId, - sessionKey: params.sessionKey, - }); + // Model resolution can canonicalize a legacy provider alias, so reselect against the resolved + // provider/model instead of reusing the raw route's selection. + const runtimeHarness = await prepareHarness( + runtimeSelectionForHarness.model.provider, + runtimeSelectionForHarness.model.id, + ); if (runtimeHarness.runSideQuestion) { return runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness); } diff --git a/src/agents/harness/runtime-plugin.test.ts b/src/agents/harness/runtime-plugin.test.ts index d6bc972100d6..1b7c199ffd22 100644 --- a/src/agents/harness/runtime-plugin.test.ts +++ b/src/agents/harness/runtime-plugin.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ ensurePluginRegistryLoaded: vi.fn(), resolveActivatableProviderOwnerPluginIds: vi.fn(), resolveBundledProviderCompatPluginIds: vi.fn(), + resolveManifestActivationPlan: vi.fn(), resolveOwningPluginIdsForProvider: vi.fn(), })); @@ -20,6 +21,10 @@ vi.mock("../../plugins/providers.js", () => ({ resolveOwningPluginIdsForProviderRef: mocks.resolveOwningPluginIdsForProvider, })); +vi.mock("../../plugins/activation-planner.js", () => ({ + resolveManifestActivationPlan: mocks.resolveManifestActivationPlan, +})); + describe("ensureSelectedAgentHarnessPlugin", () => { let ensureSelectedAgentHarnessPlugin: typeof import("./runtime-plugin.js").ensureSelectedAgentHarnessPlugin; @@ -27,7 +32,30 @@ describe("ensureSelectedAgentHarnessPlugin", () => { mocks.ensurePluginRegistryLoaded.mockReset(); mocks.resolveActivatableProviderOwnerPluginIds.mockReset(); mocks.resolveBundledProviderCompatPluginIds.mockReset(); + mocks.resolveManifestActivationPlan.mockReset(); mocks.resolveOwningPluginIdsForProvider.mockReset(); + mocks.resolveManifestActivationPlan.mockImplementation( + ({ + trigger, + config, + }: { + trigger: { kind: "agentHarness"; runtime: string }; + config?: OpenClawConfig; + }) => { + const pluginId = trigger.runtime; + const allow = config?.plugins?.allow ?? []; + if ( + config?.plugins?.entries?.[pluginId]?.enabled === false || + (allow.length > 0 && !allow.includes(pluginId)) + ) { + return { entries: [] }; + } + return { + entries: + pluginId === "codex" || pluginId === "copilot" ? [{ pluginId, origin: "bundled" }] : [], + }; + }, + ); mocks.resolveOwningPluginIdsForProvider.mockImplementation( ({ provider }: { provider: string }) => (provider === "openai" ? ["openai"] : undefined), ); @@ -132,6 +160,54 @@ describe("ensureSelectedAgentHarnessPlugin", () => { ); }); + it("loads a manifest-owned custom harness runtime before selection", async () => { + mocks.resolveManifestActivationPlan.mockReturnValueOnce({ + entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }], + }); + + await ensureSelectedAgentHarnessPlugin({ + provider: "custom-provider", + modelId: "custom-model", + config: { + plugins: { + entries: { + "custom-harness-plugin": { enabled: true }, + }, + }, + } as OpenClawConfig, + agentHarnessRuntimeOverride: "custom-harness", + workspaceDir: "/tmp/workspace", + }); + + expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({ + trigger: { kind: "agentHarness", runtime: "custom-harness" }, + config: expect.any(Object), + workspaceDir: "/tmp/workspace", + }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "all", + workspaceDir: "/tmp/workspace", + onlyPluginIds: ["custom-harness-plugin", "memory-core"], + }), + ); + }); + + it("does not activate an untrusted workspace harness from manifest metadata alone", async () => { + mocks.resolveManifestActivationPlan.mockReturnValueOnce({ + entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }], + }); + + await ensureSelectedAgentHarnessPlugin({ + provider: "custom-provider", + modelId: "custom-model", + agentHarnessRuntimeOverride: "custom-harness", + workspaceDir: "/tmp/workspace", + }); + + expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + }); + it("does not bypass a restrictive allowlist that omits a configured Copilot harness", async () => { // A configured harness can request loading, but explicit plugin allowlists // remain the operator's boundary and are not widened implicitly. @@ -158,21 +234,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => { workspaceDir: "/tmp/workspace", }); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["copilot"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["telegram"], - entries: expect.not.objectContaining({ - copilot: expect.anything(), - }), - }), - }), - }), - ); + expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); it("widens a scoped harness allowlist with the provider owner for openai models", async () => { @@ -337,22 +399,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => { expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); expect(mocks.resolveBundledProviderCompatPluginIds).not.toHaveBeenCalled(); expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalled(); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["telegram"], - entries: expect.not.objectContaining({ - codex: expect.anything(), - openai: expect.anything(), - }), - }), - }), - }), - ); + expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); it("keeps real bundled memory-core in a Codex scoped load when the provider has no owner plugin", async () => { @@ -417,5 +464,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => { expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); + expect(mocks.resolveManifestActivationPlan).not.toHaveBeenCalled(); }); }); diff --git a/src/agents/harness/runtime-plugin.ts b/src/agents/harness/runtime-plugin.ts index f976863f646c..96badaaec8f4 100644 --- a/src/agents/harness/runtime-plugin.ts +++ b/src/agents/harness/runtime-plugin.ts @@ -3,8 +3,13 @@ */ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withActivatedPluginIds } from "../../plugins/activation-context.js"; -import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js"; +import { resolveManifestActivationPlan } from "../../plugins/activation-planner.js"; +import { + normalizePluginsConfig, + resolveEffectivePluginActivationState, +} from "../../plugins/config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js"; +import { hasExplicitManifestOwnerTrust } from "../../plugins/manifest-owner-policy.js"; import { loadPluginRegistrySnapshot, normalizePluginsConfigWithRegistry, @@ -16,16 +21,9 @@ import { } from "../../plugins/providers.js"; import { isDefaultAgentRuntimeId, OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; +import { isCliRuntimeAliasForProvider } from "../model-runtime-aliases.js"; import { resolveAgentHarnessPolicy } from "./policy.js"; -/** - * Lazy-loads plugin-backed harness runtimes before selection. - * - * Only cold-loadable runtimes live here; always-loaded core/openclaw runtimes should not trigger - * plugin registry scans on every embedded-agent turn. - */ -const COLD_LOADABLE_HARNESS_PLUGIN_IDS = new Set(["codex", "copilot"]); - function dedupePluginIds(values: readonly string[]): string[] { const seen = new Set(); const result: string[] = []; @@ -82,13 +80,35 @@ function resolveHarnessPluginIds(params: { config?: OpenClawConfig; workspaceDir: string; }): string[] { + const activationPlan = resolveManifestActivationPlan({ + trigger: { kind: "agentHarness", runtime: params.runtime }, + config: params.config, + workspaceDir: params.workspaceDir, + }); + const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); + const harnessPluginIds = activationPlan.entries + .filter( + (entry) => + entry.origin === "bundled" || + hasExplicitManifestOwnerTrust({ + plugin: { id: entry.pluginId }, + normalizedConfig: normalizedPlugins, + }), + ) + .map((entry) => entry.pluginId); + if (harnessPluginIds.length === 0) { + return []; + } if (params.runtime !== "codex") { - return [params.runtime]; + return harnessPluginIds; + } + if (!harnessPluginIds.includes("codex")) { + return harnessPluginIds; } if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) { // Respect a restrictive allowlist even when Codex would normally pull in provider owner // plugins. Operators who set an allowlist expect no implicit plugin expansion. - return ["codex"]; + return harnessPluginIds; } const providerOwnerPluginIds = dedupePluginIds( resolveOwningPluginIdsForProviderRef({ @@ -98,7 +118,7 @@ function resolveHarnessPluginIds(params: { }) ?? [], ); if (providerOwnerPluginIds.length === 0) { - return ["codex"]; + return harnessPluginIds; } const safeProviderOwnerPluginIds = dedupePluginIds([ ...resolveBundledProviderCompatPluginIds({ @@ -114,6 +134,7 @@ function resolveHarnessPluginIds(params: { ]); return dedupePluginIds([ "codex", + ...harnessPluginIds, ...providerOwnerPluginIds.filter( (pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId), ), @@ -164,7 +185,11 @@ export async function ensureSelectedAgentHarnessPlugin(params: { if ( isDefaultAgentRuntimeId(runtime) || runtime === OPENCLAW_AGENT_RUNTIME_ID || - !COLD_LOADABLE_HARNESS_PLUGIN_IDS.has(runtime) + isCliRuntimeAliasForProvider({ + runtime, + provider: params.provider, + cfg: params.config, + }) ) { return; } @@ -177,6 +202,9 @@ export async function ensureSelectedAgentHarnessPlugin(params: { config: params.config, workspaceDir: params.workspaceDir, }); + if (pluginIds.length === 0) { + return; + } const memoryPluginIds = resolveSelectedMemoryPluginIds({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index 2dd6b4a397e1..5d94ba5acd51 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -68,6 +68,18 @@ describe("activation planner", () => { hooks: [], origin: "bundled", }, + { + id: "custom-harness-plugin", + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + activation: { + onAgentHarnesses: ["custom-harness"], + }, + origin: "workspace", + }, { id: "demo-channel", channels: ["telegram"], @@ -145,6 +157,33 @@ describe("activation planner", () => { ).toEqual([]); }); + it("plans manifest-owned custom harnesses and respects their activation policy", () => { + expect( + resolveManifestActivationPluginIds({ + trigger: { + kind: "agentHarness", + runtime: "custom-harness", + }, + }), + ).toEqual(["custom-harness-plugin"]); + + expect( + resolveManifestActivationPluginIds({ + config: { + plugins: { + entries: { + "custom-harness-plugin": { enabled: false }, + }, + }, + }, + trigger: { + kind: "agentHarness", + runtime: "custom-harness", + }, + }), + ).toEqual([]); + }); + it("keeps ids-only provider, agent harness, channel, and route planning stable", () => { expect( resolveManifestActivationPluginIds({