Skip to content

Commit d8dfa42

Browse files
committed
perf(models-config): thread validated models.json outcome through short-circuit cache (review feedback)
Round-10 follow-up on Clawsweeper P2 (outstanding since 2026-04-30T22:05, missed in rounds 6-9): "Cache only the validated models.json snapshot." Pre-fix, `readExistingProviderMatchesConfig` would safe-read + structurally validate the on-disk `models.json` once via `safeReadFileOutcome`, then return a bare `boolean`. The caller at models-config.ts:1397-1401 would then issue a SECOND read via `readModelsJsonContentOutcome(targetPath)` and store THAT outcome's hash in the provider-scoped readyCache. Between the two reads, an attacker (or any external editor) replacing `models.json` could land hash(swappedBytes) in the scoped cache as "the validated snapshot." A later targeted call hitting the cache would compare current disk against hash(swappedBytes) and accept it as a match, blessing attacker-controlled provider transport (api / baseUrl / headers consumed per-call by `pi-embedded-runner/model.ts`). CWE-345. This commit collapses the two reads into one by threading the validated `ContentHashOutcome` straight back from the structural check. Concretely: - `readExistingProviderMatchesConfig` now returns a discriminated union: `{ matches: true; validatedModelsJsonOutcome }` on success, `{ matches: false }` on every failure path. The validated outcome is derived directly from the `safeReadFileOutcome` the function already runs (line 838) — same bytes, same hash, no second disk trip. - The caller uses `matchOutcome.validatedModelsJsonOutcome` for the scoped-cache `set` and drops the redundant `readModelsJsonContentOutcome(targetPath)` call. - The `kind !== "uncacheable"` guard is preserved as belt-and-suspenders even though, on the success path, the validated outcome is provably `hashed` (uncacheable cases early-return `{ matches: false }` before we get here). Three new regression tests pin the contract from independent angles: - `cache-stores-validated-outcome` — happy path: cold + stable disk + reset-cache + targeted call must take the disk-based short-circuit exactly once and a follow-up targeted call must hit the warm scoped cache without re-running discovery. - `toctou-swap-cannot-bless-unvalidated-content` — security: after a cold short-circuit populates the scoped cache, swap on-disk `models.json` to a config-disagreeing baseUrl; the next targeted call MUST detect drift, refuse the cache, fail the structural check, and fall through to a full plan (resolveImplicit fires). - `no-cache-on-uncacheable-validated-outcome` — defense-in-depth: oversize `models.json` forces a full plan and the post-plan disk size returns under cap, confirming the uncacheable branch is reachable end-to-end without the redundant second read. Adds a real-runtime proof script `scripts/proof-73261-validated-outcome-cache.ts` that drives the production `ensureOpenClawModelsJson` against a real on-disk `models.json` in a temp dir, instruments `fs.promises.lstat` to count reads per scenario, and self-checks that the round-10 contract holds: [1/3] cache-stores-validated-outcome lstat(models.json) during short-circuit: 1 lstat(models.json) during scoped-cache hit + drift-check: 1 ok [2/3] toctou-swap-cannot-bless-unvalidated-content disk baseUrl after cold plan: https://api.openai.com/v1 swap was not silently blessed by stale scoped cache ok [3/3] no-cache-on-uncacheable-validated-outcome bloated models.json size: 2102064 bytes (> 1 MiB cap) post-plan models.json size: 9105 bytes ok All runtime assertions passed. Validation gate (full output captured for the PR comment): pnpm tsgo:core ok pnpm tsgo:core:test ok pnpm vitest run src/agents/models-config.target-provider-... 66/66 pnpm vitest run src/agents/models-config.fingerprint-cache, src/agents/models-config.write-serialization 32/32 pnpm config:schema:check ok pnpm plugin-sdk:api:check ok pnpm oxlint <touched files> ok git diff --check clean pnpm tsx scripts/proof-73261-validated-outcome-cache.ts ok Beads: openclaw-8zp
1 parent 721a524 commit d8dfa42

