Files
openclaw/src/agents/models-config.ts
Peter Steinberger 538605ff44 [codex] Extract filesystem safety primitives (#77918)
* refactor: extract filesystem safety primitives

* refactor: use fs-safe for file access helpers

* refactor: reuse fs-safe for media reads

* refactor: use fs-safe for image reads

* refactor: reuse fs-safe in qqbot media opener

* refactor: reuse fs-safe for local media checks

* refactor: consume cleaner fs-safe api

* refactor: align fs-safe json option names

* fix: preserve fs-safe migration contracts

* refactor: use fs-safe primitive subpaths

* refactor: use grouped fs-safe subpaths

* refactor: align fs-safe api usage

* refactor: adapt private state store api

* chore: refresh proof gate

* refactor: follow fs-safe json api split

* refactor: follow reduced fs-safe surface

* build: default fs-safe python helper off

* fix: preserve fs-safe plugin sdk aliases

* refactor: consolidate fs-safe usage

* refactor: unify fs-safe store usage

* refactor: trim fs-safe temp workspace usage

* refactor: hide low-level fs-safe primitives

* build: use published fs-safe package

* fix: preserve outbound recovery durability after rebase

* chore: refresh pr checks
2026-05-06 02:15:17 +01:00

293 lines
10 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import {
getRuntimeConfig,
getRuntimeConfigSourceSnapshot,
projectConfigOntoRuntimeSourceSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { createConfigRuntimeEnv } from "../config/env-vars.js";
import { privateFileStore } from "../infra/private-file-store.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { resolveInstalledManifestRegistryIndexFingerprint } from "../plugins/manifest-registry-installed.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentDir,
resolveDefaultAgentId,
} from "./agent-scope.js";
import { MODELS_JSON_STATE } from "./models-config-state.js";
import { planOpenClawModelsJson } from "./models-config.plan.js";
export { resetModelsJsonReadyCacheForTest } from "./models-config-state.js";
async function readFileMtimeMs(pathname: string): Promise<number | null> {
try {
const stat = await fs.stat(pathname);
return Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : null;
} catch {
return null;
}
}
function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>).toSorted(([a], [b]) =>
a.localeCompare(b),
);
return `{${entries
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`)
.join(",")}}`;
}
async function buildModelsJsonFingerprint(params: {
config: OpenClawConfig;
sourceConfigForSecrets: OpenClawConfig;
agentDir: string;
workspaceDir?: string;
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
}): Promise<string> {
const authProfilesMtimeMs = await readFileMtimeMs(
path.join(params.agentDir, "auth-profiles.json"),
);
const modelsFileMtimeMs = await readFileMtimeMs(path.join(params.agentDir, "models.json"));
const envShape = createConfigRuntimeEnv(params.config, {});
const pluginMetadataSnapshotIndexFingerprint = params.pluginMetadataSnapshot
? resolveInstalledManifestRegistryIndexFingerprint(params.pluginMetadataSnapshot.index)
: undefined;
return stableStringify({
config: params.config,
sourceConfigForSecrets: params.sourceConfigForSecrets,
envShape,
authProfilesMtimeMs,
modelsFileMtimeMs,
workspaceDir: params.workspaceDir,
pluginMetadataSnapshotIndexFingerprint,
providerDiscoveryProviderIds: params.providerDiscoveryProviderIds,
providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs,
providerDiscoveryEntriesOnly: params.providerDiscoveryEntriesOnly === true,
});
}
function modelsJsonReadyCacheKey(targetPath: string, fingerprint: string): string {
return `${targetPath}\0${fingerprint}`;
}
async function readExistingModelsFile(pathname: string): Promise<{
raw: string;
parsed: unknown;
}> {
try {
const raw = await privateFileStore(path.dirname(pathname)).readTextIfExists(
path.basename(pathname),
);
if (raw === null) {
return {
raw: "",
parsed: null,
};
}
return {
raw,
parsed: JSON.parse(raw) as unknown,
};
} catch {
return {
raw: "",
parsed: null,
};
}
}
export async function ensureModelsFileModeForModelsJson(pathname: string): Promise<void> {
await fs.chmod(pathname, 0o600).catch(() => {
// best-effort
});
}
export async function writeModelsFileAtomicForModelsJson(
targetPath: string,
contents: string,
): Promise<void> {
await privateFileStore(path.dirname(targetPath)).writeText(path.basename(targetPath), contents);
}
function resolveModelsConfigInput(config?: OpenClawConfig): {
config: OpenClawConfig;
sourceConfigForSecrets: OpenClawConfig;
} {
const runtimeSource = getRuntimeConfigSourceSnapshot();
if (!config) {
const loaded = getRuntimeConfig();
return {
config: runtimeSource ?? loaded,
sourceConfigForSecrets: runtimeSource ?? loaded,
};
}
if (!runtimeSource) {
return {
config,
sourceConfigForSecrets: config,
};
}
const projected = projectConfigOntoRuntimeSourceSnapshot(config);
return {
config: projected,
// If projection is skipped (for example incompatible top-level shape),
// keep managed secret persistence anchored to the active source snapshot.
sourceConfigForSecrets: projected === config ? runtimeSource : projected,
};
}
async function withModelsJsonWriteLock<T>(targetPath: string, run: () => Promise<T>): Promise<T> {
const prior = MODELS_JSON_STATE.writeLocks.get(targetPath) ?? Promise.resolve();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const pending = prior.then(() => gate);
MODELS_JSON_STATE.writeLocks.set(targetPath, pending);
try {
await prior;
return await run();
} finally {
release();
if (MODELS_JSON_STATE.writeLocks.get(targetPath) === pending) {
MODELS_JSON_STATE.writeLocks.delete(targetPath);
}
}
}
export async function ensureOpenClawModelsJson(
config?: OpenClawConfig,
agentDirOverride?: string,
options: {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
workspaceDir?: string;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
} = {},
): Promise<{ agentDir: string; wrote: boolean }> {
const resolved = resolveModelsConfigInput(config);
const cfg = resolved.config;
const workspaceDir =
options.workspaceDir ??
(agentDirOverride?.trim()
? undefined
: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
const pluginMetadataSnapshot =
options.pluginMetadataSnapshot ??
getCurrentPluginMetadataSnapshot({
config: cfg,
...(workspaceDir ? { workspaceDir } : {}),
});
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveDefaultAgentDir(cfg);
const targetPath = path.join(agentDir, "models.json");
const fingerprint = await buildModelsJsonFingerprint({
config: cfg,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
...(workspaceDir ? { workspaceDir } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
...(options.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: {}),
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint);
const cached = MODELS_JSON_STATE.readyCache.get(cacheKey);
if (cached) {
const settled = await cached;
await ensureModelsFileModeForModelsJson(targetPath);
return settled.result;
}
const pending = withModelsJsonWriteLock(targetPath, async () => {
// Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are
// are available to provider discovery without mutating process.env.
const env = createConfigRuntimeEnv(cfg);
const existingModelsFile = await readExistingModelsFile(targetPath);
const plan = await planOpenClawModelsJson({
cfg,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
env,
...(workspaceDir ? { workspaceDir } : {}),
existingRaw: existingModelsFile.raw,
existingParsed: existingModelsFile.parsed,
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
...(options.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: {}),
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
if (plan.action === "skip") {
return { fingerprint, result: { agentDir, wrote: false } };
}
if (plan.action === "noop") {
await ensureModelsFileModeForModelsJson(targetPath);
return { fingerprint, result: { agentDir, wrote: false } };
}
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
await writeModelsFileAtomicForModelsJson(targetPath, plan.contents);
await ensureModelsFileModeForModelsJson(targetPath);
return { fingerprint, result: { agentDir, wrote: true } };
});
MODELS_JSON_STATE.readyCache.set(cacheKey, pending);
try {
const settled = await pending;
const refreshedFingerprint = await buildModelsJsonFingerprint({
config: cfg,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
...(workspaceDir ? { workspaceDir } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
...(options.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: {}),
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint);
if (refreshedCacheKey !== cacheKey) {
MODELS_JSON_STATE.readyCache.delete(cacheKey);
MODELS_JSON_STATE.readyCache.set(
refreshedCacheKey,
Promise.resolve({ fingerprint: refreshedFingerprint, result: settled.result }),
);
}
return settled.result;
} catch (error) {
if (MODELS_JSON_STATE.readyCache.get(cacheKey) === pending) {
MODELS_JSON_STATE.readyCache.delete(cacheKey);
}
throw error;
}
}