mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
SecretRef: harden custom/provider secret persistence and reuse (#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 (#42554) (thanks @joshavant)
This commit is contained in:
@@ -149,6 +149,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
|
||||||
|
|
||||||
## 2026.3.7
|
## 2026.3.7
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
WizardPrompter,
|
WizardPrompter,
|
||||||
} from "openclaw/plugin-sdk/bluebubbles";
|
} from "openclaw/plugin-sdk/bluebubbles";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
formatDocsLink,
|
formatDocsLink,
|
||||||
mergeAllowFromEntries,
|
mergeAllowFromEntries,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat";
|
import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
applySetupAccountConfigPatch,
|
applySetupAccountConfigPatch,
|
||||||
addWildcardAllowFrom,
|
addWildcardAllowFrom,
|
||||||
formatDocsLink,
|
formatDocsLink,
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
botSecret: value,
|
botSecret: value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
next = secretStep.cfg;
|
next = secretStep.cfg as CoreConfig;
|
||||||
|
|
||||||
if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) {
|
if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) {
|
||||||
next = setNextcloudTalkAccountConfig(next, accountId, {
|
next = setNextcloudTalkAccountConfig(next, accountId, {
|
||||||
@@ -278,7 +278,7 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
next =
|
next =
|
||||||
apiPasswordStep.action === "keep"
|
apiPasswordStep.action === "keep"
|
||||||
? setNextcloudTalkAccountConfig(next, accountId, { apiUser })
|
? setNextcloudTalkAccountConfig(next, accountId, { apiUser })
|
||||||
: apiPasswordStep.cfg;
|
: (apiPasswordStep.cfg as CoreConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forceAllowFrom) {
|
if (forceAllowFrom) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
WizardPrompter,
|
WizardPrompter,
|
||||||
} from "openclaw/plugin-sdk/zalouser";
|
} from "openclaw/plugin-sdk/zalouser";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
formatResolvedUnresolvedNote,
|
formatResolvedUnresolvedNote,
|
||||||
mergeAllowFromEntries,
|
mergeAllowFromEntries,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock("./auth-profiles.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./model-auth.js", () => ({
|
vi.mock("./model-auth.js", () => ({
|
||||||
getCustomProviderApiKey: () => undefined,
|
resolveUsableCustomProviderApiKey: () => null,
|
||||||
resolveEnvApiKey: () => null,
|
resolveEnvApiKey: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
resolveAuthProfileDisplayLabel,
|
resolveAuthProfileDisplayLabel,
|
||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
} from "./auth-profiles.js";
|
} from "./auth-profiles.js";
|
||||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "./model-auth.js";
|
import { resolveEnvApiKey, resolveUsableCustomProviderApiKey } from "./model-auth.js";
|
||||||
import { normalizeProviderId } from "./model-selection.js";
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
|
|
||||||
export function resolveModelAuthLabel(params: {
|
export function resolveModelAuthLabel(params: {
|
||||||
@@ -59,7 +59,10 @@ export function resolveModelAuthLabel(params: {
|
|||||||
return `api-key (${envKey.source})`;
|
return `api-key (${envKey.source})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customKey = getCustomProviderApiKey(params.cfg, providerKey);
|
const customKey = resolveUsableCustomProviderApiKey({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: providerKey,
|
||||||
|
});
|
||||||
if (customKey) {
|
if (customKey) {
|
||||||
return `api-key (models.json)`;
|
return `api-key (models.json)`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
|
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
|
||||||
import { isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
import {
|
||||||
|
isKnownEnvApiKeyMarker,
|
||||||
|
isNonSecretApiKeyMarker,
|
||||||
|
NON_ENV_SECRETREF_MARKER,
|
||||||
|
} from "./model-auth-markers.js";
|
||||||
|
|
||||||
describe("model auth markers", () => {
|
describe("model auth markers", () => {
|
||||||
it("recognizes explicit non-secret markers", () => {
|
it("recognizes explicit non-secret markers", () => {
|
||||||
@@ -23,4 +27,9 @@ describe("model auth markers", () => {
|
|||||||
it("can exclude env marker-name interpretation for display-only paths", () => {
|
it("can exclude env marker-name interpretation for display-only paths", () => {
|
||||||
expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false);
|
expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes aws-sdk env markers from known api key env marker helper", () => {
|
||||||
|
expect(isKnownEnvApiKeyMarker("OPENAI_API_KEY")).toBe(true);
|
||||||
|
expect(isKnownEnvApiKeyMarker("AWS_PROFILE")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export function isAwsSdkAuthMarker(value: string): boolean {
|
|||||||
return AWS_SDK_ENV_MARKERS.has(value.trim());
|
return AWS_SDK_ENV_MARKERS.has(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isKnownEnvApiKeyMarker(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return KNOWN_ENV_API_KEY_MARKERS.has(trimmed) && !isAwsSdkAuthMarker(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string {
|
export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string {
|
||||||
return NON_ENV_SECRETREF_MARKER;
|
return NON_ENV_SECRETREF_MARKER;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||||
import { requireApiKey, resolveAwsSdkEnvVarName, resolveModelAuthMode } from "./model-auth.js";
|
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||||
|
import {
|
||||||
|
hasUsableCustomProviderApiKey,
|
||||||
|
requireApiKey,
|
||||||
|
resolveAwsSdkEnvVarName,
|
||||||
|
resolveModelAuthMode,
|
||||||
|
resolveUsableCustomProviderApiKey,
|
||||||
|
} from "./model-auth.js";
|
||||||
|
|
||||||
describe("resolveAwsSdkEnvVarName", () => {
|
describe("resolveAwsSdkEnvVarName", () => {
|
||||||
it("prefers bearer token over access keys and profile", () => {
|
it("prefers bearer token over access keys and profile", () => {
|
||||||
@@ -117,3 +124,102 @@ describe("requireApiKey", () => {
|
|||||||
).toThrow('No API key resolved for provider "openai"');
|
).toThrow('No API key resolved for provider "openai"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveUsableCustomProviderApiKey", () => {
|
||||||
|
it("returns literal custom provider keys", () => {
|
||||||
|
const resolved = resolveUsableCustomProviderApiKey({
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
baseUrl: "https://example.com/v1",
|
||||||
|
apiKey: "sk-custom-runtime", // pragma: allowlist secret
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "custom",
|
||||||
|
});
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
apiKey: "sk-custom-runtime",
|
||||||
|
source: "models.json",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat non-env markers as usable credentials", () => {
|
||||||
|
const resolved = resolveUsableCustomProviderApiKey({
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
baseUrl: "https://example.com/v1",
|
||||||
|
apiKey: NON_ENV_SECRETREF_MARKER,
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "custom",
|
||||||
|
});
|
||||||
|
expect(resolved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves known env marker names from process env for custom providers", () => {
|
||||||
|
const previous = process.env.OPENAI_API_KEY;
|
||||||
|
process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret
|
||||||
|
try {
|
||||||
|
const resolved = resolveUsableCustomProviderApiKey({
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
baseUrl: "https://example.com/v1",
|
||||||
|
apiKey: "OPENAI_API_KEY",
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "custom",
|
||||||
|
});
|
||||||
|
expect(resolved?.apiKey).toBe("sk-from-env");
|
||||||
|
expect(resolved?.source).toContain("OPENAI_API_KEY");
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.OPENAI_API_KEY = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat known env marker names as usable when env value is missing", () => {
|
||||||
|
const previous = process.env.OPENAI_API_KEY;
|
||||||
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
hasUsableCustomProviderApiKey(
|
||||||
|
{
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
baseUrl: "https://example.com/v1",
|
||||||
|
apiKey: "OPENAI_API_KEY",
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"custom",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.OPENAI_API_KEY = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
resolveAuthStorePathForDisplay,
|
resolveAuthStorePathForDisplay,
|
||||||
} from "./auth-profiles.js";
|
} from "./auth-profiles.js";
|
||||||
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
|
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
|
||||||
import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js";
|
import {
|
||||||
|
isKnownEnvApiKeyMarker,
|
||||||
|
isNonSecretApiKeyMarker,
|
||||||
|
OLLAMA_LOCAL_AUTH_MARKER,
|
||||||
|
} from "./model-auth-markers.js";
|
||||||
import { normalizeProviderId } from "./model-selection.js";
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
|
|
||||||
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
|
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||||
@@ -60,6 +64,49 @@ export function getCustomProviderApiKey(
|
|||||||
return normalizeOptionalSecretInput(entry?.apiKey);
|
return normalizeOptionalSecretInput(entry?.apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResolvedCustomProviderApiKey = {
|
||||||
|
apiKey: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveUsableCustomProviderApiKey(params: {
|
||||||
|
cfg: OpenClawConfig | undefined;
|
||||||
|
provider: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): ResolvedCustomProviderApiKey | null {
|
||||||
|
const customKey = getCustomProviderApiKey(params.cfg, params.provider);
|
||||||
|
if (!customKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isNonSecretApiKeyMarker(customKey)) {
|
||||||
|
return { apiKey: customKey, source: "models.json" };
|
||||||
|
}
|
||||||
|
if (!isKnownEnvApiKeyMarker(customKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[customKey]);
|
||||||
|
if (!envValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const applied = new Set(getShellEnvAppliedKeys());
|
||||||
|
return {
|
||||||
|
apiKey: envValue,
|
||||||
|
source: resolveEnvSourceLabel({
|
||||||
|
applied,
|
||||||
|
envVars: [customKey],
|
||||||
|
label: `${customKey} (models.json marker)`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUsableCustomProviderApiKey(
|
||||||
|
cfg: OpenClawConfig | undefined,
|
||||||
|
provider: string,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(resolveUsableCustomProviderApiKey({ cfg, provider, env }));
|
||||||
|
}
|
||||||
|
|
||||||
function resolveProviderAuthOverride(
|
function resolveProviderAuthOverride(
|
||||||
cfg: OpenClawConfig | undefined,
|
cfg: OpenClawConfig | undefined,
|
||||||
provider: string,
|
provider: string,
|
||||||
@@ -238,9 +285,9 @@ export async function resolveApiKeyForProvider(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
const customKey = resolveUsableCustomProviderApiKey({ cfg, provider });
|
||||||
if (customKey) {
|
if (customKey) {
|
||||||
return { apiKey: customKey, source: "models.json", mode: "api-key" };
|
return { apiKey: customKey.apiKey, source: customKey.source, mode: "api-key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider });
|
const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider });
|
||||||
@@ -360,7 +407,7 @@ export function resolveModelAuthMode(
|
|||||||
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
|
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getCustomProviderApiKey(cfg, resolved)) {
|
if (hasUsableCustomProviderApiKey(cfg, resolved)) {
|
||||||
return "api-key";
|
return "api-key";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -477,6 +477,51 @@ describe("models-config", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces stale merged apiKey when config key normalizes to a known env marker", async () => {
|
||||||
|
await withEnvVar("OPENAI_API_KEY", "sk-plaintext-should-not-appear", async () => {
|
||||||
|
await withTempHome(async () => {
|
||||||
|
await writeAgentModelsJson({
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [{ id: "gpt-4.1", name: "GPT-4.1", input: ["text"] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
models: {
|
||||||
|
mode: "merge",
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-plaintext-should-not-appear", // pragma: allowlist secret; simulates resolved ${OPENAI_API_KEY}
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "gpt-4.1",
|
||||||
|
name: "GPT-4.1",
|
||||||
|
input: ["text"],
|
||||||
|
reasoning: false,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 16384,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await ensureOpenClawModelsJson(cfg);
|
||||||
|
const result = await readGeneratedModelsJson<{
|
||||||
|
providers: Record<string, { apiKey?: string }>;
|
||||||
|
}>();
|
||||||
|
expect(result.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => {
|
it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
|
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
|
||||||
|
|||||||
@@ -92,4 +92,25 @@ describe("models-config merge helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not preserve stale plaintext apiKey when next entry is a marker", () => {
|
||||||
|
const merged = mergeWithExistingProviderSecrets({
|
||||||
|
nextProviders: {
|
||||||
|
custom: {
|
||||||
|
apiKey: "OPENAI_API_KEY", // pragma: allowlist secret
|
||||||
|
models: [{ id: "model", api: "openai-responses" }],
|
||||||
|
} as ProviderConfig,
|
||||||
|
},
|
||||||
|
existingProviders: {
|
||||||
|
custom: {
|
||||||
|
apiKey: preservedApiKey,
|
||||||
|
models: [{ id: "model", api: "openai-responses" }],
|
||||||
|
} as ExistingProviderConfig,
|
||||||
|
},
|
||||||
|
secretRefManagedProviders: new Set<string>(),
|
||||||
|
explicitBaseUrlProviders: new Set<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(merged.custom?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -148,9 +148,14 @@ function resolveProviderApiSurface(
|
|||||||
function shouldPreserveExistingApiKey(params: {
|
function shouldPreserveExistingApiKey(params: {
|
||||||
providerKey: string;
|
providerKey: string;
|
||||||
existing: ExistingProviderConfig;
|
existing: ExistingProviderConfig;
|
||||||
|
nextEntry: ProviderConfig;
|
||||||
secretRefManagedProviders: ReadonlySet<string>;
|
secretRefManagedProviders: ReadonlySet<string>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { providerKey, existing, secretRefManagedProviders } = params;
|
const { providerKey, existing, nextEntry, secretRefManagedProviders } = params;
|
||||||
|
const nextApiKey = typeof nextEntry.apiKey === "string" ? nextEntry.apiKey : "";
|
||||||
|
if (nextApiKey && isNonSecretApiKeyMarker(nextApiKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
!secretRefManagedProviders.has(providerKey) &&
|
!secretRefManagedProviders.has(providerKey) &&
|
||||||
typeof existing.apiKey === "string" &&
|
typeof existing.apiKey === "string" &&
|
||||||
@@ -198,7 +203,14 @@ export function mergeWithExistingProviderSecrets(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const preserved: Record<string, unknown> = {};
|
const preserved: Record<string, unknown> = {};
|
||||||
if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) {
|
if (
|
||||||
|
shouldPreserveExistingApiKey({
|
||||||
|
providerKey: key,
|
||||||
|
existing,
|
||||||
|
nextEntry: newEntry,
|
||||||
|
secretRefManagedProviders,
|
||||||
|
})
|
||||||
|
) {
|
||||||
preserved.apiKey = existing.apiKey;
|
preserved.apiKey = existing.apiKey;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ describe("normalizeProviders", () => {
|
|||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
|
||||||
const original = process.env.OPENAI_API_KEY;
|
const original = process.env.OPENAI_API_KEY;
|
||||||
process.env.OPENAI_API_KEY = "sk-test-secret-value-12345"; // pragma: allowlist secret
|
process.env.OPENAI_API_KEY = "sk-test-secret-value-12345"; // pragma: allowlist secret
|
||||||
|
const secretRefManagedProviders = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const providers: NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]> = {
|
const providers: NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]> = {
|
||||||
openai: {
|
openai: {
|
||||||
@@ -97,8 +98,9 @@ describe("normalizeProviders", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const normalized = normalizeProviders({ providers, agentDir });
|
const normalized = normalizeProviders({ providers, agentDir, secretRefManagedProviders });
|
||||||
expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY");
|
expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY");
|
||||||
|
expect(secretRefManagedProviders.has("openai")).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) {
|
if (original === undefined) {
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
|||||||
@@ -347,6 +347,9 @@ export function normalizeProviders(params: {
|
|||||||
apiKey: normalizedConfiguredApiKey,
|
apiKey: normalizedConfiguredApiKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (isNonSecretApiKeyMarker(normalizedConfiguredApiKey)) {
|
||||||
|
params.secretRefManagedProviders?.add(normalizedKey);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
profileApiKey &&
|
profileApiKey &&
|
||||||
profileApiKey.source !== "plaintext" &&
|
profileApiKey.source !== "plaintext" &&
|
||||||
@@ -370,6 +373,7 @@ export function normalizeProviders(params: {
|
|||||||
if (envVarName && env[envVarName] === currentApiKey) {
|
if (envVarName && env[envVarName] === currentApiKey) {
|
||||||
mutated = true;
|
mutated = true;
|
||||||
normalizedProvider = { ...normalizedProvider, apiKey: envVarName };
|
normalizedProvider = { ...normalizedProvider, apiKey: envVarName };
|
||||||
|
params.secretRefManagedProviders?.add(normalizedKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,56 @@ describe("models-config runtime source snapshot", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("projects cloned runtime configs onto source snapshot when preserving provider auth", async () => {
|
||||||
|
await withTempHome(async () => {
|
||||||
|
const sourceConfig: OpenClawConfig = {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
|
||||||
|
api: "openai-completions" as const,
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const runtimeConfig: OpenClawConfig = {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-runtime-resolved", // pragma: allowlist secret
|
||||||
|
api: "openai-completions" as const,
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const clonedRuntimeConfig: OpenClawConfig = {
|
||||||
|
...runtimeConfig,
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
imageModel: "openai/gpt-image-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||||
|
await ensureOpenClawModelsJson(clonedRuntimeConfig);
|
||||||
|
|
||||||
|
const parsed = await readGeneratedModelsJson<{
|
||||||
|
providers: Record<string, { apiKey?: string }>;
|
||||||
|
}>();
|
||||||
|
expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
||||||
|
} finally {
|
||||||
|
clearRuntimeConfigSnapshot();
|
||||||
|
clearConfigCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => {
|
it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
const sourceConfig: OpenClawConfig = {
|
const sourceConfig: OpenClawConfig = {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
getRuntimeConfigSnapshot,
|
|
||||||
getRuntimeConfigSourceSnapshot,
|
getRuntimeConfigSourceSnapshot,
|
||||||
|
projectConfigOntoRuntimeSourceSnapshot,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
@@ -44,17 +44,13 @@ async function writeModelsFileAtomic(targetPath: string, contents: string): Prom
|
|||||||
|
|
||||||
function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig {
|
function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig {
|
||||||
const runtimeSource = getRuntimeConfigSourceSnapshot();
|
const runtimeSource = getRuntimeConfigSourceSnapshot();
|
||||||
if (!runtimeSource) {
|
|
||||||
return config ?? loadConfig();
|
|
||||||
}
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return runtimeSource;
|
return runtimeSource ?? loadConfig();
|
||||||
}
|
}
|
||||||
const runtimeResolved = getRuntimeConfigSnapshot();
|
if (!runtimeSource) {
|
||||||
if (runtimeResolved && config === runtimeResolved) {
|
return config;
|
||||||
return runtimeSource;
|
|
||||||
}
|
}
|
||||||
return config;
|
return projectConfigOntoRuntimeSourceSnapshot(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withModelsJsonWriteLock<T>(targetPath: string, run: () => Promise<T>): Promise<T> {
|
async function withModelsJsonWriteLock<T>(targetPath: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ vi.mock("../agents/auth-profiles.js", () => ({
|
|||||||
|
|
||||||
vi.mock("../agents/model-auth.js", () => ({
|
vi.mock("../agents/model-auth.js", () => ({
|
||||||
resolveEnvApiKey: () => null,
|
resolveEnvApiKey: () => null,
|
||||||
getCustomProviderApiKey: () => null,
|
resolveUsableCustomProviderApiKey: () => null,
|
||||||
resolveModelAuthMode: () => "api-key",
|
resolveModelAuthMode: () => "api-key",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ describe("buildInlineProviderModels", () => {
|
|||||||
expect(result[0].headers).toBeUndefined();
|
expect(result[0].headers).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves literal marker-shaped headers in inline provider models", () => {
|
it("drops SecretRef marker headers in inline provider models", () => {
|
||||||
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
|
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
|
||||||
custom: {
|
custom: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -196,8 +196,6 @@ describe("buildInlineProviderModels", () => {
|
|||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].headers).toEqual({
|
expect(result[0].headers).toEqual({
|
||||||
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
|
|
||||||
"X-Managed": "secretref-managed",
|
|
||||||
"X-Static": "tenant-a",
|
"X-Static": "tenant-a",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -245,7 +243,7 @@ describe("resolveModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves literal marker-shaped provider headers in fallback models", () => {
|
it("drops SecretRef marker provider headers in fallback models", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
models: {
|
models: {
|
||||||
providers: {
|
providers: {
|
||||||
@@ -266,8 +264,6 @@ describe("resolveModel", () => {
|
|||||||
|
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||||
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
|
|
||||||
"X-Managed": "secretref-managed",
|
|
||||||
"X-Custom-Auth": "token-123",
|
"X-Custom-Auth": "token-123",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,8 +81,12 @@ function applyConfiguredProviderOverrides(params: {
|
|||||||
const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, {
|
const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, {
|
||||||
stripSecretRefMarkers: true,
|
stripSecretRefMarkers: true,
|
||||||
});
|
});
|
||||||
const providerHeaders = sanitizeModelHeaders(providerConfig.headers);
|
const providerHeaders = sanitizeModelHeaders(providerConfig.headers, {
|
||||||
const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers);
|
stripSecretRefMarkers: true,
|
||||||
|
});
|
||||||
|
const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers, {
|
||||||
|
stripSecretRefMarkers: true,
|
||||||
|
});
|
||||||
if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) {
|
if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) {
|
||||||
return {
|
return {
|
||||||
...discoveredModel,
|
...discoveredModel,
|
||||||
@@ -118,14 +122,18 @@ export function buildInlineProviderModels(
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const providerHeaders = sanitizeModelHeaders(entry?.headers);
|
const providerHeaders = sanitizeModelHeaders(entry?.headers, {
|
||||||
|
stripSecretRefMarkers: true,
|
||||||
|
});
|
||||||
return (entry?.models ?? []).map((model) => ({
|
return (entry?.models ?? []).map((model) => ({
|
||||||
...model,
|
...model,
|
||||||
provider: trimmed,
|
provider: trimmed,
|
||||||
baseUrl: entry?.baseUrl,
|
baseUrl: entry?.baseUrl,
|
||||||
api: model.api ?? entry?.api,
|
api: model.api ?? entry?.api,
|
||||||
headers: (() => {
|
headers: (() => {
|
||||||
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers);
|
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, {
|
||||||
|
stripSecretRefMarkers: true,
|
||||||
|
});
|
||||||
if (!providerHeaders && !modelHeaders) {
|
if (!providerHeaders && !modelHeaders) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -205,8 +213,12 @@ export function resolveModelWithRegistry(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
|
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
|
||||||
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers);
|
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, {
|
||||||
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers);
|
stripSecretRefMarkers: true,
|
||||||
|
});
|
||||||
|
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers, {
|
||||||
|
stripSecretRefMarkers: true,
|
||||||
|
});
|
||||||
if (providerConfig || modelId.startsWith("mock-")) {
|
if (providerConfig || modelId.startsWith("mock-")) {
|
||||||
return normalizeResolvedModel({
|
return normalizeResolvedModel({
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ vi.mock("../../agents/model-selection.js", () => ({
|
|||||||
|
|
||||||
vi.mock("../../agents/model-auth.js", () => ({
|
vi.mock("../../agents/model-auth.js", () => ({
|
||||||
ensureAuthProfileStore: () => mockStore,
|
ensureAuthProfileStore: () => mockStore,
|
||||||
getCustomProviderApiKey: () => undefined,
|
resolveUsableCustomProviderApiKey: () => null,
|
||||||
resolveAuthProfileOrder: () => mockOrder,
|
resolveAuthProfileOrder: () => mockOrder,
|
||||||
resolveEnvApiKey: () => null,
|
resolveEnvApiKey: () => null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
} from "../../agents/auth-profiles.js";
|
} from "../../agents/auth-profiles.js";
|
||||||
import {
|
import {
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
getCustomProviderApiKey,
|
|
||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
resolveEnvApiKey,
|
resolveEnvApiKey,
|
||||||
|
resolveUsableCustomProviderApiKey,
|
||||||
} from "../../agents/model-auth.js";
|
} from "../../agents/model-auth.js";
|
||||||
import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js";
|
import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
@@ -204,7 +204,7 @@ export const resolveAuthLabel = async (
|
|||||||
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
|
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
|
||||||
return { label, source: mode === "verbose" ? envKey.source : "" };
|
return { label, source: mode === "verbose" ? envKey.source : "" };
|
||||||
}
|
}
|
||||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
const customKey = resolveUsableCustomProviderApiKey({ cfg, provider })?.apiKey;
|
||||||
if (customKey) {
|
if (customKey) {
|
||||||
return {
|
return {
|
||||||
label: maskApiKey(customKey),
|
label: maskApiKey(customKey),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("applySetupAccountConfigPatch", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("patches named account config and enables both channel and account", () => {
|
it("patches named account config and preserves existing account enabled flag", () => {
|
||||||
const next = applySetupAccountConfigPatch({
|
const next = applySetupAccountConfigPatch({
|
||||||
cfg: asConfig({
|
cfg: asConfig({
|
||||||
channels: {
|
channels: {
|
||||||
@@ -50,7 +50,7 @@ describe("applySetupAccountConfigPatch", () => {
|
|||||||
expect(next.channels?.zalo).toMatchObject({
|
expect(next.channels?.zalo).toMatchObject({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
work: { enabled: true, botToken: "new" },
|
work: { enabled: false, botToken: "new" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
|
||||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
|
import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
|
||||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
@@ -34,8 +34,8 @@ export async function warnIfModelConfigLooksOff(
|
|||||||
const store = ensureAuthProfileStore(options?.agentDir);
|
const store = ensureAuthProfileStore(options?.agentDir);
|
||||||
const hasProfile = listProfilesForProvider(store, ref.provider).length > 0;
|
const hasProfile = listProfilesForProvider(store, ref.provider).length > 0;
|
||||||
const envKey = resolveEnvApiKey(ref.provider);
|
const envKey = resolveEnvApiKey(ref.provider);
|
||||||
const customKey = getCustomProviderApiKey(config, ref.provider);
|
const hasCustomKey = hasUsableCustomProviderApiKey(config, ref.provider);
|
||||||
if (!hasProfile && !envKey && !customKey) {
|
if (!hasProfile && !envKey && !hasCustomKey) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`,
|
`No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ vi.mock("../agents/auth-profiles.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined));
|
const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined));
|
||||||
const getCustomProviderApiKey = vi.hoisted(() => vi.fn(() => undefined));
|
const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false));
|
||||||
vi.mock("../agents/model-auth.js", () => ({
|
vi.mock("../agents/model-auth.js", () => ({
|
||||||
resolveEnvApiKey,
|
resolveEnvApiKey,
|
||||||
getCustomProviderApiKey,
|
hasUsableCustomProviderApiKey,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const OPENROUTER_CATALOG = [
|
const OPENROUTER_CATALOG = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
|
import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
|
||||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
import {
|
import {
|
||||||
buildAllowedModelSet,
|
buildAllowedModelSet,
|
||||||
@@ -52,7 +52,7 @@ function hasAuthForProvider(
|
|||||||
if (resolveEnvApiKey(provider)) {
|
if (resolveEnvApiKey(provider)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (getCustomProviderApiKey(cfg, provider)) {
|
if (hasUsableCustomProviderApiKey(cfg, provider)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const resolveAuthStorePathForDisplay = vi
|
|||||||
const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null);
|
const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null);
|
||||||
const resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
|
const resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
|
||||||
const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined);
|
const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined);
|
||||||
|
const hasUsableCustomProviderApiKey = vi.fn().mockReturnValue(false);
|
||||||
|
const resolveUsableCustomProviderApiKey = vi.fn().mockReturnValue(null);
|
||||||
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
|
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
|
||||||
const modelRegistryState = {
|
const modelRegistryState = {
|
||||||
models: [] as Array<Record<string, unknown>>,
|
models: [] as Array<Record<string, unknown>>,
|
||||||
@@ -57,6 +59,8 @@ vi.mock("../agents/auth-profiles.js", () => ({
|
|||||||
vi.mock("../agents/model-auth.js", () => ({
|
vi.mock("../agents/model-auth.js", () => ({
|
||||||
resolveEnvApiKey,
|
resolveEnvApiKey,
|
||||||
resolveAwsSdkEnvVarName,
|
resolveAwsSdkEnvVarName,
|
||||||
|
hasUsableCustomProviderApiKey,
|
||||||
|
resolveUsableCustomProviderApiKey,
|
||||||
getCustomProviderApiKey,
|
getCustomProviderApiKey,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ describe("resolveProviderAuthOverview", () => {
|
|||||||
modelsPath: "/tmp/models.json",
|
modelsPath: "/tmp/models.json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(overview.effective.kind).toBe("models.json");
|
expect(overview.effective.kind).toBe("missing");
|
||||||
expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`);
|
expect(overview.effective.detail).toBe("missing");
|
||||||
expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`);
|
expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,8 +66,41 @@ describe("resolveProviderAuthOverview", () => {
|
|||||||
modelsPath: "/tmp/models.json",
|
modelsPath: "/tmp/models.json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(overview.effective.kind).toBe("models.json");
|
expect(overview.effective.kind).toBe("missing");
|
||||||
expect(overview.effective.detail).not.toContain("marker(");
|
expect(overview.effective.detail).toBe("missing");
|
||||||
expect(overview.effective.detail).not.toContain("OPENAI_API_KEY");
|
expect(overview.modelsJson?.value).not.toContain("marker(");
|
||||||
|
expect(overview.modelsJson?.value).not.toContain("OPENAI_API_KEY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats env-var marker as usable only when the env key is currently resolvable", () => {
|
||||||
|
const prior = process.env.OPENAI_API_KEY;
|
||||||
|
process.env.OPENAI_API_KEY = "sk-openai-from-env"; // pragma: allowlist secret
|
||||||
|
try {
|
||||||
|
const overview = resolveProviderAuthOverview({
|
||||||
|
provider: "openai",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: "OPENAI_API_KEY", // pragma: allowlist secret
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
store: { version: 1, profiles: {} } as never,
|
||||||
|
modelsPath: "/tmp/models.json",
|
||||||
|
});
|
||||||
|
expect(overview.effective.kind).toBe("env");
|
||||||
|
expect(overview.effective.detail).not.toContain("OPENAI_API_KEY");
|
||||||
|
} finally {
|
||||||
|
if (prior === undefined) {
|
||||||
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.OPENAI_API_KEY = prior;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
resolveProfileUnusableUntilForDisplay,
|
resolveProfileUnusableUntilForDisplay,
|
||||||
} from "../../agents/auth-profiles.js";
|
} from "../../agents/auth-profiles.js";
|
||||||
import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js";
|
import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js";
|
||||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
|
import {
|
||||||
|
getCustomProviderApiKey,
|
||||||
|
resolveEnvApiKey,
|
||||||
|
resolveUsableCustomProviderApiKey,
|
||||||
|
} from "../../agents/model-auth.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
import { shortenHomePath } from "../../utils.js";
|
||||||
import { maskApiKey } from "./list.format.js";
|
import { maskApiKey } from "./list.format.js";
|
||||||
@@ -99,6 +103,7 @@ export function resolveProviderAuthOverview(params: {
|
|||||||
|
|
||||||
const envKey = resolveEnvApiKey(provider);
|
const envKey = resolveEnvApiKey(provider);
|
||||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
const customKey = getCustomProviderApiKey(cfg, provider);
|
||||||
|
const usableCustomKey = resolveUsableCustomProviderApiKey({ cfg, provider });
|
||||||
|
|
||||||
const effective: ProviderAuthOverview["effective"] = (() => {
|
const effective: ProviderAuthOverview["effective"] = (() => {
|
||||||
if (profiles.length > 0) {
|
if (profiles.length > 0) {
|
||||||
@@ -115,8 +120,8 @@ export function resolveProviderAuthOverview(params: {
|
|||||||
detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey),
|
detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (customKey) {
|
if (usableCustomKey) {
|
||||||
return { kind: "models.json", detail: formatMarkerOrSecret(customKey) };
|
return { kind: "models.json", detail: formatMarkerOrSecret(usableCustomKey.apiKey) };
|
||||||
}
|
}
|
||||||
return { kind: "missing", detail: "missing" };
|
return { kind: "missing", detail: "missing" };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import {
|
|||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
} from "../../agents/auth-profiles.js";
|
} from "../../agents/auth-profiles.js";
|
||||||
import { describeFailoverError } from "../../agents/failover-error.js";
|
import { describeFailoverError } from "../../agents/failover-error.js";
|
||||||
import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js";
|
import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
|
|
||||||
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
||||||
import {
|
import {
|
||||||
findNormalizedProviderValue,
|
findNormalizedProviderValue,
|
||||||
@@ -373,8 +372,7 @@ export async function buildProbeTargets(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envKey = resolveEnvApiKey(providerKey);
|
const envKey = resolveEnvApiKey(providerKey);
|
||||||
const customKey = getCustomProviderApiKey(cfg, providerKey);
|
const hasUsableModelsJsonKey = hasUsableCustomProviderApiKey(cfg, providerKey);
|
||||||
const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey));
|
|
||||||
if (!envKey && !hasUsableModelsJsonKey) {
|
if (!envKey && !hasUsableModelsJsonKey) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
|
|||||||
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
|
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
|
||||||
import { listProfilesForProvider } from "../../agents/auth-profiles.js";
|
import { listProfilesForProvider } from "../../agents/auth-profiles.js";
|
||||||
import {
|
import {
|
||||||
getCustomProviderApiKey,
|
hasUsableCustomProviderApiKey,
|
||||||
resolveAwsSdkEnvVarName,
|
resolveAwsSdkEnvVarName,
|
||||||
resolveEnvApiKey,
|
resolveEnvApiKey,
|
||||||
} from "../../agents/model-auth.js";
|
} from "../../agents/model-auth.js";
|
||||||
@@ -35,7 +35,7 @@ const hasAuthForProvider = (
|
|||||||
if (resolveEnvApiKey(provider)) {
|
if (resolveEnvApiKey(provider)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (getCustomProviderApiKey(cfg, provider)) {
|
if (hasUsableCustomProviderApiKey(cfg, provider)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const mocks = vi.hoisted(() => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
|
hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false),
|
||||||
|
resolveUsableCustomProviderApiKey: vi.fn().mockReturnValue(null),
|
||||||
getCustomProviderApiKey: vi.fn().mockReturnValue(undefined),
|
getCustomProviderApiKey: vi.fn().mockReturnValue(undefined),
|
||||||
getShellEnvAppliedKeys: vi.fn().mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]),
|
getShellEnvAppliedKeys: vi.fn().mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]),
|
||||||
shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true),
|
shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true),
|
||||||
@@ -106,6 +108,8 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
|
|||||||
|
|
||||||
vi.mock("../../agents/model-auth.js", () => ({
|
vi.mock("../../agents/model-auth.js", () => ({
|
||||||
resolveEnvApiKey: mocks.resolveEnvApiKey,
|
resolveEnvApiKey: mocks.resolveEnvApiKey,
|
||||||
|
hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey,
|
||||||
|
resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey,
|
||||||
getCustomProviderApiKey: mocks.getCustomProviderApiKey,
|
getCustomProviderApiKey: mocks.getCustomProviderApiKey,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
createConfigIO,
|
createConfigIO,
|
||||||
getRuntimeConfigSnapshot,
|
getRuntimeConfigSnapshot,
|
||||||
getRuntimeConfigSourceSnapshot,
|
getRuntimeConfigSourceSnapshot,
|
||||||
|
projectConfigOntoRuntimeSourceSnapshot,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
readBestEffortConfig,
|
readBestEffortConfig,
|
||||||
parseConfigJson5,
|
parseConfigJson5,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
clearRuntimeConfigSnapshot,
|
clearRuntimeConfigSnapshot,
|
||||||
getRuntimeConfigSourceSnapshot,
|
getRuntimeConfigSourceSnapshot,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
projectConfigOntoRuntimeSourceSnapshot,
|
||||||
setRuntimeConfigSnapshotRefreshHandler,
|
setRuntimeConfigSnapshotRefreshHandler,
|
||||||
setRuntimeConfigSnapshot,
|
setRuntimeConfigSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
@@ -61,6 +62,46 @@ describe("runtime config snapshot writes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips source projection for non-runtime-derived configs", async () => {
|
||||||
|
await withTempHome("openclaw-config-runtime-projection-shape-", async () => {
|
||||||
|
const sourceConfig: OpenClawConfig = {
|
||||||
|
...createSourceConfig(),
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const runtimeConfig: OpenClawConfig = {
|
||||||
|
...createRuntimeConfig(),
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const independentConfig: OpenClawConfig = {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-independent-config", // pragma: allowlist secret
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||||
|
const projected = projectConfigOntoRuntimeSourceSnapshot(independentConfig);
|
||||||
|
expect(projected).toBe(independentConfig);
|
||||||
|
} finally {
|
||||||
|
resetRuntimeConfigState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("clears runtime source snapshot when runtime snapshot is cleared", async () => {
|
it("clears runtime source snapshot when runtime snapshot is cleared", async () => {
|
||||||
const sourceConfig = createSourceConfig();
|
const sourceConfig = createSourceConfig();
|
||||||
const runtimeConfig = createRuntimeConfig();
|
const runtimeConfig = createRuntimeConfig();
|
||||||
|
|||||||
@@ -1374,6 +1374,58 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
|
|||||||
return runtimeConfigSourceSnapshot;
|
return runtimeConfigSourceSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCompatibleTopLevelRuntimeProjectionShape(params: {
|
||||||
|
runtimeSnapshot: OpenClawConfig;
|
||||||
|
candidate: OpenClawConfig;
|
||||||
|
}): boolean {
|
||||||
|
const runtime = params.runtimeSnapshot as Record<string, unknown>;
|
||||||
|
const candidate = params.candidate as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(runtime)) {
|
||||||
|
if (!Object.hasOwn(candidate, key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const runtimeValue = runtime[key];
|
||||||
|
const candidateValue = candidate[key];
|
||||||
|
const runtimeType = Array.isArray(runtimeValue)
|
||||||
|
? "array"
|
||||||
|
: runtimeValue === null
|
||||||
|
? "null"
|
||||||
|
: typeof runtimeValue;
|
||||||
|
const candidateType = Array.isArray(candidateValue)
|
||||||
|
? "array"
|
||||||
|
: candidateValue === null
|
||||||
|
? "null"
|
||||||
|
: typeof candidateValue;
|
||||||
|
if (runtimeType !== candidateType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectConfigOntoRuntimeSourceSnapshot(config: OpenClawConfig): OpenClawConfig {
|
||||||
|
if (!runtimeConfigSnapshot || !runtimeConfigSourceSnapshot) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
if (config === runtimeConfigSnapshot) {
|
||||||
|
return runtimeConfigSourceSnapshot;
|
||||||
|
}
|
||||||
|
// This projection expects callers to pass config objects derived from the
|
||||||
|
// active runtime snapshot (for example shallow/deep clones with targeted edits).
|
||||||
|
// For structurally unrelated configs, skip projection to avoid accidental
|
||||||
|
// merge-patch deletions or reintroducing resolved values into source refs.
|
||||||
|
if (
|
||||||
|
!isCompatibleTopLevelRuntimeProjectionShape({
|
||||||
|
runtimeSnapshot: runtimeConfigSnapshot,
|
||||||
|
candidate: config,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
const runtimePatch = createMergePatch(runtimeConfigSnapshot, config);
|
||||||
|
return coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
|
||||||
|
}
|
||||||
|
|
||||||
export function setRuntimeConfigSnapshotRefreshHandler(
|
export function setRuntimeConfigSnapshotRefreshHandler(
|
||||||
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
|
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
} from "../agents/auth-profiles.js";
|
} from "../agents/auth-profiles.js";
|
||||||
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
|
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
|
||||||
import { getCustomProviderApiKey } from "../agents/model-auth.js";
|
import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js";
|
||||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
||||||
@@ -42,7 +42,9 @@ function resolveZaiApiKey(): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const key = getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai");
|
const key =
|
||||||
|
resolveUsableCustomProviderApiKey({ cfg, provider: "zai" })?.apiKey ??
|
||||||
|
resolveUsableCustomProviderApiKey({ cfg, provider: "z-ai" })?.apiKey;
|
||||||
if (key) {
|
if (key) {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
@@ -103,8 +105,11 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const key = getCustomProviderApiKey(cfg, params.providerId);
|
const key = resolveUsableCustomProviderApiKey({
|
||||||
if (key && !isNonSecretApiKeyMarker(key)) {
|
cfg,
|
||||||
|
provider: params.providerId,
|
||||||
|
})?.apiKey;
|
||||||
|
if (key) {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type AuditFixture = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret
|
const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret
|
||||||
|
const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
@@ -482,6 +483,73 @@ describe("secrets audit", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports non-regular models.json files as unresolved findings", async () => {
|
||||||
|
await fs.rm(fixture.modelsPath, { force: true });
|
||||||
|
await fs.mkdir(fixture.modelsPath, { recursive: true });
|
||||||
|
const report = await runSecretsAudit({ env: fixture.env });
|
||||||
|
expect(
|
||||||
|
hasFinding(
|
||||||
|
report,
|
||||||
|
(entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports oversized models.json as unresolved findings", async () => {
|
||||||
|
const oversizedApiKey = "a".repeat(MAX_AUDIT_MODELS_JSON_BYTES + 256);
|
||||||
|
await writeJsonFile(fixture.modelsPath, {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: oversizedApiKey,
|
||||||
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await runSecretsAudit({ env: fixture.env });
|
||||||
|
expect(
|
||||||
|
hasFinding(
|
||||||
|
report,
|
||||||
|
(entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scans active agent-dir override models.json even when outside state dir", async () => {
|
||||||
|
const externalAgentDir = path.join(fixture.rootDir, "external-agent");
|
||||||
|
const externalModelsPath = path.join(externalAgentDir, "models.json");
|
||||||
|
await fs.mkdir(externalAgentDir, { recursive: true });
|
||||||
|
await writeJsonFile(externalModelsPath, {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: "sk-external-plaintext", // pragma: allowlist secret
|
||||||
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await runSecretsAudit({
|
||||||
|
env: {
|
||||||
|
...fixture.env,
|
||||||
|
OPENCLAW_AGENT_DIR: externalAgentDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
hasFinding(
|
||||||
|
report,
|
||||||
|
(entry) =>
|
||||||
|
entry.code === "PLAINTEXT_FOUND" &&
|
||||||
|
entry.file === externalModelsPath &&
|
||||||
|
entry.jsonPath === "providers.openai.apiKey",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(report.filesScanned).toContain(externalModelsPath);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not flag non-sensitive routing headers in openclaw config", async () => {
|
it("does not flag non-sensitive routing headers in openclaw config", async () => {
|
||||||
await writeJsonFile(fixture.configPath, {
|
await writeJsonFile(fixture.configPath, {
|
||||||
models: {
|
models: {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ type AuditCollector = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const REF_RESOLVE_FALLBACK_CONCURRENCY = 8;
|
const REF_RESOLVE_FALLBACK_CONCURRENCY = 8;
|
||||||
|
const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024;
|
||||||
const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([
|
const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([
|
||||||
"authorization",
|
"authorization",
|
||||||
"proxy-authorization",
|
"proxy-authorization",
|
||||||
@@ -369,7 +370,10 @@ function collectModelsJsonSecrets(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
params.collector.filesScanned.add(params.modelsJsonPath);
|
params.collector.filesScanned.add(params.modelsJsonPath);
|
||||||
const parsedResult = readJsonObjectIfExists(params.modelsJsonPath);
|
const parsedResult = readJsonObjectIfExists(params.modelsJsonPath, {
|
||||||
|
requireRegularFile: true,
|
||||||
|
maxBytes: MAX_AUDIT_MODELS_JSON_BYTES,
|
||||||
|
});
|
||||||
if (parsedResult.error) {
|
if (parsedResult.error) {
|
||||||
addFinding(params.collector, {
|
addFinding(params.collector, {
|
||||||
code: "REF_UNRESOLVED",
|
code: "REF_UNRESOLVED",
|
||||||
@@ -630,7 +634,7 @@ export async function runSecretsAudit(
|
|||||||
defaults,
|
defaults,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir)) {
|
for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir, env)) {
|
||||||
collectModelsJsonSecrets({
|
collectModelsJsonSecrets({
|
||||||
modelsJsonPath,
|
modelsJsonPath,
|
||||||
collector,
|
collector,
|
||||||
|
|||||||
@@ -32,11 +32,25 @@ export function listLegacyAuthJsonPaths(stateDir: string): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: string): string[] {
|
function resolveActiveAgentDir(stateDir: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const paths = new Set<string>();
|
const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
|
||||||
paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json"));
|
if (override) {
|
||||||
|
return resolveUserPath(override);
|
||||||
|
}
|
||||||
|
return path.join(resolveUserPath(stateDir), "agents", "main", "agent");
|
||||||
|
}
|
||||||
|
|
||||||
const agentsRoot = path.join(resolveUserPath(stateDir), "agents");
|
export function listAgentModelsJsonPaths(
|
||||||
|
config: OpenClawConfig,
|
||||||
|
stateDir: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string[] {
|
||||||
|
const resolvedStateDir = resolveUserPath(stateDir);
|
||||||
|
const paths = new Set<string>();
|
||||||
|
paths.add(path.join(resolvedStateDir, "agents", "main", "agent", "models.json"));
|
||||||
|
paths.add(path.join(resolveActiveAgentDir(stateDir, env), "models.json"));
|
||||||
|
|
||||||
|
const agentsRoot = path.join(resolvedStateDir, "agents");
|
||||||
if (fs.existsSync(agentsRoot)) {
|
if (fs.existsSync(agentsRoot)) {
|
||||||
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory()) {
|
||||||
@@ -48,7 +62,7 @@ export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: strin
|
|||||||
|
|
||||||
for (const agentId of listAgentIds(config)) {
|
for (const agentId of listAgentIds(config)) {
|
||||||
if (agentId === "main") {
|
if (agentId === "main") {
|
||||||
paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json"));
|
paths.add(path.join(resolvedStateDir, "agents", "main", "agent", "models.json"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const agentDir = resolveAgentDir(config, agentId);
|
const agentDir = resolveAgentDir(config, agentId);
|
||||||
@@ -58,14 +72,51 @@ export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: strin
|
|||||||
return [...paths];
|
return [...paths];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReadJsonObjectOptions = {
|
||||||
|
maxBytes?: number;
|
||||||
|
requireRegularFile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function readJsonObjectIfExists(filePath: string): {
|
export function readJsonObjectIfExists(filePath: string): {
|
||||||
value: Record<string, unknown> | null;
|
value: Record<string, unknown> | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
};
|
||||||
|
export function readJsonObjectIfExists(
|
||||||
|
filePath: string,
|
||||||
|
options: ReadJsonObjectOptions,
|
||||||
|
): {
|
||||||
|
value: Record<string, unknown> | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
export function readJsonObjectIfExists(
|
||||||
|
filePath: string,
|
||||||
|
options: ReadJsonObjectOptions = {},
|
||||||
|
): {
|
||||||
|
value: Record<string, unknown> | null;
|
||||||
|
error?: string;
|
||||||
} {
|
} {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return { value: null };
|
return { value: null };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
if (options.requireRegularFile && !stats.isFile()) {
|
||||||
|
return {
|
||||||
|
value: null,
|
||||||
|
error: `Refusing to read non-regular file: ${filePath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof options.maxBytes === "number" &&
|
||||||
|
Number.isFinite(options.maxBytes) &&
|
||||||
|
options.maxBytes >= 0 &&
|
||||||
|
stats.size > options.maxBytes
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
value: null,
|
||||||
|
error: `Refusing to read oversized JSON (${stats.size} bytes): ${filePath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
const raw = fs.readFileSync(filePath, "utf8");
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user