From 32c541a9783a2089620938d078a9d099fb3d526b Mon Sep 17 00:00:00 2001 From: Cuong Date: Tue, 14 Apr 2026 17:07:35 +0700 Subject: [PATCH 1/2] fix(swarm): resolve all blocking and medium PR review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking (5 fixes): - #1: Bound resolveUniqueFilename loop to MAX_SUFFIX=10000 with timestamp fallback (local-markdown-adapter) - #2: Make GBrain bin resolution lazy — execFileSync no longer runs in constructor, deferred to first executor use - #3: Replace execFileSync with async execFileAsync in config-validator (validateGBrain is now async) - #4: Add MAX_ARG_LENGTH guard (200KB) in execBrvCurate to prevent E2BIG on large content - #5: Bound memory-wiki store() filename loop with same pattern Medium (6 fixes): - #6: Remove unreachable 'creative' from QueryType union and SELECTION_MATRIX - #8: Replace this.index! non-null assertions with explicit null guards in obsidian and local-markdown adapters - #9: Fix byterover writeSupported: true → false in createDefaultCapabilities (matches adapter) - #10: Replace 'as' type assertion with instanceof narrowing in execBrvCurate error handler - #11: Replace 'as Error' assertions with instanceof checks in swarm curate and query CLI commands - Stale JSDoc: remove 'creative' from router comment Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/core/domain/swarm/types.ts | 6 +++--- src/agent/infra/swarm/adapters/gbrain-adapter.ts | 13 +++++++++++-- .../swarm/adapters/local-markdown-adapter.ts | 9 +++++++-- .../infra/swarm/adapters/memory-wiki-adapter.ts | 8 +++++++- .../infra/swarm/adapters/obsidian-adapter.ts | 4 +++- src/agent/infra/swarm/swarm-coordinator.ts | 14 ++++++++++++-- src/agent/infra/swarm/swarm-router.ts | 3 +-- .../infra/swarm/validation/config-validator.ts | 15 +++++++++------ src/oclif/commands/swarm/curate.ts | 5 +++-- src/oclif/commands/swarm/query.ts | 5 +++-- test/unit/agent/core/domain/swarm/types.test.ts | 2 +- 11 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/agent/core/domain/swarm/types.ts b/src/agent/core/domain/swarm/types.ts index 37adaa691..986e0395f 100644 --- a/src/agent/core/domain/swarm/types.ts +++ b/src/agent/core/domain/swarm/types.ts @@ -19,7 +19,7 @@ export type ProviderType = (typeof PROVIDER_TYPES)[number] /** * Query type classification for routing decisions. */ -export type QueryType = 'creative' | 'factual' | 'personal' | 'relational' | 'temporal' +export type QueryType = 'factual' | 'personal' | 'relational' | 'temporal' /** * Local providers that require no network calls. @@ -105,7 +105,7 @@ export type QueryRequest = { after?: number before?: number } - /** Hint: factual, temporal, personal, creative, relational */ + /** Hint: factual, temporal, personal, relational */ type?: QueryType } @@ -172,7 +172,7 @@ export function createDefaultCapabilities(type: ProviderType): ProviderCapabilit semanticSearch: false, temporalQuery: false, userModeling: false, - writeSupported: true, + writeSupported: false, } } diff --git a/src/agent/infra/swarm/adapters/gbrain-adapter.ts b/src/agent/infra/swarm/adapters/gbrain-adapter.ts index 07e6e20d5..5f7c2463e 100644 --- a/src/agent/infra/swarm/adapters/gbrain-adapter.ts +++ b/src/agent/infra/swarm/adapters/gbrain-adapter.ts @@ -148,14 +148,23 @@ export class GBrainAdapter implements IMemoryProvider { } public readonly id = 'gbrain' public readonly type = 'gbrain' as const - private readonly executor: GBrainExecutor + private cachedExecutor?: GBrainExecutor + private readonly injectedExecutor?: GBrainExecutor + private readonly options: GBrainAdapterOptions private readonly repoPath: string private readonly searchMode: 'hybrid' | 'keyword' | 'vector' constructor(options: GBrainAdapterOptions, executor?: GBrainExecutor) { this.repoPath = options.repoPath this.searchMode = options.searchMode - this.executor = executor ?? createDefaultExecutor(resolveGBrainBin(options)) + this.options = options + this.injectedExecutor = executor + } + + private get executor(): GBrainExecutor { + if (this.injectedExecutor) return this.injectedExecutor + this.cachedExecutor ??= createDefaultExecutor(resolveGBrainBin(this.options)) + return this.cachedExecutor } public async delete(id: string): Promise { diff --git a/src/agent/infra/swarm/adapters/local-markdown-adapter.ts b/src/agent/infra/swarm/adapters/local-markdown-adapter.ts index 3db288ad2..1ff4d40bb 100644 --- a/src/agent/infra/swarm/adapters/local-markdown-adapter.ts +++ b/src/agent/infra/swarm/adapters/local-markdown-adapter.ts @@ -94,8 +94,9 @@ function buildIndexSignature(files: ScannedMarkdownFile[]): string { function resolveUniqueFilename(folderPath: string, preferredFilename: string): string { const baseName = preferredFilename.replace(/\.md$/u, '') + const MAX_SUFFIX = 10_000 let suffix = 0 - while (true) { + while (suffix <= MAX_SUFFIX) { const candidate = suffix === 0 ? `${baseName}.md` : `${baseName}-${suffix}.md` if (!existsSync(join(folderPath, candidate))) { return candidate @@ -103,6 +104,8 @@ function resolveUniqueFilename(folderPath: string, preferredFilename: string): s suffix++ } + + return `${baseName}-${Date.now()}.md` } type IndexedDoc = { @@ -186,8 +189,10 @@ export class LocalMarkdownAdapter implements IMemoryProvider { const maxResults = request.maxResults ?? 10 + if (!this.index) return [] + // T1/T2/T3: Precision-filtered search (stop words, AND-first, score floor, gap ratio) - const precisionResults = searchWithPrecision(this.index!, request.query, {maxResults}) + const precisionResults = searchWithPrecision(this.index, request.query, {maxResults}) if (precisionResults.length === 0) return [] // Collect direct matches diff --git a/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts b/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts index 5c50214ca..c1e6d6ce3 100644 --- a/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts +++ b/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts @@ -204,15 +204,21 @@ export class MemoryWikiAdapter implements IMemoryProvider { const now = new Date().toISOString() // Resolve unique filename + const MAX_SUFFIX = 10_000 let filename = `${slug}.md` let filePath = join(dirPath, filename) let suffix = 1 - while (existsSync(filePath)) { + while (existsSync(filePath) && suffix <= MAX_SUFFIX) { filename = `${slug}-${suffix}.md` filePath = join(dirPath, filename) suffix++ } + if (existsSync(filePath)) { + filename = `${slug}-${Date.now()}.md` + filePath = join(dirPath, filename) + } + const pageId = `${pageType}.swarm.${slug}` const pageContent = [ '---', diff --git a/src/agent/infra/swarm/adapters/obsidian-adapter.ts b/src/agent/infra/swarm/adapters/obsidian-adapter.ts index 89a3274e0..cf5cc4787 100644 --- a/src/agent/infra/swarm/adapters/obsidian-adapter.ts +++ b/src/agent/infra/swarm/adapters/obsidian-adapter.ts @@ -149,8 +149,10 @@ export class ObsidianAdapter implements IMemoryProvider { const maxResults = request.maxResults ?? 10 + if (!this.index) return [] + // T1/T2/T3: Precision-filtered search (stop words, AND-first, score floor, gap ratio) - const precisionResults = searchWithPrecision(this.index!, request.query, {maxResults}) + const precisionResults = searchWithPrecision(this.index, request.query, {maxResults}) if (precisionResults.length === 0) return [] // Collect direct matches diff --git a/src/agent/infra/swarm/swarm-coordinator.ts b/src/agent/infra/swarm/swarm-coordinator.ts index 27ad76f78..ebf9d0125 100644 --- a/src/agent/infra/swarm/swarm-coordinator.ts +++ b/src/agent/infra/swarm/swarm-coordinator.ts @@ -23,7 +23,13 @@ export type BrvCurateResult = {data?: {logId?: string; taskId?: string}; error?: const execFileAsync = promisify(execFileCb) +const MAX_ARG_LENGTH = 200_000 // ~200KB safe for most OS arg limits + async function execBrvCurate(content: string): Promise { + if (content.length > MAX_ARG_LENGTH) { + throw new Error(`Content too large for CLI argument (${content.length} bytes, max ${MAX_ARG_LENGTH}). Use brv curate directly.`) + } + let stdout: string try { ;({stdout} = await execFileAsync('brv', ['curate', '--detach', '--format', 'json', content], { @@ -31,8 +37,12 @@ async function execBrvCurate(content: string): Promise { timeout: 30_000, })) } catch (error) { - const err = error as {message: string; stderr?: string} - throw new Error(err.stderr?.trim() || err.message) + if (error instanceof Error) { + const stderr = (error as NodeJS.ErrnoException & {stderr?: string}).stderr?.trim() + throw new Error(stderr ?? error.message) + } + + throw new Error(String(error)) } try { diff --git a/src/agent/infra/swarm/swarm-router.ts b/src/agent/infra/swarm/swarm-router.ts index 7ea5b655b..ca100e037 100644 --- a/src/agent/infra/swarm/swarm-router.ts +++ b/src/agent/infra/swarm/swarm-router.ts @@ -27,10 +27,9 @@ export function classifyQuery(query: string): QueryType { /** * Provider selection matrix per query type. * Honcho and Hindsight are temporarily disabled — adapters coming in Phase 3. - * When re-enabled, add 'honcho' to personal/creative and 'hindsight' to temporal/relational/creative. + * When re-enabled, add 'honcho' to personal and 'hindsight' to temporal/relational. */ const SELECTION_MATRIX: Record = { - creative: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'], factual: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'], personal: ['byterover', 'obsidian', 'local-markdown'], relational: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'], diff --git a/src/agent/infra/swarm/validation/config-validator.ts b/src/agent/infra/swarm/validation/config-validator.ts index df472edd1..0f965c850 100644 --- a/src/agent/infra/swarm/validation/config-validator.ts +++ b/src/agent/infra/swarm/validation/config-validator.ts @@ -1,5 +1,8 @@ -import {execFileSync} from 'node:child_process' +import {execFile} from 'node:child_process' import {existsSync} from 'node:fs' +import {promisify} from 'node:util' + +const execFileAsync = promisify(execFile) import {join} from 'node:path' import type {SwarmConfig} from '../config/swarm-config-schema.js' @@ -151,10 +154,10 @@ function validateHindsight( /** * Validate gbrain provider config at runtime. */ -function validateGBrain( +async function validateGBrain( config: NonNullable, errors: ValidationIssue[] -): void { +): Promise { if (!existsSync(config.repoPath)) { errors.push({ field: 'repo_path', @@ -172,7 +175,7 @@ function validateGBrain( // Option A: gbrain globally installed try { - execFileSync('gbrain', ['--version'], {encoding: 'utf8', stdio: 'pipe', timeout: 5000}) + await execFileAsync('gbrain', ['--version'], {encoding: 'utf8', timeout: 5000}) gbrainReachable = true } catch { // Not in PATH @@ -189,7 +192,7 @@ function validateGBrain( if (scriptFound) { // Script exists — verify bun is available to run it try { - execFileSync('bun', ['--version'], {encoding: 'utf8', stdio: 'pipe', timeout: 5000}) + await execFileAsync('bun', ['--version'], {encoding: 'utf8', timeout: 5000}) gbrainReachable = true } catch { errors.push({ @@ -440,7 +443,7 @@ export async function validateSwarmProviders( } if (providers.gbrain?.enabled) { - validateGBrain(providers.gbrain, errors) + await validateGBrain(providers.gbrain, errors) } if (providers.memoryWiki?.enabled && !existsSync(providers.memoryWiki.vaultPath)) { diff --git a/src/oclif/commands/swarm/curate.ts b/src/oclif/commands/swarm/curate.ts index a8ba3ef4f..84720af11 100644 --- a/src/oclif/commands/swarm/curate.ts +++ b/src/oclif/commands/swarm/curate.ts @@ -73,10 +73,11 @@ public static description = 'Store knowledge in a swarm provider (GBrain, local this.exit(2) } } catch (error) { + const message = error instanceof Error ? error.message : String(error) if (isJson) { - this.log(JSON.stringify({error: (error as Error).message, success: false})) + this.log(JSON.stringify({error: message, success: false})) } else { - this.logToStderr(`Error: ${(error as Error).message}`) + this.logToStderr(`Error: ${message}`) this.exit(2) } } diff --git a/src/oclif/commands/swarm/query.ts b/src/oclif/commands/swarm/query.ts index bafbdf33b..e5add75b7 100644 --- a/src/oclif/commands/swarm/query.ts +++ b/src/oclif/commands/swarm/query.ts @@ -79,10 +79,11 @@ public static description = 'Query the memory swarm across all active providers' this.log(formatQueryResults(result, args.query)) } } catch (error) { + const message = error instanceof Error ? error.message : String(error) if (isJson) { - this.log(JSON.stringify({error: (error as Error).message, success: false})) + this.log(JSON.stringify({error: message, success: false})) } else { - this.logToStderr(`Error: ${(error as Error).message}`) + this.logToStderr(`Error: ${message}`) this.exit(2) } } diff --git a/test/unit/agent/core/domain/swarm/types.test.ts b/test/unit/agent/core/domain/swarm/types.test.ts index 25f4f7693..8254aa1f3 100644 --- a/test/unit/agent/core/domain/swarm/types.test.ts +++ b/test/unit/agent/core/domain/swarm/types.test.ts @@ -65,7 +65,7 @@ describe('Swarm Types', () => { expect(caps.graphTraversal).to.be.false expect(caps.temporalQuery).to.be.false expect(caps.userModeling).to.be.false - expect(caps.writeSupported).to.be.true + expect(caps.writeSupported).to.be.false expect(caps.localOnly).to.be.true expect(caps.avgLatencyMs).to.equal(50) }) From ce4a23b4449a2265b2290340d4b96c612ec8e6e5 Mon Sep 17 00:00:00 2001 From: Cuong Date: Tue, 14 Apr 2026 17:26:10 +0700 Subject: [PATCH 2/2] fix: harden memory-wiki YAML injection and add reviewer-requested comments Prevent YAML title injection by using JSON.stringify instead of raw interpolation. Add clarifying comments for null guard and sync probe. --- src/agent/infra/swarm/adapters/gbrain-adapter.ts | 2 +- src/agent/infra/swarm/adapters/memory-wiki-adapter.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent/infra/swarm/adapters/gbrain-adapter.ts b/src/agent/infra/swarm/adapters/gbrain-adapter.ts index 5f7c2463e..682b56254 100644 --- a/src/agent/infra/swarm/adapters/gbrain-adapter.ts +++ b/src/agent/infra/swarm/adapters/gbrain-adapter.ts @@ -57,7 +57,7 @@ export function resolveGBrainBin(options: GBrainAdapterOptions): {argsPrefix: st return {argsPrefix: [], command: options.gbrainBinPath} } - // 2. Check PATH + // 2. Check PATH (sync probe — runs lazily on first executor access, not at construction) try { execFileSync('gbrain', ['--version'], {encoding: 'utf8', stdio: 'pipe', timeout: 5000}) diff --git a/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts b/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts index c1e6d6ce3..1dd0b36b7 100644 --- a/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts +++ b/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts @@ -149,6 +149,7 @@ export class MemoryWikiAdapter implements IMemoryProvider { public async query(request: QueryRequest): Promise { this.ensureIndex() + // Safety net: ensureIndex() could leave this.index null if file reads fail if (!this.index) { return [] } @@ -224,7 +225,7 @@ export class MemoryWikiAdapter implements IMemoryProvider { '---', `pageType: ${pageType}`, `id: ${pageId}`, - `title: "${title}"`, + `title: ${JSON.stringify(title)}`, 'status: active', `updatedAt: "${now}"`, 'sourceType: swarm-curate',