From 8a4d8c889c3590d08cfcdcdb917f155d945859bf Mon Sep 17 00:00:00 2001 From: Dale Babiy <42547246+minupla@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:34:23 -0500 Subject: [PATCH] fix(secrets): normalize inline SecretRef token/key to tokenRef/keyRef in runtime snapshot (#31047) * fix(secrets): normalize inline SecretRef token/key to tokenRef/keyRef in runtime snapshot When auth-profiles.json uses an inline SecretRef as the token or key value directly (e.g. `"token": {"source":"file",...}`), the resolved plaintext was written back to disk on every updateAuthProfileStoreWithLock call, overwriting the SecretRef. Root cause: collectTokenProfileAssignment and collectApiKeyProfileAssignment detected inline SecretRefs but did not promote them to the canonical tokenRef/keyRef fields. saveAuthProfileStore only strips plaintext when tokenRef/keyRef is set, so the inline case fell through and persisted plaintext on every save. Fix: when an inline SecretRef is detected and no explicit tokenRef/keyRef exists, promote it to the canonical field and delete the inline form. saveAuthProfileStore then correctly strips the resolved plaintext on write. Fixes #29108 * fix test: cast inline SecretRef loadAuthStore mocks to AuthProfileStore * fix(secrets): fix TypeScript type error in runtime test loadAuthStore lambda * test(secrets): keep explicit keyRef precedence over inline key ref --------- Co-authored-by: Peter Steinberger --- src/secrets/runtime.test.ts | 98 ++++++++++++++++++++++++++++++++++++- src/secrets/runtime.ts | 8 +++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 00d11c7392a..113b1807cd6 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; +import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { activateSecretsRuntimeSnapshot, @@ -83,6 +83,102 @@ describe("secrets runtime snapshot", () => { type: "api_key", key: "sk-env-openai", }); + // After normalization, inline SecretRef string should be promoted to keyRef + expect( + (snapshot.authStores[0].store.profiles["openai:inline"] as Record).keyRef, + ).toEqual({ source: "env", provider: "default", id: "OPENAI_API_KEY" }); + }); + + it("normalizes inline SecretRef object on token to tokenRef", async () => { + const config: OpenClawConfig = { models: {}, secrets: {} }; + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: { MY_TOKEN: "resolved-token-value" }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: ((_agentDir?: string) => + ({ + version: 1, + profiles: { + "custom:inline-token": { + type: "token", + provider: "custom", + token: { source: "env", provider: "default", id: "MY_TOKEN" }, + }, + }, + }) as unknown as AuthProfileStore) as (agentDir?: string) => AuthProfileStore, + }); + + const profile = snapshot.authStores[0]?.store.profiles["custom:inline-token"] as Record< + string, + unknown + >; + // tokenRef should be set from the inline SecretRef + expect(profile.tokenRef).toEqual({ source: "env", provider: "default", id: "MY_TOKEN" }); + // token should be resolved to the actual value after activation + activateSecretsRuntimeSnapshot(snapshot); + expect(profile.token).toBe("resolved-token-value"); + }); + + it("normalizes inline SecretRef object on key to keyRef", async () => { + const config: OpenClawConfig = { models: {}, secrets: {} }; + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: { MY_KEY: "resolved-key-value" }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: ((_agentDir?: string) => + ({ + version: 1, + profiles: { + "custom:inline-key": { + type: "api_key", + provider: "custom", + key: { source: "env", provider: "default", id: "MY_KEY" }, + }, + }, + }) as unknown as AuthProfileStore) as (agentDir?: string) => AuthProfileStore, + }); + + const profile = snapshot.authStores[0]?.store.profiles["custom:inline-key"] as Record< + string, + unknown + >; + // keyRef should be set from the inline SecretRef + expect(profile.keyRef).toEqual({ source: "env", provider: "default", id: "MY_KEY" }); + // key should be resolved to the actual value after activation + activateSecretsRuntimeSnapshot(snapshot); + expect(profile.key).toBe("resolved-key-value"); + }); + + it("keeps explicit keyRef when inline key SecretRef is also present", async () => { + const config: OpenClawConfig = { models: {}, secrets: {} }; + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: { + PRIMARY_KEY: "primary-key-value", + SHADOW_KEY: "shadow-key-value", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => + ({ + version: 1, + profiles: { + "custom:explicit-keyref": { + type: "api_key", + provider: "custom", + keyRef: { source: "env", provider: "default", id: "PRIMARY_KEY" }, + key: { source: "env", provider: "default", id: "SHADOW_KEY" }, + }, + }, + }) as AuthProfileStore, + }); + + const profile = snapshot.authStores[0]?.store.profiles["custom:explicit-keyref"] as Record< + string, + unknown + >; + expect(profile.keyRef).toEqual({ source: "env", provider: "default", id: "PRIMARY_KEY" }); + activateSecretsRuntimeSnapshot(snapshot); + expect(profile.key).toBe("primary-key-value"); }); it("resolves file refs via configured file provider", async () => { diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index cb79fbc355c..c75a639ae27 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -241,6 +241,10 @@ function collectApiKeyProfileAssignment(params: { if (!resolvedKeyRef) { return; } + if (inlineKeyRef && !keyRef) { + params.profile.keyRef = inlineKeyRef; + delete (params.profile as unknown as Record).key; + } if (keyRef && isNonEmptyString(params.profile.key)) { params.context.warnings.push({ code: "SECRETS_REF_OVERRIDES_PLAINTEXT", @@ -271,6 +275,10 @@ function collectTokenProfileAssignment(params: { if (!resolvedTokenRef) { return; } + if (inlineTokenRef && !tokenRef) { + params.profile.tokenRef = inlineTokenRef; + delete (params.profile as unknown as Record).token; + } if (tokenRef && isNonEmptyString(params.profile.token)) { params.context.warnings.push({ code: "SECRETS_REF_OVERRIDES_PLAINTEXT",