fix(secrets): restore source-mode contract loading

This commit is contained in:
Vincent Koc
2026-04-06 17:58:23 +01:00
parent 725cbcc362
commit 8e2ecd053f
15 changed files with 315 additions and 111 deletions

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -1,17 +1,3 @@
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export const unsupportedSecretRefSurfacePatterns = [
"channels.whatsapp.creds.json",
"channels.whatsapp.accounts.*.creds.json",
] as const;
import { whatsappCommandPolicy as whatsappCommandPolicyImpl } from "./src/command-policy.js";
import { resolveLegacyGroupSessionKey as resolveLegacyGroupSessionKeyImpl } from "./src/group-session-contract.js";
import { __testing as whatsappAccessControlTestingImpl } from "./src/inbound/access-control.js";
@@ -28,6 +14,10 @@ import {
canonicalizeLegacySessionKey as canonicalizeLegacySessionKeyImpl,
isLegacyGroupSessionKey as isLegacyGroupSessionKeyImpl,
} from "./src/session-contract.js";
export {
collectUnsupportedSecretRefConfigCandidates,
unsupportedSecretRefSurfacePatterns,
} from "./src/security-contract.js";
export const canonicalizeLegacySessionKey = canonicalizeLegacySessionKeyImpl;
export const createWhatsAppPollFixture = createWhatsAppPollFixtureImpl;
@@ -39,39 +29,3 @@ export const resolveLegacyGroupSessionKey = resolveLegacyGroupSessionKeyImpl;
export const resolveWhatsAppRuntimeGroupPolicy = resolveWhatsAppRuntimeGroupPolicyImpl;
export const whatsappAccessControlTesting = whatsappAccessControlTestingImpl;
export const whatsappCommandPolicy = whatsappCommandPolicyImpl;
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;
}

View File

@@ -0,0 +1,4 @@
export {
collectUnsupportedSecretRefConfigCandidates,
unsupportedSecretRefSurfacePatterns,
} from "./src/security-contract.js";

View File

@@ -0,0 +1,49 @@
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}

View File

