Secrets: harden SecretRef-safe models.json persistence (#38955)

This commit is contained in:
Josh Avant
2026-03-07 11:28:39 -06:00
committed by GitHub
parent b08337b902
commit 8e20dd22d8
66 changed files with 2713 additions and 299 deletions

View File

@@ -8,6 +8,7 @@ import { FailoverError } from "../agents/failover-error.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import * as modelSelectionModule from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js";
import type { OpenClawConfig } from "../config/config.js";
import * as configModule from "../config/config.js";
import * as sessionsModule from "../config/sessions.js";
@@ -51,6 +52,8 @@ const runtime: RuntimeEnv = {
};
const configSpy = vi.spyOn(configModule, "loadConfig");
const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite");
const setRuntimeConfigSnapshotSpy = vi.spyOn(configModule, "setRuntimeConfigSnapshot");
const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent");
const deliverAgentCommandResultSpy = vi.spyOn(agentDeliveryModule, "deliverAgentCommandResult");
@@ -256,13 +259,91 @@ function createTelegramOutboundPlugin() {
beforeEach(() => {
vi.clearAllMocks();
configModule.clearRuntimeConfigSnapshot();
runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never);
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
vi.mocked(loadModelCatalog).mockResolvedValue([]);
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
readConfigFileSnapshotForWriteSpy.mockResolvedValue({
snapshot: { valid: false, resolved: {} as OpenClawConfig },
writeOptions: {},
} as Awaited<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
});
describe("agentCommand", () => {
it("sets runtime snapshots from source config before embedded agent run", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
const loadedConfig = {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
models: { "anthropic/claude-opus-4-5": {} },
workspace: path.join(home, "openclaw"),
},
},
session: { store, mainKey: "main" },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
models: [],
},
},
},
} as unknown as OpenClawConfig;
const sourceConfig = {
...loadedConfig,
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
models: [],
},
},
},
} as unknown as OpenClawConfig;
const resolvedConfig = {
...loadedConfig,
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-resolved-runtime", // pragma: allowlist secret
models: [],
},
},
},
} as unknown as OpenClawConfig;
configSpy.mockReturnValue(loadedConfig);
readConfigFileSnapshotForWriteSpy.mockResolvedValue({
snapshot: { valid: true, resolved: sourceConfig },
writeOptions: {},
} as Awaited<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
const resolveSecretsSpy = vi
.spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway")
.mockResolvedValueOnce({
resolvedConfig,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
});
await agentCommand({ message: "hello", to: "+1555" }, runtime);
expect(resolveSecretsSpy).toHaveBeenCalledWith({
config: loadedConfig,
commandName: "agent",
targetIds: expect.any(Set),
});
expect(setRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig);
expect(vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig);
});
});
it("creates a session entry when deriving from --to", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");

View File

@@ -57,7 +57,11 @@ import { formatCliCommand } from "../cli/command-format.js";
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js";
import { type CliDeps, createDefaultDeps } from "../cli/deps.js";
import { loadConfig } from "../config/config.js";
import {
loadConfig,
readConfigFileSnapshotForWrite,
setRuntimeConfigSnapshot,
} from "../config/config.js";
import {
mergeSessionEntry,
parseSessionThreadInfo,
@@ -427,11 +431,23 @@ async function agentCommandInternal(
}
const loadedRaw = loadConfig();
const sourceConfig = await (async () => {
try {
const { snapshot } = await readConfigFileSnapshotForWrite();
if (snapshot.valid) {
return snapshot.resolved;
}
} catch {
// Fall back to runtime-loaded config when source snapshot is unavailable.
}
return loadedRaw;
})();
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "agent",
targetIds: getAgentRuntimeCommandSecretTargetIds(),
});
setRuntimeConfigSnapshot(cfg, sourceConfig);
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);
}

View File

