fix(secrets): preserve auth profile key refs during provider scrub (#77489)

* fix(secrets): preserve auth profile key refs during provider scrub

* Add changelog for secrets apply fix

* Seed auth profile ref for scrub regression

* fix(secrets): guard auth profile ref scrub

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Brandon
2026-05-04 20:50:39 -04:00
committed by GitHub
parent b378a91257
commit e2e0908055
3 changed files with 65 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Agents/performance: pass the resolved workspace through BTW, compaction, embedded-run model generation, and PDF model setup so explicit agent-dir model refreshes can reuse the current workspace-scoped plugin metadata snapshot instead of falling back to cold plugin metadata scans. (#77519, #77532)
- Plugins/performance: let unscoped model catalog and manifest-contract readers reuse the current workspace-compatible plugin metadata snapshot, avoiding repeated cold plugin metadata scans on hot control-plane paths while preserving env/config/workspace compatibility checks. (#77519, #77532)
- Config/plugin auto-enable: prefer the claiming plugin manifest id over a built-in channel alias when auto-allowlisting a configured channel, so WeCom/Yuanbao-style aliases resolve to the installed plugin id. Thanks @Beandon13.
- Secrets/apply: preserve auth-profile `keyRef` and `tokenRef` fields when scrubbing provider-target secrets, so the canonical SecretRef metadata survives `secrets apply` without keeping plaintext values. Thanks @Beandon13.
- 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.

View File

@@ -110,7 +110,8 @@ async function seedDefaultApplyFixture(fixture: ApplyFixture): Promise<void> {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-openai-plaintext", // pragma: allowlist secret
key: "sk-ope...text", // pragma: allowlist secret
keyRef: OPENAI_API_KEY_ENV_REF,
},
},
});
@@ -290,7 +291,11 @@ describe("secrets apply", () => {
profiles: { "openai:default": { key?: string; keyRef?: unknown } };
};
expect(nextAuthStore.profiles["openai:default"].key).toBeUndefined();
expect(nextAuthStore.profiles["openai:default"].keyRef).toBeUndefined();
expect(nextAuthStore.profiles["openai:default"].keyRef).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
const nextAuthJson = JSON.parse(await fs.readFile(fixture.authJsonPath, "utf8")) as Record<
string,
@@ -303,6 +308,58 @@ describe("secrets apply", () => {
expect(nextEnv).toContain("UNRELATED=value");
});
it("preserves auth-profile tokenRef during provider scrub", async () => {
await writeJsonFile(fixture.authStorePath, {
version: 1,
profiles: {
"openai:bot": {
type: "token",
provider: "openai",
token: "sk-token-plaintext", // pragma: allowlist secret
tokenRef: OPENAI_API_KEY_ENV_REF,
},
},
});
const plan = createPlan({
targets: [createOpenAiProviderTarget()],
options: createOneWayScrubOptions(),
});
await runSecretsApply({ plan, env: fixture.env, write: true });
const nextAuthStore = JSON.parse(await fs.readFile(fixture.authStorePath, "utf8")) as {
profiles: { "openai:bot": { token?: string; tokenRef?: unknown } };
};
expect(nextAuthStore.profiles["openai:bot"].token).toBeUndefined();
expect(nextAuthStore.profiles["openai:bot"].tokenRef).toEqual(OPENAI_API_KEY_ENV_REF);
});
it("scrubs malformed auth-profile ref residue during provider scrub", async () => {
await writeJsonFile(fixture.authStorePath, {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-openai-plaintext", // pragma: allowlist secret
keyRef: "secretref-managed", // pragma: allowlist secret
},
},
});
const plan = createPlan({
targets: [createOpenAiProviderTarget()],
options: createOneWayScrubOptions(),
});
await runSecretsApply({ plan, env: fixture.env, write: true });
const nextAuthStore = JSON.parse(await fs.readFile(fixture.authStorePath, "utf8")) as {
profiles: { "openai:default": { key?: string; keyRef?: unknown } };
};
expect(nextAuthStore.profiles["openai:default"].key).toBeUndefined();
expect(nextAuthStore.profiles["openai:default"].keyRef).toBeUndefined();
});
it("skips exec SecretRef checks during dry-run unless explicitly allowed", async () => {
if (process.platform === "win32") {
return;

View File

@@ -15,7 +15,7 @@ import {
type OpenClawConfig,
} from "../config/config.js";
import type { ConfigWriteOptions } from "../config/io.js";
import type { SecretProviderConfig } from "../config/types.secrets.js";
import { coerceSecretRef, type SecretProviderConfig } from "../config/types.secrets.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js";
@@ -411,7 +411,10 @@ function scrubAuthStoresForProviderTargets(params: {
delete profile.profile[profile.valueField];
mutated = true;
}
if (profile.refField in profile.profile) {
if (
profile.refField in profile.profile &&
coerceSecretRef(profile.refValue, params.nextConfig.secrets?.defaults) === null
) {
delete profile.profile[profile.refField];
mutated = true;
}