diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index ae3050bd3e3..c0d8607fc70 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; import { getPath } from "./path-utils.js"; import { getCoreSecretTargetRegistry, getSecretTargetRegistry } from "./target-registry-data.js"; import { @@ -31,6 +32,11 @@ let compiledCoreOpenClawTargetState: { targetsByType: Map; } | null = null; +const compiledBundledChannelOpenClawTargets = new Map< + string, + CompiledTargetRegistryEntry[] | null +>(); + function buildTargetTypeIndex( compiledSecretTargetRegistry: CompiledTargetRegistryEntry[], ): Map { @@ -106,6 +112,24 @@ function getCompiledCoreOpenClawTargetState() { return compiledCoreOpenClawTargetState; } +function getCompiledBundledChannelOpenClawTargets( + channelId: string, +): CompiledTargetRegistryEntry[] | null { + const normalizedChannelId = channelId.trim(); + if (!normalizedChannelId) { + return null; + } + if (compiledBundledChannelOpenClawTargets.has(normalizedChannelId)) { + return compiledBundledChannelOpenClawTargets.get(normalizedChannelId) ?? null; + } + const compiledEntries = + loadBundledChannelSecretContractApi(normalizedChannelId) + ?.secretTargetRegistryEntries?.filter((entry) => entry.configFile === "openclaw.json") + .map(compileTargetRegistryEntry) ?? null; + compiledBundledChannelOpenClawTargets.set(normalizedChannelId, compiledEntries); + return compiledEntries; +} + function normalizeAllowedTargetIds(targetIds?: Iterable): Set | null { if (targetIds === undefined) { return null; @@ -292,6 +316,41 @@ function resolvePlanTargetAgainstEntries( } export function resolveConfigSecretTargetByPath(pathSegments: string[]): ResolvedPlanTarget | null { + for (const entry of getCompiledCoreOpenClawTargetState().openClawCompiledSecretTargets) { + if (!entry.includeInPlan) { + continue; + } + const matched = matchPathTokens(pathSegments, entry.pathTokens); + if (!matched) { + continue; + } + const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures); + if (!resolved) { + continue; + } + return resolved; + } + + const explicitBundledChannelId = + pathSegments[0] === "channels" ? (pathSegments[1]?.trim() ?? "") : ""; + const explicitBundledChannelEntries = explicitBundledChannelId + ? getCompiledBundledChannelOpenClawTargets(explicitBundledChannelId) + : null; + for (const entry of explicitBundledChannelEntries ?? []) { + if (!entry.includeInPlan) { + continue; + } + const matched = matchPathTokens(pathSegments, entry.pathTokens); + if (!matched) { + continue; + } + const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures); + if (!resolved) { + continue; + } + return resolved; + } + for (const entry of getCompiledSecretTargetRegistryState().openClawCompiledSecretTargets) { if (!entry.includeInPlan) { continue; diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts new file mode 100644 index 00000000000..98f1567087c --- /dev/null +++ b/src/secrets/target-registry.fast-path.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { loadPluginManifestRegistryMock } = vi.hoisted(() => ({ + loadPluginManifestRegistryMock: vi.fn(() => { + throw new Error("manifest registry should stay off the explicit channel target fast path"); + }), +})); + +const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "googlechat" && artifactBasename === "secret-contract-api.js") { + return { + secretTargetRegistryEntries: [ + { + id: "channels.googlechat.serviceAccount", + targetType: "channels.googlechat.serviceAccount", + configFile: "openclaw.json", + pathPattern: "channels.googlechat.serviceAccount", + refPathPattern: "channels.googlechat.serviceAccountRef", + secretShape: "sibling_ref", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }; + } + throw new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); + }, + ), +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: loadPluginManifestRegistryMock, +})); + +vi.mock("../plugins/public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, +})); + +import { resolveConfigSecretTargetByPath } from "./target-registry.js"; + +describe("secret target registry fast path", () => { + beforeEach(() => { + loadPluginManifestRegistryMock.mockClear(); + loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); + }); + + it("resolves bundled channel targets by explicit channel id without manifest scans", () => { + const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); + + expect(target).not.toBeNull(); + expect(target?.entry.id).toBe("channels.googlechat.serviceAccount"); + expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "googlechat", + artifactBasename: "secret-contract-api.js", + }); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); +});