diff --git a/src/commands/files.ts b/src/commands/files.ts index a8990bfdd..d38bd5dcf 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -116,8 +116,7 @@ async function listFiles(engine: BrainEngine, slug?: string) { console.log(`${rows.length} file(s):`); for (const row of rows) { - const sizeBytes = row.size_bytes as number | null; - const size = sizeBytes ? `${Math.round(sizeBytes / 1024)}KB` : '?'; + const size = row.size_bytes ? `${Math.round(Number(row.size_bytes) / 1024)}KB` : '?'; console.log(` ${row.page_slug || '(unlinked)'} / ${row.filename} [${size}, ${row.mime_type || '?'}]`); } } diff --git a/src/core/operations.ts b/src/core/operations.ts index 41f376b6f..98e02d3c0 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -2686,10 +2686,16 @@ const file_list: Operation = { handler: async (_ctx, p) => { const sql = db.getConnection(); const slug = p.slug as string | undefined; - if (slug) { - return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename LIMIT ${FILE_LIST_LIMIT}`; - } - return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files ORDER BY page_slug, filename LIMIT ${FILE_LIST_LIMIT}`; + const rows = slug + ? await sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename LIMIT ${FILE_LIST_LIMIT}` + : await sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files ORDER BY page_slug, filename LIMIT ${FILE_LIST_LIMIT}`; + // Postgres returns size_bytes (BIGINT) as native BigInt — JSON.stringify + // throws on those, breaking MCP callers. PGLite returns Number already. + // 9 PB ceiling (2^53 bytes) is far above any plausible file size. + return rows.map((r: Record) => ({ + ...r, + size_bytes: r.size_bytes == null ? null : Number(r.size_bytes), + })); }, }; diff --git a/test/e2e/mechanical.test.ts b/test/e2e/mechanical.test.ts index 5428e1d27..8d6c52568 100644 --- a/test/e2e/mechanical.test.ts +++ b/test/e2e/mechanical.test.ts @@ -582,6 +582,11 @@ describeE2E('E2E: Files', () => { const files = await callOp('file_list', {}) as any[]; expect(files.length).toBe(1); + // Regression: Postgres BIGINT(size_bytes) returned native BigInt before + // v0.22.5 so the MCP serializer threw and CLI listFiles div-by-1024 threw. + expect(typeof files[0].size_bytes).toBe('number'); + expect(() => JSON.stringify(files)).not.toThrow(); + // Verify file_url returns URI format const url = await callOp('file_url', { storage_path: result.storage_path }) as any; expect(url.url).toContain('gbrain:files/'); diff --git a/test/files.test.ts b/test/files.test.ts index 596b45cba..8d8a7e58a 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -1,10 +1,12 @@ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { describe, test, expect, beforeAll, afterAll, spyOn } from 'bun:test'; import { writeFileSync, mkdirSync, rmSync, symlinkSync, mkdtempSync } from 'fs'; import { join, basename } from 'path'; import { createHash } from 'crypto'; import { extname } from 'path'; import { tmpdir } from 'os'; import { collectFiles } from '../src/commands/files.ts'; +import { operationsByName } from '../src/core/operations.ts'; +import * as db from '../src/core/db.ts'; const TMP = join(import.meta.dir, '.tmp-files-test'); @@ -183,6 +185,38 @@ describe('collectFiles (production import)', () => { } }); + test('file_list normalizes BigInt size_bytes for JSON serialization', async () => { + // Postgres BIGINT(size_bytes) returns native BigInt under postgres.js's + // {bigint: postgres.BigInt} type map. Both JSON.stringify (MCP) and the + // CLI's `size_bytes / 1024` divide trip on it. Regression for the bug + // openclaw's agent surfaced in v0.22.4. + const fakeRows = [ + { id: 1, page_slug: 'a', filename: 'f1', storage_path: 'a/f1', + mime_type: 'text/plain', size_bytes: 4096n, content_hash: 'h1', + created_at: '2026-04-27' }, + { id: 2, page_slug: 'a', filename: 'f2', storage_path: 'a/f2', + mime_type: null, size_bytes: null, content_hash: 'h2', + created_at: '2026-04-27' }, + ]; + const fakeSql: any = (..._: unknown[]) => Promise.resolve(fakeRows); + const spy = spyOn(db, 'getConnection').mockReturnValue(fakeSql); + + try { + const op = operationsByName['file_list']; + const ctx: any = { engine: null, config: {}, logger: { info() {}, warn() {}, error() {} }, dryRun: false, remote: true }; + const result = await op.handler(ctx, {}) as Array>; + + expect(result.length).toBe(2); + expect(typeof result[0].size_bytes).toBe('number'); + expect(result[0].size_bytes).toBe(4096); + expect(result[1].size_bytes).toBeNull(); + // The exact failure mode openclaw reported. + expect(() => JSON.stringify(result)).not.toThrow(); + } finally { + spy.mockRestore(); + } + }); + test('collectFiles skips node_modules', () => { const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-nodemod-')); try {