mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix: security hardening — search DoS, slug hijack, symlink traversal, content bombs, stdin guard 4 security vulnerabilities closed: - Search limit clamped to 100 (MAX_SEARCH_LIMIT) with statement_timeout 8s - Frontmatter slug authority enforced (path-derived, mismatch rejected) - Symlink traversal blocked (lstatSync in walker + importFromFile) - Content size guard on importFromContent (Buffer.byteLength, 5MB) - Stdin size guard in parseOpArgs (5MB cap) Search pagination added (--offset param on search + query operations). Clamp warning emitted when limit is capped. Co-Authored-By: garagon <garagon@users.noreply.github.com> * fix: PGLite concurrent access lock — prevent Aborted() crash File-based advisory lock using atomic mkdir with PID tracking and 5-minute stale detection. Clear error messages show which process holds the lock and how to recover. Co-Authored-By: danbr <danbr@users.noreply.github.com> * fix: 12 data integrity fixes + stale embedding prevention CTE searchKeyword rewrite (SQL-level LIMIT, not JS splice). Write validation on addLink/addTag/addTimelineEntry/putRawData/createVersion. Health metrics now measure real problems (stale_pages, orphan_pages, dead_links). Orphan chunk cleanup on empty pages. Embedding error logging. contentHash now covers all PageInput fields. Stale embedding NULL'd when chunk_text changes (prevents wrong vector on new text). hybridSearch stops double-embedding query. MCP param validation. type/exclude_slugs search filters now work. pgcrypto extension for Postgres <13. Co-Authored-By: win4r <win4r@users.noreply.github.com> * perf: 30x embedAll speedup + O(n²) fix + ask alias Sliding worker pool (concurrency 20, tunable via GBRAIN_EMBED_CONCURRENCY). O(n²) chunk lookup in embedPage replaced with Map. gbrain ask alias for query (CLI-only, not in MCP tools-json). .idea added to .gitignore. Co-Authored-By: stephenhungg <stephenhungg@users.noreply.github.com> Co-Authored-By: sharziki <sharziki@users.noreply.github.com> Co-Authored-By: hnshah <hnshah@users.noreply.github.com> Co-Authored-By: doguabaris <doguabaris@users.noreply.github.com> * chore: bump version and changelog (v0.9.1) Community fix wave: 10 PRs, 7 contributors. 4 security fixes, PGLite crash fix, 12 data integrity fixes, 30x embed speedup, search pagination, ask alias. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: garagon <garagon@users.noreply.github.com> Co-authored-by: danbr <danbr@users.noreply.github.com> Co-authored-by: win4r <win4r@users.noreply.github.com> Co-authored-by: stephenhungg <stephenhungg@users.noreply.github.com> Co-authored-by: sharziki <sharziki@users.noreply.github.com> Co-authored-by: hnshah <hnshah@users.noreply.github.com> Co-authored-by: doguabaris <doguabaris@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
|
|
// Mock the embedding module BEFORE importing runEmbed, so runEmbed picks up
|
|
// the mocked embedBatch. We track max concurrent invocations via a counter
|
|
// that increments on entry and decrements when the mock resolves.
|
|
let activeEmbedCalls = 0;
|
|
let maxConcurrentEmbedCalls = 0;
|
|
let totalEmbedCalls = 0;
|
|
|
|
mock.module('../src/core/embedding.ts', () => ({
|
|
embedBatch: async (texts: string[]) => {
|
|
activeEmbedCalls++;
|
|
totalEmbedCalls++;
|
|
if (activeEmbedCalls > maxConcurrentEmbedCalls) {
|
|
maxConcurrentEmbedCalls = activeEmbedCalls;
|
|
}
|
|
// Simulate API latency so concurrent workers actually overlap.
|
|
await new Promise(r => setTimeout(r, 30));
|
|
activeEmbedCalls--;
|
|
return texts.map(() => new Float32Array(1536));
|
|
},
|
|
}));
|
|
|
|
// Import AFTER mocking.
|
|
const { runEmbed } = await import('../src/commands/embed.ts');
|
|
|
|
// Proxy-based mock engine that matches test/import-file.test.ts pattern.
|
|
function mockEngine(overrides: Partial<Record<string, any>> = {}): BrainEngine {
|
|
const calls: { method: string; args: any[] }[] = [];
|
|
const track = (method: string) => (...args: any[]) => {
|
|
calls.push({ method, args });
|
|
if (overrides[method]) return overrides[method](...args);
|
|
return Promise.resolve(null);
|
|
};
|
|
const engine = new Proxy({} as any, {
|
|
get(_, prop: string) {
|
|
if (prop === '_calls') return calls;
|
|
if (overrides[prop]) return overrides[prop];
|
|
return track(prop);
|
|
},
|
|
});
|
|
return engine;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
activeEmbedCalls = 0;
|
|
maxConcurrentEmbedCalls = 0;
|
|
totalEmbedCalls = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.GBRAIN_EMBED_CONCURRENCY;
|
|
});
|
|
|
|
describe('runEmbed --all (parallel)', () => {
|
|
test('runs embedBatch calls concurrently across pages', async () => {
|
|
const NUM_PAGES = 20;
|
|
const pages = Array.from({ length: NUM_PAGES }, (_, i) => ({ slug: `page-${i}` }));
|
|
// Each page has one chunk without an embedding (stale).
|
|
const chunksBySlug = new Map(
|
|
pages.map(p => [
|
|
p.slug,
|
|
[{ chunk_index: 0, chunk_text: `text for ${p.slug}`, chunk_source: 'compiled_truth', embedded_at: null, token_count: 4 }],
|
|
]),
|
|
);
|
|
|
|
const engine = mockEngine({
|
|
listPages: async () => pages,
|
|
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
|
upsertChunks: async () => {},
|
|
});
|
|
|
|
process.env.GBRAIN_EMBED_CONCURRENCY = '10';
|
|
|
|
await runEmbed(engine, ['--all']);
|
|
|
|
expect(totalEmbedCalls).toBe(NUM_PAGES);
|
|
// Concurrency actually happened.
|
|
expect(maxConcurrentEmbedCalls).toBeGreaterThan(1);
|
|
// And stayed within the configured limit.
|
|
expect(maxConcurrentEmbedCalls).toBeLessThanOrEqual(10);
|
|
});
|
|
|
|
test('respects GBRAIN_EMBED_CONCURRENCY=1 (serial)', async () => {
|
|
const pages = Array.from({ length: 5 }, (_, i) => ({ slug: `page-${i}` }));
|
|
const chunksBySlug = new Map(
|
|
pages.map(p => [
|
|
p.slug,
|
|
[{ chunk_index: 0, chunk_text: `text ${p.slug}`, chunk_source: 'compiled_truth', embedded_at: null, token_count: 4 }],
|
|
]),
|
|
);
|
|
|
|
const engine = mockEngine({
|
|
listPages: async () => pages,
|
|
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
|
upsertChunks: async () => {},
|
|
});
|
|
|
|
process.env.GBRAIN_EMBED_CONCURRENCY = '1';
|
|
|
|
await runEmbed(engine, ['--all']);
|
|
|
|
expect(totalEmbedCalls).toBe(5);
|
|
expect(maxConcurrentEmbedCalls).toBe(1);
|
|
});
|
|
|
|
test('skips pages whose chunks are all already embedded when --stale', async () => {
|
|
const pages = [{ slug: 'fresh' }, { slug: 'stale' }];
|
|
const chunksBySlug = new Map<string, any[]>([
|
|
['fresh', [{ chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 1 }]],
|
|
['stale', [{ chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 }]],
|
|
]);
|
|
|
|
const engine = mockEngine({
|
|
listPages: async () => pages,
|
|
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
|
upsertChunks: async () => {},
|
|
});
|
|
|
|
process.env.GBRAIN_EMBED_CONCURRENCY = '5';
|
|
|
|
await runEmbed(engine, ['--stale']);
|
|
|
|
// Only the stale page triggers an embedBatch call.
|
|
expect(totalEmbedCalls).toBe(1);
|
|
});
|
|
});
|