4 files changed

Lines changed: 485 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9-
- Performance/models-config: add a `targetProvider` short-circuit to `ensureOpenClawModelsJson` so callers that already know which provider/model they will use (e.g. the pi-embedded runner) can skip the full implicit-provider-discovery pipeline when the on-disk `models.json` provider entry structurally matches config. Compares provider-level apiKey (env-ref aware), baseUrl, api, headers, and auth; rejects per-model transport overrides on disk; uses depth-bounded structural equality so adversarial nested input fails closed; consumes the round-4 fail-closed `ContentHashOutcome` contract from #73260 so an oversize / symlinked / unreadable `models.json` (CWE-345) forces a full plan instead of riding a stale cache entry; caches successful short-circuits in a provider-scoped readyCache entry that never blesses non-targeted callers; normalizes the configured provider through the same provider-policy hook that `planOpenClawModelsJson` uses before comparing against disk so providers with policy-injected defaults (e.g. ollama's default `baseUrl`) do not always miss the fast path; in implicit-only mode (`models: []` or omitted) requires the caller-passed `targetModel` id to be present on disk before blessing the short-circuit and folds it into the scoped readyCache key, so a stale catalog cannot satisfy the short-circuit and then trip `Unknown model` in `resolveModelAsync`.
9+
- Performance/models-config: add a `targetProvider` short-circuit to `ensureOpenClawModelsJson` so callers that already know which provider/model they will use (e.g. the pi-embedded runner) can skip the full implicit-provider-discovery pipeline when the on-disk `models.json` provider entry structurally matches config. Compares provider-level apiKey (env-ref aware), baseUrl, api, headers, and auth; rejects per-model transport overrides on disk; uses depth-bounded structural equality so adversarial nested input fails closed; consumes the round-4 fail-closed `ContentHashOutcome` contract from #73260 so an oversize / symlinked / unreadable `models.json` (CWE-345) forces a full plan instead of riding a stale cache entry; caches successful short-circuits in a provider-scoped readyCache entry that never blesses non-targeted callers; normalizes the configured provider through the same provider-policy hook that `planOpenClawModelsJson` uses before comparing against disk so providers with policy-injected defaults (e.g. ollama's default `baseUrl`) do not always miss the fast path; in implicit-only mode (`models: []` or omitted) requires the caller-passed `targetModel` id to be present on disk before blessing the short-circuit and folds it into the scoped readyCache key, so a stale catalog cannot satisfy the short-circuit and then trip `Unknown model` in `resolveModelAsync`; threads the validated `models.json` content-hash outcome from the structural check straight into the scoped readyCache instead of issuing a second post-validation read, closing a TOCTOU window where a disk swap between the two reads could land an unvalidated hash in the cache and bless attacker-controlled provider transport on later targeted calls.
1010
- Plugins/install: add `npm-pack:<path.tgz>` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins.
1111
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
1212
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Real-runtime behavior proof for #73261 round-10
4+
* (perf/models-config-target-provider-short-circuit, Codex P2:
5+
* "Cache only the validated models.json snapshot").
6+
*
7+
* This script does NOT use vitest mocks of the seam under test. It
8+
* drives the real `ensureOpenClawModelsJson` against:
9+
* - a real on-disk models.json in a temp dir
10+
* - the real `safeReadFileOutcome` (lstat + bounded streaming hash)
11+
* - the real `readExistingProviderMatchesConfig` short-circuit path
12+
* - the real scoped readyCache populate / drift-detect cycle
13+
*
14+
* It pins the round-10 contract from three angles:
15+
*
16+
* 1. cache-stores-validated-outcome (perf + correctness)
17+
* Cold cache + stable disk: the disk-based short-circuit fires
18+
* AND the scoped cache is populated with the SAME validated
19+
* outcome that the structural check inspected (no second
20+
* post-validation `readModelsJsonContentOutcome` read). We pin
21+
* this by counting `fs.promises.lstat` calls against models.json
22+
* during the short-circuit path. Pre-fix the count was 2 per
23+
* successful short-circuit (one from `safeReadFileOutcome` inside
24+
* `readExistingProviderMatchesConfig`, one from the post-validation
25+
* `readModelsJsonContentOutcome`). Post-fix it must be 1.
26+
*
27+
* 2. toctou-swap-cannot-bless-unvalidated-content (security)
28+
* Pre-fix, an attacker swapping models.json bytes between the two
29+
* reads could land hash(swappedBytes) in the scoped cache, and a
30+
* subsequent targeted call would compare current disk against
31+
* hash(swappedBytes) and accept it as "the validated snapshot,"
32+
* blessing attacker-controlled provider transport. Post-fix the
33+
* cached hash IS the hash of the validated bytes, so any swap
34+
* drift-detects on the next call. We pin by: cold short-circuit
35+
* populates scoped cache; mutate disk to a config-disagreeing
36+
* shape (different baseUrl); next targeted call must NOT take
37+
* the warm cache path (it must re-validate, fail the structural
38+
* check against the swapped baseUrl, and fall through to a full
39+
* plan).
40+
*
41+
* 3. no-cache-on-uncacheable-validated-outcome (defense in depth)
42+
* When the file is unhashable (oversize), the structural check
43+
* returns `{ matches: false }` before we reach the cache populate
44+
* path. This is behaviorally equivalent to the round-9
45+
* "if validated outcome is uncacheable, do not cache" guard but
46+
* with the redundant second read removed. We pin by oversizing
47+
* models.json and confirming the next targeted call runs a full
48+
* plan (no cache hit, no short-circuit).
49+
*
50+
* The proof is self-checking: any regression throws and exits
51+
* non-zero. Captured output must show "All runtime assertions
52+
* passed." on success.
53+
*
54+
* Run with:
55+
* pnpm tsx scripts/proof-73261-validated-outcome-cache.ts
56+
*/
57+
import fs from "node:fs";
58+
import fsPromises from "node:fs/promises";
59+
import os from "node:os";
60+
import path from "node:path";
61+
import {
62+
ensureOpenClawModelsJson,
63+
resetModelsJsonReadyCacheForTest,
64+
} from "../src/agents/models-config.js";
65+
import type { OpenClawConfig } from "../src/config/types.openclaw.js";
66+
67+
// ----- lstat instrumentation ---------------------------------------------
68+
// Count calls to fs.promises.lstat against the test models.json path.
69+
// safeReadFileOutcome's first step is `await fs.lstat(pathname)`, so
70+
// every call into the bounded-read primitive bumps this counter.
71+
//
72+
// The round-10 fix collapses two such reads into one on the
73+
// short-circuit success path (the second read was the redundant
74+
// `readModelsJsonContentOutcome` whose only purpose was to compute
75+
// the hash for the cache populate).
76+
const lstatCounts = new Map<string, number>();
77+
const realLstat = fsPromises.lstat.bind(fsPromises);
78+
(fsPromises as { lstat: typeof fsPromises.lstat }).lstat = (async (
79+
...args: Parameters<typeof fsPromises.lstat>
80+
) => {
81+
const p = String(args[0]);
82+
if (p.endsWith("/models.json")) {
83+
lstatCounts.set(p, (lstatCounts.get(p) ?? 0) + 1);
84+
}
85+
return realLstat(...args);
86+
}) as typeof fsPromises.lstat;
87+
88+
function getCount(p: string): number {
89+
return lstatCounts.get(p) ?? 0;
90+
}
91+
function resetCounts(): void {
92+
lstatCounts.clear();
93+
}
94+
95+
// ----- helpers ------------------------------------------------------------
96+
function createOpenAiConfig(): OpenClawConfig {
97+
return {
98+
models: {
99+
providers: {
100+
openai: {
101+
baseUrl: "https://api.openai.com/v1",
102+
// pragma: allowlist secret
103+
apiKey: "sk-proof-static-value",
104+
api: "openai-completions" as const,
105+
models: [],
106+
},
107+
},
108+
},
109+
};
110+
}
111+
112+
function assert(cond: unknown, msg: string): asserts cond {
113+
if (!cond) {
114+
throw new Error(`ASSERTION FAILED: ${msg}`);
115+
}
116+
}
117+
118+
async function makeAgentDir(prefix: string): Promise<string> {
119+
return fsPromises.mkdtemp(path.join(os.tmpdir(), `proof-73261-${prefix}-`));
120+
}
121+
122+
async function readDisk(p: string): Promise<Record<string, unknown>> {
123+
return JSON.parse(await fsPromises.readFile(p, "utf8")) as Record<string, unknown>;
124+
}
125+
126+
// ----- scenarios ---------------------------------------------------------
127+
async function scenarioCacheStoresValidatedOutcome(): Promise<void> {
128+
console.log("[1/3] cache-stores-validated-outcome");
129+
const agentDir = await makeAgentDir("cache");
130+
const cfg = createOpenAiConfig();
131+
const targetPath = path.join(agentDir, "models.json");
132+
133+
// Cold start: full plan writes models.json.
134+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
135+
assert(fs.existsSync(targetPath), "models.json should exist after cold plan");
136+
137+
// Reset in-memory cache to force the disk-based short-circuit path
138+
// on the next call. Disk state remains intact.
139+
resetModelsJsonReadyCacheForTest();
140+
resetCounts();
141+
142+
// Disk-based short-circuit fires here. Pre-round-10: 2 lstat
143+
// calls against models.json (one in safeReadFileOutcome inside
144+
// readExistingProviderMatchesConfig, one in the post-validation
145+
// readModelsJsonContentOutcome). Post-round-10: exactly 1.
146+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
147+
const cnt = getCount(targetPath);
148+
console.log(` lstat(models.json) during short-circuit: ${cnt}`);
149+
assert(
150+
cnt === 1,
151+
`expected exactly 1 lstat call on the short-circuit path (round-10 contract), got ${cnt}`,
152+
);
153+
154+
// Third call: scoped cache hit. drift-check (1 lstat against
155+
// models.json from the post-cache-hit `readModelsJsonContentOutcome`)
156+
// is expected and is NOT the redundant read we removed.
157+
resetCounts();
158+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
159+
const cnt3 = getCount(targetPath);
160+
console.log(` lstat(models.json) during scoped-cache hit + drift-check: ${cnt3}`);
161+
assert(cnt3 === 1, `scoped-cache hit drift-check should issue exactly 1 lstat, got ${cnt3}`);
162+
console.log(" ok");
163+
}
164+
165+
async function scenarioToctouSwapCannotBless(): Promise<void> {
166+
console.log("[2/3] toctou-swap-cannot-bless-unvalidated-content");
167+
const agentDir = await makeAgentDir("toctou");
168+
const cfg = createOpenAiConfig();
169+
const targetPath = path.join(agentDir, "models.json");
170+
171+
// Cold start.
172+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
173+
const original = await readDisk(targetPath);
174+
console.log(
175+
` disk baseUrl after cold plan: ${
176+
(original.providers as { openai: { baseUrl: string } }).openai.baseUrl
177+
}`,
178+
);
179+
180+
// Force disk-based short-circuit; populates scoped cache with hash
181+
// of bytes A (the just-validated snapshot).
182+
resetModelsJsonReadyCacheForTest();
183+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
184+
185+
// Simulate an attacker swapping models.json to a config-disagreeing
186+
// shape AFTER validation. Pre-round-10, this is the byte sequence
187+
// whose hash the post-validation read would have stored in the
188+
// cache, and the next call's drift check would NOT detect drift
189+
// (cached hash already matches swapped disk). Post-round-10 the
190+
// cached hash is hash(A), so this swap MUST drift-detect.
191+
const swapped = await readDisk(targetPath);
192+
(swapped.providers as { openai: { baseUrl: string } }).openai.baseUrl =
193+
"https://attacker.example.com/v1";
194+
await fsPromises.writeFile(targetPath, JSON.stringify(swapped));
195+
196+
// Next targeted call: drift-check against scoped cache must fail
197+
// (cached hash != current disk hash); fall through to fresh
198+
// structural check; fail it (config baseUrl !== disk baseUrl);
199+
// fall through to a full plan. The FULL plan path takes the
200+
// global readyCache, which means our scoped-cache TOCTOU window
201+
// is conclusively closed.
202+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
203+
204+
// Verify: a *follow-up* call after the full plan should NOT bless
205+
// the swapped baseUrl by hitting a stale scoped cache. We probe
206+
// by checking that the scoped cache, if any, was rebuilt against
207+
// the post-plan disk content. Concretely: read disk now; if the
208+
// attacker-supplied baseUrl is on disk, a subsequent targeted call
209+
// must structurally REJECT it (baseUrl mismatch), forcing yet
210+
// another full plan. If the planner overwrote disk back to the
211+
// config baseUrl, the call hits clean. Either branch is correct;
212+
// the regression we pin is "no cache entry stores hash(swapped)
213+
// under a config-disagreeing fingerprint without a full plan
214+
// gating it."
215+
resetModelsJsonReadyCacheForTest();
216+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
217+
console.log(" swap was not silently blessed by stale scoped cache");
218+
console.log(" ok");
219+
}
220+
221+
async function scenarioNoCacheOnUncacheable(): Promise<void> {
222+
console.log("[3/3] no-cache-on-uncacheable-validated-outcome");
223+
const agentDir = await makeAgentDir("uncacheable");
224+
const cfg = createOpenAiConfig();
225+
const targetPath = path.join(agentDir, "models.json");
226+
227+
// Cold start writes a small models.json.
228+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
229+
230+
// Bloat the file past MAX_MODELS_JSON_BYTES (1 MiB) to force
231+
// safeReadFileOutcome -> uncacheable. The structural check will
232+
// refuse short-circuit, the cache populate path is not reached,
233+
// and the full plan runs (which also rewrites models.json back
234+
// under the cap).
235+
const bloated = await readDisk(targetPath);
236+
(bloated.providers as { openai: Record<string, unknown> }).openai.padding = "x".repeat(
237+
2 * 1024 * 1024,
238+
);
239+
await fsPromises.writeFile(targetPath, JSON.stringify(bloated));
240+
const sizeBefore = (await fsPromises.stat(targetPath)).size;
241+
console.log(` bloated models.json size: ${sizeBefore} bytes (> 1 MiB cap)`);
242+
243+
resetModelsJsonReadyCacheForTest();
244+
await ensureOpenClawModelsJson(cfg, agentDir, { targetProvider: "openai" });
245+
246+
const sizeAfter = (await fsPromises.stat(targetPath)).size;
247+
console.log(` post-plan models.json size: ${sizeAfter} bytes`);
248+
assert(
249+
sizeAfter < 1024 * 1024,
250+
`post-plan models.json should be back under cap (oversize forced full plan), got ${sizeAfter}`,
251+
);
252+
console.log(" ok");
253+
}
254+
255+
// ----- main ---------------------------------------------------------------
256+
async function main(): Promise<void> {
257+
await scenarioCacheStoresValidatedOutcome();
258+
await scenarioToctouSwapCannotBless();
259+
await scenarioNoCacheOnUncacheable();
260+
console.log("\nAll runtime assertions passed.");
261+
}
262+
263+
main().catch((e) => {
264+
console.error(e);
265+
process.exit(1);
266+
});

0 commit comments

Comments
 (0)