mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 01:13:29 +00:00
Secrets: harden SecretRef-safe models.json persistence (#38955)
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
103
src/commands/models/load-config.test.ts
Normal file
103
src/commands/models/load-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user