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..e59ef169 100644 --- a/sdk/typescript/tests-ts/knowledge-base.test.ts +++ b/sdk/typescript/tests-ts/knowledge-base.test.ts @@ -10,14 +10,19 @@ 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"; 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( @@ -105,6 +110,36 @@ describe("scan knowledge bases", () => { } }); + 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", () => ({ ...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", () => nodeOs); + } + expect(os.homedir()).toBe(realHomeDirectory); + }); + test("extracts searchable text from PDFs and DOCX documents", async () => { const root = await temporaryDirectory(); await writeFile(