@@ -5,6 +5,11 @@ let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegis
let toModelRow: typeof import("./models/list.registry.js").toModelRow;
const loadConfig = vi.fn();
const readConfigFileSnapshotForWrite = vi.fn().mockResolvedValue({
snapshot: { valid: false, resolved: {} },
writeOptions: {},
});
const setRuntimeConfigSnapshot = vi.fn();
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent");
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
@@ -29,6 +34,8 @@ vi.mock("../config/config.js", () => ({
CONFIG_PATH: "/tmp/openclaw.json",
STATE_DIR: "/tmp/openclaw-state",
loadConfig,
readConfigFileSnapshotForWrite,
setRuntimeConfigSnapshot,
}));
vi.mock("../agents/models-config.js", () => ({
@@ -84,8 +91,16 @@ vi.mock("../agents/pi-model-discovery.js", () => {
});
vi.mock("../agents/pi-embedded-runner/model.js", () => ({
resolveModel: () => {
throw new Error("resolveModel should not be called from models.list tests");
resolveModelWithRegistry: ({
provider,
modelId,
modelRegistry,
}: {
provider: string;
modelId: string;
modelRegistry: { find: (provider: string, id: string) => unknown };
}) => {
return modelRegistry.find(provider, modelId);
},
}));
@@ -114,6 +129,13 @@ beforeEach(() => {
modelRegistryState.getAllError = undefined;
modelRegistryState.getAvailableError = undefined;
listProfilesForProvider.mockReturnValue([]);
ensureOpenClawModelsJson.mockClear();
readConfigFileSnapshotForWrite.mockClear();
readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: { valid: false, resolved: {} },
writeOptions: {},
});
setRuntimeConfigSnapshot.mockClear();
});
afterEach(() => {
@@ -302,6 +324,35 @@ describe("models list/status", () => {
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
});
it("loadModelRegistry persists using source config snapshot when provided", async () => {
modelRegistryState.models = [OPENAI_MODEL];
modelRegistryState.available = [OPENAI_MODEL];
const sourceConfig = {
models: { providers: { openai: { apiKey: "$OPENAI_API_KEY" } } }, // pragma: allowlist secret
};
const resolvedConfig = {
models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret
};
await loadModelRegistry(resolvedConfig as never, { sourceConfig: sourceConfig as never });
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(sourceConfig);
});
it("loadModelRegistry uses resolved config when no source snapshot is provided", async () => {
modelRegistryState.models = [OPENAI_MODEL];
modelRegistryState.available = [OPENAI_MODEL];
const resolvedConfig = {
models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret
};
await loadModelRegistry(resolvedConfig as never);
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig);
});
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
const row = toModelRow({
model: makeGoogleAntigravityTemplate(

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js";
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
describe("resolveProviderAuthOverview", () => {
@@ -21,4 +22,52 @@ describe("resolveProviderAuthOverview", () => {
expect(overview.profiles.labels[0]).toContain("token:ref(env:GITHUB_TOKEN)");
});
it("renders marker-backed models.json auth as marker detail", () => {
const overview = resolveProviderAuthOverview({
provider: "openai",
cfg: {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
apiKey: NON_ENV_SECRETREF_MARKER,
models: [],
},
},
},
} as never,
store: { version: 1, profiles: {} } as never,
modelsPath: "/tmp/models.json",
});
expect(overview.effective.kind).toBe("models.json");
expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`);
expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`);
});
it("keeps env-var-shaped models.json values masked to avoid accidental plaintext exposure", () => {
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("models.json");
expect(overview.effective.detail).not.toContain("marker(");
expect(overview.effective.detail).not.toContain("OPENAI_API_KEY");
});
});

View File

@@ -6,12 +6,19 @@ import {
resolveAuthStorePathForDisplay,
resolveProfileUnusableUntilForDisplay,
} from "../../agents/auth-profiles.js";
import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import type { OpenClawConfig } from "../../config/config.js";
import { shortenHomePath } from "../../utils.js";
import { maskApiKey } from "./list.format.js";
import type { ProviderAuthOverview } from "./list.types.js";
function formatMarkerOrSecret(value: string): string {
return isNonSecretApiKeyMarker(value, { includeEnvVarName: false })
? `marker(${value.trim()})`
: maskApiKey(value);
}
function formatProfileSecretLabel(params: {
value: string | undefined;
ref: { source: string; id: string } | undefined;
@@ -19,7 +26,8 @@ function formatProfileSecretLabel(params: {
}): string {
const value = typeof params.value === "string" ? params.value.trim() : "";
if (value) {
return params.kind === "token" ? `token:${maskApiKey(value)}` : maskApiKey(value);
const display = formatMarkerOrSecret(value);
return params.kind === "token" ? `token:${display}` : display;
}
if (params.ref) {
const refLabel = `ref(${params.ref.source}:${params.ref.id})`;
@@ -108,7 +116,7 @@ export function resolveProviderAuthOverview(params: {
};
}
if (customKey) {
return { kind: "models.json", detail: maskApiKey(customKey) };
return { kind: "models.json", detail: formatMarkerOrSecret(customKey) };
}
return { kind: "missing", detail: "missing" };
})();
@@ -137,7 +145,7 @@ export function resolveProviderAuthOverview(params: {
...(customKey
? {
modelsJson: {
value: maskApiKey(customKey),
value: formatMarkerOrSecret(customKey),
source: `models.json: ${shortenHomePath(params.modelsPath)}`,
},
}

View File

@@ -2,11 +2,38 @@ import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const printModelTable = vi.fn();
const sourceConfig = {
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
models: {
providers: {
openai: {
apiKey: "$OPENAI_API_KEY", // pragma: allowlist secret
},
},
},
};
const resolvedConfig = {
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
models: {
providers: {
openai: {
apiKey: "sk-resolved-runtime-value", // pragma: allowlist secret
},
},
},
};
return {
loadConfig: vi.fn().mockReturnValue({
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
models: { providers: {} },
}),
sourceConfig,
resolvedConfig,
loadModelsConfigWithSource: vi.fn().mockResolvedValue({
sourceConfig,
resolvedConfig,
diagnostics: [],
}),
ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }),
loadModelRegistry: vi
.fn()
@@ -58,6 +85,10 @@ vi.mock("./list.registry.js", async (importOriginal) => {
};
});
vi.mock("./load-config.js", () => ({
loadModelsConfigWithSource: mocks.loadModelsConfigWithSource,
}));
vi.mock("./list.configured.js", () => ({
resolveConfiguredEntries: mocks.resolveConfiguredEntries,
}));
@@ -95,6 +126,16 @@ describe("modelsListCommand forward-compat", () => {
expect(codex?.tags).not.toContain("missing");
});
it("passes source config to model registry loading for persistence safety", async () => {
const runtime = { log: vi.fn(), error: vi.fn() };
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, {
sourceConfig: mocks.sourceConfig,
});
});
it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({
entries: [

View File

@@ -8,7 +8,7 @@ import { formatErrorWithStack } from "./list.errors.js";
import { loadModelRegistry, toModelRow } from "./list.registry.js";
import { printModelTable } from "./list.table.js";
import type { ModelRow } from "./list.types.js";
import { loadModelsConfig } from "./load-config.js";
import { loadModelsConfigWithSource } from "./load-config.js";
import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js";
export async function modelsListCommand(
@@ -23,7 +23,10 @@ export async function modelsListCommand(
) {
ensureFlagCompatibility(opts);
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
const cfg = await loadModelsConfig({ commandName: "models list", runtime });
const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({
commandName: "models list",
runtime,
});
const authStore = ensureAuthProfileStore();
const providerFilter = (() => {
const raw = opts.provider?.trim();
@@ -39,7 +42,7 @@ export async function modelsListCommand(
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
try {
const loaded = await loadModelRegistry(cfg);
const loaded = await loadModelRegistry(cfg, { sourceConfig });
modelRegistry = loaded.registry;
models = loaded.models;
availableKeys = loaded.availableKeys;

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import { OLLAMA_LOCAL_AUTH_MARKER } from "../../agents/model-auth-markers.js";
import type { OpenClawConfig } from "../../config/config.js";
let mockStore: AuthProfileStore;
@@ -138,4 +139,109 @@ describe("buildProbeTargets reason codes", () => {
expectLegacyMissingCredentialsError(plan.results[0], "unresolved_ref");
expect(plan.results[0]?.error).toContain("env:default:MISSING_ANTHROPIC_TOKEN");
});
it("skips marker-only models.json credentials when building probe targets", async () => {
const previousAnthropic = process.env.ANTHROPIC_API_KEY;
const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_OAUTH_TOKEN;
mockStore = {
version: 1,
profiles: {},
order: {},
};
try {
const plan = await buildProbeTargets({
cfg: {
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
api: "anthropic-messages",
apiKey: OLLAMA_LOCAL_AUTH_MARKER,
models: [],
},
},
},
} as OpenClawConfig,
providers: ["anthropic"],
modelCandidates: ["anthropic/claude-sonnet-4-6"],
options: {
timeoutMs: 5_000,
concurrency: 1,
maxTokens: 16,
},
});
expect(plan.targets).toEqual([]);
expect(plan.results).toEqual([]);
} finally {
if (previousAnthropic === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = previousAnthropic;
}
if (previousAnthropicOauth === undefined) {
delete process.env.ANTHROPIC_OAUTH_TOKEN;
} else {
process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth;
}
}
});
it("does not treat arbitrary all-caps models.json apiKey values as markers", async () => {
const previousAnthropic = process.env.ANTHROPIC_API_KEY;
const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_OAUTH_TOKEN;
mockStore = {
version: 1,
profiles: {},
order: {},
};
try {
const plan = await buildProbeTargets({
cfg: {
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
api: "anthropic-messages",
apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret
models: [],
},
},
},
} as OpenClawConfig,
providers: ["anthropic"],
modelCandidates: ["anthropic/claude-sonnet-4-6"],
options: {
timeoutMs: 5_000,
concurrency: 1,
maxTokens: 16,
},
});
expect(plan.results).toEqual([]);
expect(plan.targets).toHaveLength(1);
expect(plan.targets[0]).toEqual(
expect.objectContaining({
provider: "anthropic",
source: "models.json",
label: "models.json",
}),
);
} finally {
if (previousAnthropic === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = previousAnthropic;
}
if (previousAnthropicOauth === undefined) {
delete process.env.ANTHROPIC_OAUTH_TOKEN;
} else {
process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth;
}
}
});
});

View File

@@ -12,6 +12,7 @@ import {
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { describeFailoverError } from "../../agents/failover-error.js";
import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
@@ -373,7 +374,8 @@ export async function buildProbeTargets(params: {
const envKey = resolveEnvApiKey(providerKey);
const customKey = getCustomProviderApiKey(cfg, providerKey);
if (!envKey && !customKey) {
const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey));
if (!envKey && !hasUsableModelsJsonKey) {
continue;
}

View File

@@ -94,8 +94,13 @@ function loadAvailableModels(registry: ModelRegistry): Model<Api>[] {
}
}
export async function loadModelRegistry(cfg: OpenClawConfig) {
await ensureOpenClawModelsJson(cfg);
export async function loadModelRegistry(
cfg: OpenClawConfig,
opts?: { sourceConfig?: OpenClawConfig },
) {
// Persistence must be based on source config (pre-resolution) so SecretRef-managed
// credentials remain markers in models.json for command paths too.
await ensureOpenClawModelsJson(opts?.sourceConfig ?? cfg);
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const registry = discoverModels(authStorage, agentDir);

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
readConfigFileSnapshotForWrite: vi.fn(),
setRuntimeConfigSnapshot: vi.fn(),
resolveCommandSecretRefsViaGateway: vi.fn(),
getModelsCommandSecretTargetIds: vi.fn(),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: mocks.loadConfig,
readConfigFileSnapshotForWrite: mocks.readConfigFileSnapshotForWrite,
setRuntimeConfigSnapshot: mocks.setRuntimeConfigSnapshot,
}));
vi.mock("../../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
}));
vi.mock("../../cli/command-secret-targets.js", () => ({
getModelsCommandSecretTargetIds: mocks.getModelsCommandSecretTargetIds,
}));
import { loadModelsConfig, loadModelsConfigWithSource } from "./load-config.js";
describe("models load-config", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns source+resolved configs and sets runtime snapshot", async () => {
const sourceConfig = {
models: {
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
},
},
},
};
const runtimeConfig = {
models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret
};
const resolvedConfig = {
models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret
};
const targetIds = new Set(["models.providers.*.apiKey"]);
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
mocks.loadConfig.mockReturnValue(runtimeConfig);
mocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: { valid: true, resolved: sourceConfig },
writeOptions: {},
});
mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds);
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig,
diagnostics: ["diag-one", "diag-two"],
});
const result = await loadModelsConfigWithSource({ commandName: "models list", runtime });
expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith({
config: runtimeConfig,
commandName: "models list",
targetIds,
});
expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig);
expect(runtime.log).toHaveBeenNthCalledWith(1, "[secrets] diag-one");
expect(runtime.log).toHaveBeenNthCalledWith(2, "[secrets] diag-two");
expect(result).toEqual({
sourceConfig,
resolvedConfig,
diagnostics: ["diag-one", "diag-two"],
});
});
it("loadModelsConfig returns resolved config while preserving runtime snapshot behavior", async () => {
const sourceConfig = { models: { providers: {} } };
const runtimeConfig = {
models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret
};
const resolvedConfig = {
models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret
};
const targetIds = new Set(["models.providers.*.apiKey"]);
mocks.loadConfig.mockReturnValue(runtimeConfig);
mocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: { valid: true, resolved: sourceConfig },
writeOptions: {},
});
mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds);
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig,
diagnostics: [],
});
await expect(loadModelsConfig({ commandName: "models list" })).resolves.toBe(resolvedConfig);
expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig);
});
});

