diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e152f49dab..0bb550dc298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/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 ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 68aaa72095e..2ec760ed4b4 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -110,7 +110,8 @@ async function seedDefaultApplyFixture(fixture: ApplyFixture): Promise { "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; diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 29b28157f22..c7dfd6ab4f1 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -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; }