@@ -0,0 +1,82 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-runtime.js";
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import type { ResolverContext, SecretDefaults } from "./runtime-shared.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
type BundledChannelContractApi = {
collectRuntimeConfigAssignments?: (params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}) => void;
secretTargetRegistryEntries?: readonly SecretTargetRegistryEntry[];
unsupportedSecretRefSurfacePatterns?: readonly string[];
collectUnsupportedSecretRefConfigCandidates?: (
raw: Record<string, unknown>,
) => UnsupportedSecretRefConfigCandidate[];
};
function loadBundledChannelPublicArtifact(
channelId: string,
artifactBasenames: readonly string[],
): BundledChannelContractApi | undefined {
const metadata = listBundledPluginMetadata({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
}).find((entry) => entry.manifest.channels?.includes(channelId));
if (!metadata) {
return undefined;
}
for (const artifactBasename of artifactBasenames) {
if (!metadata.publicSurfaceArtifacts?.includes(artifactBasename)) {
continue;
}
try {
return loadBundledPluginPublicSurfaceModuleSync<BundledChannelContractApi>({
dirName: metadata.dirName,
artifactBasename,
});
} catch (error) {
if (process.env.OPENCLAW_DEBUG_CHANNEL_CONTRACT_API === "1") {
const detail = error instanceof Error ? error.message : String(error);
process.stderr.write(
`[channel-contract-api] failed to load ${channelId} via ${metadata.dirName}/${artifactBasename}: ${detail}\n`,
);
}
}
}
return undefined;
}
export type BundledChannelSecretContractApi = Pick<
BundledChannelContractApi,
"collectRuntimeConfigAssignments" | "secretTargetRegistryEntries"
>;
export function loadBundledChannelSecretContractApi(
channelId: string,
): BundledChannelSecretContractApi | undefined {
return loadBundledChannelPublicArtifact(channelId, ["secret-contract-api.js", "contract-api.js"]);
}
export type BundledChannelSecurityContractApi = Pick<
BundledChannelContractApi,
"unsupportedSecretRefSurfacePatterns" | "collectUnsupportedSecretRefConfigCandidates"
>;
export function loadBundledChannelSecurityContractApi(
channelId: string,
): BundledChannelSecurityContractApi | undefined {
return loadBundledChannelPublicArtifact(channelId, [
"security-contract-api.js",
"contract-api.js",
]);
}

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ResolverContext } from "./runtime-shared.js";
const getBootstrapChannelSecrets = vi.fn();
const loadBundledChannelSecretContractApi = vi.fn();
vi.mock("../channels/plugins/bootstrap-registry.js", () => ({
getBootstrapChannelSecrets,
}));
vi.mock("./channel-contract-api.js", () => ({
loadBundledChannelSecretContractApi,
}));
describe("runtime channel config collectors", () => {
beforeEach(() => {
getBootstrapChannelSecrets.mockReset();
loadBundledChannelSecretContractApi.mockReset();
});
it("uses the bundled channel contract-api collector when bootstrap secrets are unavailable", async () => {
const { collectChannelConfigAssignments } =
await import("./runtime-config-collectors-channels.js");
const collectRuntimeConfigAssignments = vi.fn();
loadBundledChannelSecretContractApi.mockReturnValue({
collectRuntimeConfigAssignments,
});
getBootstrapChannelSecrets.mockReturnValue(undefined);
collectChannelConfigAssignments({
config: {
channels: {
bluebubbles: {
accounts: {
ops: {},
},
},
},
} as OpenClawConfig,
defaults: undefined,
context: {} as ResolverContext,
});
expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("bluebubbles");
expect(collectRuntimeConfigAssignments).toHaveBeenCalledOnce();
expect(getBootstrapChannelSecrets).not.toHaveBeenCalled();
});
it("falls back to bootstrap secrets when no channel contract-api is published", async () => {
const { collectChannelConfigAssignments } =
await import("./runtime-config-collectors-channels.js");
const collectRuntimeConfigAssignments = vi.fn();
loadBundledChannelSecretContractApi.mockReturnValue(undefined);
getBootstrapChannelSecrets.mockReturnValue({
collectRuntimeConfigAssignments,
});
collectChannelConfigAssignments({
config: {
channels: {
legacy: {},
},
} as OpenClawConfig,
defaults: undefined,
context: {} as ResolverContext,
});
expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("legacy");
expect(getBootstrapChannelSecrets).toHaveBeenCalledWith("legacy");
expect(collectRuntimeConfigAssignments).toHaveBeenCalledOnce();
});
});

View File