View File

@@ -1,15 +1,39 @@
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { loadConfig, type OpenClawConfig } from "../../config/config.js";
import {
loadConfig,
readConfigFileSnapshotForWrite,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
export async function loadModelsConfig(params: {
export type LoadedModelsConfig = {
sourceConfig: OpenClawConfig;
resolvedConfig: OpenClawConfig;
diagnostics: string[];
};
async function loadSourceConfigSnapshot(fallback: OpenClawConfig): Promise<OpenClawConfig> {
try {
const { snapshot } = await readConfigFileSnapshotForWrite();
if (snapshot.valid) {
return snapshot.resolved;
}
} catch {
// Fall back to runtime-loaded config if source snapshot cannot be read.
}
return fallback;
}
export async function loadModelsConfigWithSource(params: {
commandName: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
const loadedRaw = loadConfig();
}): Promise<LoadedModelsConfig> {
const runtimeConfig = loadConfig();
const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig);
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
config: runtimeConfig,
commandName: params.commandName,
targetIds: getModelsCommandSecretTargetIds(),
});
@@ -18,5 +42,17 @@ export async function loadModelsConfig(params: {
params.runtime.log(`[secrets] ${entry}`);
}
}
return resolvedConfig;
setRuntimeConfigSnapshot(resolvedConfig, sourceConfig);
return {
sourceConfig,
resolvedConfig,
diagnostics,
};
}
export async function loadModelsConfig(params: {
commandName: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
return (await loadModelsConfigWithSource(params)).resolvedConfig;
}