From 6c67c52f95a7e8c96d59d73adf7f9d30e725d314 Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Mon, 3 Aug 2026 18:32:55 -0600 Subject: [PATCH 1/2] fix(knowledge-base): expand ~ in requested paths prepareKnowledgeBase() called resolve() on each requested path, so a literal ~/docs resolved against the current working directory and the scan failed with ENOENT on /~/docs before any work started. It is reachable from a quoted argument and from anything that does not go through shell word expansion at all: a config file, a CI variable, a Makefile. Expand where the path is resolved, reusing the exported expandHome from src/runtime.ts rather than adding another copy. That is where the sibling path options already expand -- --plugin-path in resolvePluginPath, --python in usablePython, --output-dir in validateOutputDir -- and one site covers every producer: scan --knowledge-base, bulk-scan --knowledge-base, and SDK callers passing knowledgeBasePaths. Replayed scan recipes store realpath values, and expandHome returns an absolute path unchanged, so the recipe path is unaffected. --- sdk/typescript/src/knowledge-base.ts | 3 ++- .../tests-ts/knowledge-base.test.ts | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/knowledge-base.ts b/sdk/typescript/src/knowledge-base.ts index ad82a3c5..5ef6b19f 100644 --- a/sdk/typescript/src/knowledge-base.ts +++ b/sdk/typescript/src/knowledge-base.ts @@ -11,6 +11,7 @@ import { import { tmpdir } from "node:os"; import { basename, extname, join, resolve } from "node:path"; import { unzipSync } from "fflate"; +import { expandHome } from "./runtime.js"; const SUPPORTED_EXTENSIONS = new Set([ ".md", @@ -37,7 +38,7 @@ export async function prepareKnowledgeBase( signal?.throwIfAborted(); if (!requested.trim()) throw new Error("Knowledge base paths cannot be empty."); - const path = resolve(requested); + const path = resolve(expandHome(requested)); const metadata = await lstat(path); if (metadata.isSymbolicLink()) { throw new Error(`Knowledge base paths cannot be symbolic links: ${path}`); diff --git a/sdk/typescript/tests-ts/knowledge-base.test.ts b/sdk/typescript/tests-ts/knowledge-base.test.ts index 79ca83c4..846f0e9c 100644 --- a/sdk/typescript/tests-ts/knowledge-base.test.ts +++ b/sdk/typescript/tests-ts/knowledge-base.test.ts @@ -10,9 +10,10 @@ import { symlink, writeFile, } from "node:fs/promises"; +import * as os from "node:os"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import { strToU8, zipSync } from "fflate"; import { prepareKnowledgeBase } from "../src/knowledge-base.js"; @@ -105,6 +106,25 @@ describe("scan knowledge bases", () => { } }); + test("expands ~ in requested paths and leaves absolute paths alone", async () => { + const home = await temporaryDirectory(); + const documents = join(home, "docs"); + await mkdir(documents, { recursive: true }); + await writeFile(join(documents, "scope.md"), "Review the payment service."); + mock.module("node:os", () => ({ ...os, homedir: () => home })); + try { + const expanded = await prepareKnowledgeBase(["~/docs"]); + temporaryDirectories.push(expanded.path); + expect(expanded.sources).toEqual([documents]); + + const absolute = await prepareKnowledgeBase([documents]); + temporaryDirectories.push(absolute.path); + expect(absolute.sources).toEqual([documents]); + } finally { + mock.module("node:os", () => os); + } + }); + test("extracts searchable text from PDFs and DOCX documents", async () => { const root = await temporaryDirectory(); await writeFile( From 86f56a6fe57e01ce5e03fc87ad26028b982bbc24 Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Tue, 4 Aug 2026 11:12:31 -0600 Subject: [PATCH 2/2] test(knowledge-base): restore node:os after the ~ expansion test `mock.module` hot-swaps the live `node:os` namespace object, so `mock.module("node:os", () => os)` re-installed the already-mocked `homedir`. The restore was a no-op and every later test in the same bun process saw `homedir()` pointing at a temporary directory that `afterEach` had deleted. Snapshot the original exports before the first mock and restore from the snapshot, then assert the restore. Also cover bare `~` and `~other/docs`, which stays literal because another account's home cannot be resolved portably. --- .../tests-ts/knowledge-base.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/tests-ts/knowledge-base.test.ts b/sdk/typescript/tests-ts/knowledge-base.test.ts index 846f0e9c..e59ef169 100644 --- a/sdk/typescript/tests-ts/knowledge-base.test.ts +++ b/sdk/typescript/tests-ts/knowledge-base.test.ts @@ -19,6 +19,10 @@ import { prepareKnowledgeBase } from "../src/knowledge-base.js"; const temporaryDirectories: string[] = []; const testPosix = process.platform === "win32" ? test.skip : test; +// `mock.module` mutates the live `node:os` namespace, so the original exports +// have to be snapshotted before the first mock to be restorable afterwards. +const nodeOs = { ...os }; +const realHomeDirectory = os.homedir(); afterEach(async () => { await Promise.all( @@ -106,23 +110,34 @@ describe("scan knowledge bases", () => { } }); - test("expands ~ in requested paths and leaves absolute paths alone", async () => { + test("expands ~ in requested paths and leaves absolute and ~user paths alone", async () => { const home = await temporaryDirectory(); const documents = join(home, "docs"); await mkdir(documents, { recursive: true }); await writeFile(join(documents, "scope.md"), "Review the payment service."); - mock.module("node:os", () => ({ ...os, homedir: () => home })); + mock.module("node:os", () => ({ ...nodeOs, homedir: () => home })); try { const expanded = await prepareKnowledgeBase(["~/docs"]); temporaryDirectories.push(expanded.path); expect(expanded.sources).toEqual([documents]); + const bare = await prepareKnowledgeBase(["~"]); + temporaryDirectories.push(bare.path); + expect(bare.sources).toEqual([home]); + const absolute = await prepareKnowledgeBase([documents]); temporaryDirectories.push(absolute.path); expect(absolute.sources).toEqual([documents]); + + // Another account's home cannot be resolved portably, so `~other` stays a + // literal path segment under the working directory. + await expect(prepareKnowledgeBase(["~other/docs"])).rejects.toThrow( + /~other/u, + ); } finally { - mock.module("node:os", () => os); + mock.module("node:os", () => nodeOs); } + expect(os.homedir()).toBe(realHomeDirectory); }); test("extracts searchable text from PDFs and DOCX documents", async () => {