fix(secretrefs): resolve external channel contracts in dist/ sidecars (#77421)

* fix(secretrefs): resolve external channel contracts in dist/ sidecars

Externalized channel plugins published to npm (e.g. @openclaw/discord
since 2026.5.2) keep their compiled secret-contract-api artifact under
<rootDir>/dist/, per the package.json `openclaw.runtimeExtensions`
convention. The runtime contract loader added in #76449 only searched
the rootDir, so npm-installed plugins silently dropped their channel
SecretRef contracts: the runtime snapshot left `channels.<id>.token`
as an unresolved SecretRef, the plugin's `isConfigured` check then
returned false, and the gateway recorded `error: not configured`
without firing the usual channel startup logs.

Look in `<rootDir>/dist/` as well as `<rootDir>/`, preferring dist
when running from a built openclaw artifact and rootDir when running
from source. The new `loads dist/ secret-contract-api sidecars …`
test in channel-contract-api.external.test.ts mirrors the real
npm-package layout and fails without this change.

Refs #76371. Fixes #77416.

* docs: credit changelog contributor

---------

Co-authored-by: Magpie <magpie@local>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Mogglemoss
2026-05-04 14:57:28 -07:00
committed by GitHub
parent a7b665cfed
commit 43b5df7295
3 changed files with 76 additions and 10 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys.
- Secrets/external channel contracts: also look in `<rootDir>/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss.
- Models/auth: add `openclaw models auth list [--provider <id>] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc.
- Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar.
- Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev.

View File

@@ -98,6 +98,66 @@ describe("external channel secret contract api", () => {
expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function");
});
it("loads dist/ secret-contract-api sidecars for compiled npm-published external channel plugins", () => {
const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract-dist", tempDirs);
fs.mkdirSync(path.join(rootDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(rootDir, "dist", "secret-contract-api.cjs"),
`
module.exports = {
secretTargetRegistryEntries: [
{
id: "channels.discord.token",
targetType: "channels.discord.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true
}
],
collectRuntimeConfigAssignments(params) {
params.context.assignments.push({
path: "channels.discord.token",
ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
expected: "string",
apply() {}
});
}
};
`,
"utf8",
);
const record = {
id: "discord",
origin: "global",
channels: ["discord"],
channelConfigs: {},
rootDir,
};
loadPluginMetadataSnapshotMock.mockReturnValue({
plugins: [record],
});
const api = loadChannelSecretContractApi({
channelId: "discord",
config: { channels: { discord: {} } },
env: {},
loadablePluginOrigins: new Map([["discord", "global"]]),
});
expect(api?.secretTargetRegistryEntries).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "channels.discord.token",
}),
]),
);
expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function");
});
it("skips external channel records outside the loadable plugin origin set", () => {
const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" });
loadPluginMetadataSnapshotMock.mockReturnValue({

View File

@@ -87,16 +87,21 @@ function orderedContractApiExtensions(): readonly string[] {
}
function resolvePluginContractApiPath(rootDir: string): string | null {
for (const extension of orderedContractApiExtensions()) {
const candidate = path.join(rootDir, `secret-contract-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
for (const extension of orderedContractApiExtensions()) {
const candidate = path.join(rootDir, `contract-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
// Compiled npm-published plugins place their public artifacts under <rootDir>/dist/
// (per package.json `openclaw.runtimeExtensions`), while flat-layout plugins keep
// them at <rootDir>/. Search both, preferring dist/ when running from built openclaw
// artifacts and rootDir/ when running from source.
const searchDirs = RUNNING_FROM_BUILT_ARTIFACT
? [path.join(rootDir, "dist"), rootDir]
: [rootDir, path.join(rootDir, "dist")];
for (const basename of ["secret-contract-api", "contract-api"]) {
for (const dir of searchDirs) {
for (const extension of orderedContractApiExtensions()) {
const candidate = path.join(dir, `${basename}${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
}
return null;