fix(brain-cache): loadMeta tolerates malformed _meta.json without crashing (#1879)

loadMeta returned the parsed JSON verbatim. A valid JSON file that lacked the
last_refresh map made three consumers (isStale, cmdInvalidate, refreshEntity)
throw a TypeError dereferencing meta.last_refresh — the sibling last_attempt was
already guarded, last_refresh wasn't.

Fix in loadMeta:
- Shape-guard: JSON.parse can return null/array/string/number; non-object → fresh meta.
- Normalize ONLY the dereferenced maps (last_refresh, last_attempt).
- Deliberately do NOT default schema_version/endpoint_hash. Leaving them absent
  makes schemaVersionMismatch()/endpointSwitched() force a rebuild (missing
  identity = mismatch = safe); defaulting them would suppress cache invalidation
  and trust a stale file of unknown provenance.

Tests: missing last_refresh no longer throws; null/array/primitive treated as cold;
missing schema_version forces rebuild instead of a trusted warm hit.

Reported by @jbetala7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-07 22:51:45 -07:00
parent 30dc40aaa0
commit d8ee7d0b04
2 changed files with 52 additions and 1 deletions
+17 -1
View File
@@ -83,7 +83,23 @@ function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string |
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
}
try {
return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
// #1879: a valid JSON file can still be the wrong shape. JSON.parse can return
// null/array/string/number, and a partial object can omit last_refresh — three
// consumers (isStale, cmdInvalidate, refreshEntity) dereference meta.last_refresh
// unguarded and crash with a TypeError.
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
}
const meta = parsed as CacheMeta;
// Normalize ONLY the dereferenced maps. Do NOT default schema_version /
// endpoint_hash — leaving them absent makes schemaVersionMismatch() /
// endpointSwitched() correctly force a rebuild (missing identity = mismatch =
// safe). Defaulting them to current values would suppress invalidation and
// trust a stale file of unknown provenance.
meta.last_refresh = meta.last_refresh ?? {};
meta.last_attempt = meta.last_attempt ?? {};
return meta;
} catch {
// Corrupt _meta — start fresh (entries will refresh on next access).
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
+35
View File
@@ -86,6 +86,41 @@ describe('brain-cache meta lifecycle', () => {
});
});
describe('brain-cache malformed _meta.json (#1879)', () => {
function seedMeta(content: string): void {
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, '_meta.json'), content);
}
test('cmdInvalidate does not throw when last_refresh is missing', async () => {
const mod = await importCache();
// Valid JSON object, but no last_refresh map — the original crash.
seedMeta(JSON.stringify({ schema_version: '0.0.1', endpoint_hash: 'x' }));
expect(() => mod.cmdInvalidate('product', 'helsinki')).not.toThrow();
});
test('cmdGet does not throw on null / array / primitive _meta.json', async () => {
const mod = await importCache();
for (const bad of ['null', '[]', '"a string"', '42']) {
seedMeta(bad);
expect(() => mod.cmdGet('product', 'helsinki')).not.toThrow();
}
});
test('missing schema_version is treated as a mismatch (forces rebuild, not trust)', async () => {
const mod = await importCache();
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, 'product.md'), '# stale-no-schema\n');
// No schema_version field — must NOT be trusted as a warm hit.
seedMeta(JSON.stringify({ endpoint_hash: mod.detectEndpointHash(), last_refresh: { product: Date.now() } }));
const result = mod.cmdGet('product', 'helsinki');
// Brain unreachable in test → rebuild path runs; must not be a trusted warm hit.
expect(['missing', 'cold-refreshed', 'stale-fallback']).toContain(result.state);
});
});
describe('brain-cache endpoint detection', () => {
test('detectEndpointHash returns "local" when no ~/.claude.json gbrain MCP', async () => {
// We don't write ~/.claude.json in the temp env, so this falls through to local.