From d8ee7d0b0427b13b59cff6c74f8a932ae200cf3f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 7 Jun 2026 22:51:45 -0700 Subject: [PATCH] fix(brain-cache): loadMeta tolerates malformed _meta.json without crashing (#1879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bin/gstack-brain-cache | 18 ++++++++++++++- test/brain-cache-roundtrip.test.ts | 35 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/bin/gstack-brain-cache b/bin/gstack-brain-cache index 8f313a519..f7694f33f 100755 --- a/bin/gstack-brain-cache +++ b/bin/gstack-brain-cache @@ -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: {} }; diff --git a/test/brain-cache-roundtrip.test.ts b/test/brain-cache-roundtrip.test.ts index d476f8b76..060ae26f9 100644 --- a/test/brain-cache-roundtrip.test.ts +++ b/test/brain-cache-roundtrip.test.ts @@ -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.