Skip to content

Commit fbc6632

Browse files
authored
SecretRef: harden custom/provider secret persistence and reuse (openclaw#42554)
* Models: gate custom provider keys by usable secret semantics * Config: project runtime writes onto source snapshot * Models: prevent stale apiKey preservation for marker-managed providers * Runner: strip SecretRef marker headers from resolved models * Secrets: scan active agent models.json path in audit * Config: guard runtime-source projection for unrelated configs * Extensions: fix onboarding type errors in CI * Tests: align setup helper account-enabled expectation * Secrets audit: harden models.json file reads * fix: harden SecretRef custom/provider secret persistence (openclaw#42554) (thanks @joshavant)
1 parent 201420a commit fbc6632

40 files changed

+650
-72
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Docs: https://docs.openclaw.ai
149149
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
150150
- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng.
151151
- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn.
152+
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
152153

153154
## 2026.3.7
154155

extensions/bluebubbles/src/onboarding.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
WizardPrompter,
77
} from "openclaw/plugin-sdk/bluebubbles";
88
import {
9+
DEFAULT_ACCOUNT_ID,
910
formatDocsLink,
1011
mergeAllowFromEntries,
1112
normalizeAccountId,

extensions/googlechat/src/onboarding.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat";
22
import {
3+
DEFAULT_ACCOUNT_ID,
34
applySetupAccountConfigPatch,
45
addWildcardAllowFrom,
56
formatDocsLink,

extensions/nextcloud-talk/src/onboarding.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
232232
botSecret: value,
233233
}),
234234
});
235-
next = secretStep.cfg;
235+
next = secretStep.cfg as CoreConfig;
236236