@@ -1,5 +1,6 @@
import { getBootstrapChannelSecrets } from "../channels/plugins/bootstrap-registry.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js";
import { type ResolverContext, type SecretDefaults } from "./runtime-shared.js";
export function collectChannelConfigAssignments(params: {
@@ -12,10 +13,10 @@ export function collectChannelConfigAssignments(params: {
return;
}
for (const channelId of channelIds) {
const secrets = getBootstrapChannelSecrets(channelId);
if (!secrets) {
continue;
}
secrets.collectRuntimeConfigAssignments?.(params);
const contract = loadBundledChannelSecretContractApi(channelId);
const collectRuntimeConfigAssignments =
contract?.collectRuntimeConfigAssignments ??
getBootstrapChannelSecrets(channelId)?.collectRuntimeConfigAssignments;
collectRuntimeConfigAssignments?.(params);
}
}

View File

@@ -53,9 +53,13 @@ const COVERAGE_REGISTRY_ENTRIES = loadCoverageRegistryEntries();
const DEBUG_COVERAGE_BATCHES = process.env.OPENCLAW_DEBUG_RUNTIME_COVERAGE === "1";
let applyResolvedAssignments: typeof import("./runtime-shared.js").applyResolvedAssignments;
let clearBootstrapChannelPluginCache: typeof import("../channels/plugins/bootstrap-registry.js").clearBootstrapChannelPluginCache;
let clearBundledPluginMetadataCache: typeof import("../plugins/bundled-plugin-metadata.js").clearBundledPluginMetadataCache;
let clearPluginManifestRegistryCache: typeof import("../plugins/manifest-registry.js").clearPluginManifestRegistryCache;
let collectAuthStoreAssignments: typeof import("./runtime-auth-collectors.js").collectAuthStoreAssignments;
let collectConfigAssignments: typeof import("./runtime-config-collectors.js").collectConfigAssignments;
let createResolverContext: typeof import("./runtime-shared.js").createResolverContext;
let resetFacadeRuntimeStateForTest: typeof import("../plugin-sdk/facade-runtime.js").resetFacadeRuntimeStateForTest;
let resolveSecretRefValues: typeof import("./resolve.js").resolveSecretRefValues;
let resolveRuntimeWebTools: typeof import("./runtime-web-tools.js").resolveRuntimeWebTools;
@@ -134,7 +138,11 @@ function resolveCoverageBatchKey(entry: SecretRegistryEntry): string {
if (
field === "accessToken" ||
field === "password" ||
(channelId === "slack" && field === "signingSecret")
(channelId === "slack" &&
(field === "appToken" ||
field === "botToken" ||
field === "signingSecret" ||
field === "userToken"))
) {
return entry.id;
}
@@ -419,15 +427,31 @@ async function prepareCoverageSnapshot(params: {
describe("secrets runtime target coverage", () => {
beforeAll(async () => {
const [sharedRuntime, authCollectors, configCollectors, resolver, webTools] = await Promise.all(
[
import("./runtime-shared.js"),
import("./runtime-auth-collectors.js"),
import("./runtime-config-collectors.js"),
import("./resolve.js"),
import("./runtime-web-tools.js"),
],
);
const [
bootstrapRegistry,
facadeRuntime,
bundledPluginMetadata,
manifestRegistry,
sharedRuntime,
authCollectors,
configCollectors,
resolver,
webTools,
] = await Promise.all([
import("../channels/plugins/bootstrap-registry.js"),
import("../plugin-sdk/facade-runtime.js"),
import("../plugins/bundled-plugin-metadata.js"),
import("../plugins/manifest-registry.js"),
import("./runtime-shared.js"),
import("./runtime-auth-collectors.js"),
import("./runtime-config-collectors.js"),
import("./resolve.js"),
import("./runtime-web-tools.js"),
]);
({ clearBootstrapChannelPluginCache } = bootstrapRegistry);
({ resetFacadeRuntimeStateForTest } = facadeRuntime);
({ clearBundledPluginMetadataCache } = bundledPluginMetadata);
({ clearPluginManifestRegistryCache } = manifestRegistry);
({ applyResolvedAssignments, createResolverContext } = sharedRuntime);
({ collectAuthStoreAssignments } = authCollectors);
({ collectConfigAssignments } = configCollectors);
@@ -440,6 +464,10 @@ describe("secrets runtime target coverage", () => {
(entry) => entry.configFile === "openclaw.json",
);
for (const batch of buildCoverageBatches(entries)) {
clearBootstrapChannelPluginCache();
clearBundledPluginMetadataCache();
clearPluginManifestRegistryCache();
resetFacadeRuntimeStateForTest();
logCoverageBatch("openclaw.json", batch);
const config = {} as OpenClawConfig;
const env: Record<string, string> = {};

View File

@@ -1,6 +1,6 @@
import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-runtime.js";
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret
@@ -19,16 +19,13 @@ function listChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] {
continue;
}
if (!metadata.publicSurfaceArtifacts?.includes("contract-api.js")) {
continue;
if (!metadata.publicSurfaceArtifacts?.includes("secret-contract-api.js")) {
continue;
}
}
try {
const contractApi = loadBundledPluginPublicSurfaceModuleSync<{
secretTargetRegistryEntries?: readonly SecretTargetRegistryEntry[];
}>({
dirName: metadata.dirName,
artifactBasename: "contract-api.js",
});
entries.push(...(contractApi.secretTargetRegistryEntries ?? []));
const contractApi = loadBundledChannelSecretContractApi(metadata.manifest.id);
entries.push(...(contractApi?.secretTargetRegistryEntries ?? []));
channelIds.forEach((channelId) => handledChannelIds.add(channelId));
} catch {
// Fall back to the full bootstrap plugin surface for channels that do not

View File

@@ -1,37 +1,10 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { SecretRefCredentialMatrixDocument } from "./credential-matrix.js";
function buildSecretRefCredentialMatrixInSubprocess(): SecretRefCredentialMatrixDocument {
// Building the matrix pulls in bundled channel registry state. Keep that work out of the
// Vitest worker so this docs-sync check does not pay the full module-graph cost in-process.
const childEnv = { ...process.env };
delete childEnv.NODE_OPTIONS;
delete childEnv.VITEST;
delete childEnv.VITEST_MODE;
delete childEnv.VITEST_POOL_ID;
delete childEnv.VITEST_WORKER_ID;
const stdout = execFileSync(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"-e",
'import { buildSecretRefCredentialMatrix } from "./src/secrets/credential-matrix.ts"; process.stdout.write(JSON.stringify(buildSecretRefCredentialMatrix()));',
],
{
cwd: process.cwd(),
encoding: "utf8",
env: childEnv,
maxBuffer: 10 * 1024 * 1024,
},
);
return JSON.parse(stdout) as SecretRefCredentialMatrixDocument;
}
import {
buildSecretRefCredentialMatrix,
type SecretRefCredentialMatrixDocument,
} from "./credential-matrix.js";
describe("secret target registry docs", () => {
it("stays in sync with docs/reference/secretref-user-supplied-credentials-matrix.json", () => {
@@ -44,7 +17,7 @@ describe("secret target registry docs", () => {
const raw = fs.readFileSync(pathname, "utf8");
const parsed = JSON.parse(raw) as unknown;
expect(parsed).toEqual(buildSecretRefCredentialMatrixInSubprocess());
expect(parsed).toEqual(buildSecretRefCredentialMatrix());
});
it("stays in sync with docs/reference/secretref-credential-surface.md", () => {

View File

@@ -1,5 +1,7 @@
import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js";
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import { isRecord } from "../utils.js";
import { loadBundledChannelSecurityContractApi } from "./channel-contract-api.js";
const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [
"commands.ownerDisplaySecret",
@@ -9,10 +11,26 @@ const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [
"auth-profiles.oauth.*",
] as const;
function listBundledChannelIds(): string[] {
return [
...new Set(
listBundledPluginMetadata({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
}).flatMap((entry) => entry.manifest.channels ?? []),
),
].toSorted((left, right) => left.localeCompare(right));
}
function collectChannelUnsupportedSecretRefSurfacePatterns(): string[] {
const patterns: string[] = [];
for (const plugin of iterateBootstrapChannelPlugins()) {
patterns.push(...(plugin.secrets?.unsupportedSecretRefSurfacePatterns ?? []));
for (const channelId of listBundledChannelIds()) {
const contract = loadBundledChannelSecurityContractApi(channelId);
patterns.push(
...(contract?.unsupportedSecretRefSurfacePatterns ??
getBootstrapChannelPlugin(channelId)?.secrets?.unsupportedSecretRefSurfacePatterns ??
[]),
);
}
return patterns;
}
@@ -76,8 +94,13 @@ export function collectUnsupportedSecretRefConfigCandidates(
}
if (isRecord(raw.channels)) {
for (const plugin of iterateBootstrapChannelPlugins()) {
const channelCandidates = plugin.secrets?.collectUnsupportedSecretRefConfigCandidates?.(raw);
for (const channelId of Object.keys(raw.channels)) {
const contract = loadBundledChannelSecurityContractApi(channelId);
const channelCandidates =
contract?.collectUnsupportedSecretRefConfigCandidates?.(raw) ??
getBootstrapChannelPlugin(
channelId,
)?.secrets?.collectUnsupportedSecretRefConfigCandidates?.(raw);
if (!channelCandidates?.length) {
continue;
}