237237
if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) {
238238
next = setNextcloudTalkAccountConfig(next, accountId, {
@@ -278,7 +278,7 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
278278
next =
279279
apiPasswordStep.action === "keep"
280280
? setNextcloudTalkAccountConfig(next, accountId, { apiUser })
281-
: apiPasswordStep.cfg;
281+
: (apiPasswordStep.cfg as CoreConfig);
282282
}
283283

284284
if (forceAllowFrom) {

extensions/zalouser/src/onboarding.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
WizardPrompter,
66
} from "openclaw/plugin-sdk/zalouser";
77
import {
8+
DEFAULT_ACCOUNT_ID,
89
formatResolvedUnresolvedNote,
910
mergeAllowFromEntries,
1011
normalizeAccountId,

src/agents/model-auth-label.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ vi.mock("./auth-profiles.js", () => ({
1212
}));
1313

1414
vi.mock("./model-auth.js", () => ({
15-
getCustomProviderApiKey: () => undefined,
15+
resolveUsableCustomProviderApiKey: () => null,
1616
resolveEnvApiKey: () => null,
1717
}));
1818

src/agents/model-auth-label.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
resolveAuthProfileDisplayLabel,
66
resolveAuthProfileOrder,
77
} from "./auth-profiles.js";
8-
import { getCustomProviderApiKey, resolveEnvApiKey } from "./model-auth.js";
8+
import { resolveEnvApiKey, resolveUsableCustomProviderApiKey } from "./model-auth.js";
99
import { normalizeProviderId } from "./model-selection.js";
1010

1111
export function resolveModelAuthLabel(params: {
@@ -59,7 +59,10 @@ export function resolveModelAuthLabel(params: {
5959
return `api-key (${envKey.source})`;
6060
}
6161

62-
const customKey = getCustomProviderApiKey(params.cfg, providerKey);
62+
const customKey = resolveUsableCustomProviderApiKey({
63+
cfg: params.cfg,
64+
provider: providerKey,
65+
});
6366
if (customKey) {
6467
return `api-key (models.json)`;
6568
}

src/agents/model-auth-markers.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest";
22
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
3-
import { isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
3+
import {
4+
isKnownEnvApiKeyMarker,
5+
isNonSecretApiKeyMarker,
6+
NON_ENV_SECRETREF_MARKER,
7+
} from "./model-auth-markers.js";
48

59
describe("model auth markers", () => {
610
it("recognizes explicit non-secret markers", () => {
@@ -23,4 +27,9 @@ describe("model auth markers", () => {
2327
it("can exclude env marker-name interpretation for display-only paths", () => {
2428
expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false);
2529
});
30+
31+
it("excludes aws-sdk env markers from known api key env marker helper", () => {
32+
expect(isKnownEnvApiKeyMarker("OPENAI_API_KEY")).toBe(true);
33+
expect(isKnownEnvApiKeyMarker("AWS_PROFILE")).toBe(false);
34+
});
2635
});

src/agents/model-auth-markers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export function isAwsSdkAuthMarker(value: string): boolean {
3535
return AWS_SDK_ENV_MARKERS.has(value.trim());
3636
}
3737

38+
export function isKnownEnvApiKeyMarker(value: string): boolean {
39+
const trimmed = value.trim();
40+
return KNOWN_ENV_API_KEY_MARKERS.has(trimmed) && !isAwsSdkAuthMarker(trimmed);
41+
}
42+
3843
export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string {
3944
return NON_ENV_SECRETREF_MARKER;
4045
}

src/agents/model-auth.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { describe, expect, it } from "vitest";
22
import type { AuthProfileStore } from "./auth-profiles.js";
3-
import { requireApiKey, resolveAwsSdkEnvVarName, resolveModelAuthMode } from "./model-auth.js";
3+
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
4+
import {
5+
hasUsableCustomProviderApiKey,
6+
requireApiKey,
7+
resolveAwsSdkEnvVarName,
8+
resolveModelAuthMode,
9+
resolveUsableCustomProviderApiKey,
10+
} from "./model-auth.js";
411

512
describe("resolveAwsSdkEnvVarName", () => {
613
it("prefers bearer token over access keys and profile", () => {
@@ -117,3 +124,102 @@ describe("requireApiKey", () => {
117124
).toThrow('No API key resolved for provider "openai"');
118125
});
119126
});
127+
128+
describe("resolveUsableCustomProviderApiKey", () => {
129+
it("returns literal custom provider keys", () => {
130+
const resolved = resolveUsableCustomProviderApiKey({
131+
cfg: {
132+
models: {
133+
providers: {
134+
custom: {
135+
baseUrl: "https://example.com/v1",
136+
apiKey: "sk-custom-runtime", // pragma: allowlist secret
137+
models: [],
138+
},
139+
},
140+
},
141+
},
142+
provider: "custom",
143+
});
144+
expect(resolved).toEqual({
145+
apiKey: "sk-custom-runtime",
146+
source: "models.json",
147+
});
148+
});
149+
150+
it("does not treat non-env markers as usable credentials", () => {
151+
const resolved = resolveUsableCustomProviderApiKey({
152+
cfg: {
153+
models: {
154+
providers: {
155+
custom: {
156+
baseUrl: "https://example.com/v1",
157+
apiKey: NON_ENV_SECRETREF_MARKER,
158+
models: [],
159+
},
160+
},
161+
},
162+
},
163+
provider: "custom",
164+
});
165+
expect(resolved).toBeNull();
166+
});
167+
168+
it("resolves known env marker names from process env for custom providers", () => {
169+
const previous = process.env.OPENAI_API_KEY;
170+
process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret
171+
try {
172+
const resolved = resolveUsableCustomProviderApiKey({
173+
cfg: {
174+
models: {
175+
providers: {
176+
custom: {
177+
baseUrl: "https://example.com/v1",
178+
apiKey: "OPENAI_API_KEY",
179+
models: [],
180+
},
181+
},
182+
},
183+
},
184+
provider: "custom",
185+
});
186+
expect(resolved?.apiKey).toBe("sk-from-env");
187+
expect(resolved?.source).toContain("OPENAI_API_KEY");
188+
} finally {
189+
if (previous === undefined) {
190+
delete process.env.OPENAI_API_KEY;
191+
} else {
192+
process.env.OPENAI_API_KEY = previous;
193+
}
194+
}
195+
});
196+
197+
it("does not treat known env marker names as usable when env value is missing", () => {
198+
const previous = process.env.OPENAI_API_KEY;
199+
delete process.env.OPENAI_API_KEY;
200+
try {
201+
expect(
202+
hasUsableCustomProviderApiKey(
203+
{
204+
models: {
205+
providers: {
206+
custom: {
207+
baseUrl: "https://example.com/v1",
208+
apiKey: "OPENAI_API_KEY",
209+
models: [],
210+
},
211+
},
212+
},
213+
},
214+
"custom",
215+
),
216+
).toBe(false);
217+
} finally {
218+
if (previous === undefined) {
219+
delete process.env.OPENAI_API_KEY;
220+
} else {
221+
process.env.OPENAI_API_KEY = previous;
222+
}
223+
}
224+
});
225+
});

0 commit comments

Comments
 (0)