Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions packages/websocket/tests/file-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ function makeRequest(urlPath: string, method = "GET"): Request {
return new Request(`http://localhost${urlPath}`, { method });
}

/**
* Creating symlinks on Windows needs Developer Mode or elevation; when the
* environment can't, the caller skips the test instead of failing on EPERM.
*/
async function trySymlink(target: string, link: string): Promise<boolean> {
try {
await fs.symlink(target, link);
return true;
} catch (error) {
if (
process.platform === "win32" &&
(error as NodeJS.ErrnoException).code === "EPERM"
) {
return false;
}
throw error;
}
}

// ---------------------------------------------------------------------------
// /api/files/local (streaming by absolute path)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -127,23 +146,28 @@ describe("/api/files/local", () => {

it("defaults the root to the home directory", async () => {
delete process.env["NODETOOL_LOCAL_FILE_ROOTS"];
const file = path.join(tmpDir, "clip.mp4");
await fs.writeFile(file, "video-bytes");
// tmpDir is outside home, so the default policy refuses it.
const res = await handleFileRequest(localRequest(file));
// A path guaranteed to be outside home on every platform (the Windows
// tmpdir lives *inside* home, so tmpDir won't do). The policy check runs
// before the existence check, so the file doesn't have to exist.
const outside = path.join(
path.parse(os.homedir()).root,
"nodetool-file-api-outside",
"clip.mp4"
);
const res = await handleFileRequest(localRequest(outside));
expect(res.status).toBe(403);
});

it("denies a symlink that escapes the roots", async () => {
it("denies a symlink that escapes the roots", async (ctx) => {
const link = path.join(tmpDir, "escape.txt");
await fs.symlink("/etc/passwd", link);
if (!(await trySymlink("/etc/passwd", link))) return ctx.skip();
const res = await handleFileRequest(localRequest(link));
expect(res.status).toBe(403);
});

it("denies a path that escapes through a symlinked parent", async () => {
it("denies a path that escapes through a symlinked parent", async (ctx) => {
const linkDir = path.join(tmpDir, "outside");
await fs.symlink("/etc", linkDir);
if (!(await trySymlink("/etc", linkDir))) return ctx.skip();
const res = await handleFileRequest(
localRequest(path.join(linkDir, "passwd"))
);
Expand Down
4 changes: 3 additions & 1 deletion packages/websocket/tests/generation-autosave-cutover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ async function waitForMessages(
ws: MockWS,
predicate: (m: Record<string, unknown>) => boolean,
count: number,
timeoutMs = 5000
// Generous ceiling — the loop returns the moment `count` is reached, so
// this only costs time when events are genuinely late (slow dev machines).
timeoutMs = 30_000
): Promise<Record<string, unknown>[]> {
const start = Date.now();
for (;;) {
Expand Down
33 changes: 27 additions & 6 deletions packages/websocket/tests/local-file-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ afterEach(async () => {
delete process.env[LOCAL_FILE_ROOTS_ENV];
});

/**
* Creating symlinks on Windows needs Developer Mode or elevation; when the
* environment can't, the caller skips the test instead of failing on EPERM.
*/
async function trySymlink(target: string, link: string): Promise<boolean> {
try {
await fs.symlink(target, link);
return true;
} catch (error) {
if (
process.platform === "win32" &&
(error as NodeJS.ErrnoException).code === "EPERM"
) {
return false;
}
throw error;
}
}

describe("getLocalFileRoots", () => {
it("defaults to the home directory", () => {
expect(getLocalFileRoots()).toEqual([path.resolve(os.homedir())]);
Expand Down Expand Up @@ -96,27 +115,29 @@ describe("resolveLocalPath", () => {
expect(result).toEqual({ ok: false, reason: "invalid" });
});

it("rejects a symlink pointing outside the roots", async () => {
it("rejects a symlink pointing outside the roots", async (ctx) => {
const link = path.join(tmpDir, "escape");
await fs.symlink("/etc/passwd", link);
if (!(await trySymlink("/etc/passwd", link))) return ctx.skip();
const result = await resolveLocalPath(link, [tmpDir]);
expect(result).toEqual({ ok: false, reason: "outside_roots" });
});

it("rejects a leaf reached through a symlinked parent", async () => {
await fs.symlink("/etc", path.join(tmpDir, "outside"));
it("rejects a leaf reached through a symlinked parent", async (ctx) => {
if (!(await trySymlink("/etc", path.join(tmpDir, "outside")))) {
return ctx.skip();
}
Comment on lines +118 to +128
const result = await resolveLocalPath(
path.join(tmpDir, "outside", "passwd"),
[tmpDir]
);
expect(result).toEqual({ ok: false, reason: "outside_roots" });
});

it("allows a symlink that stays inside the roots", async () => {
it("allows a symlink that stays inside the roots", async (ctx) => {
const target = path.join(tmpDir, "real.txt");
await fs.writeFile(target, "x");
const link = path.join(tmpDir, "alias.txt");
await fs.symlink(target, link);
if (!(await trySymlink(target, link))) return ctx.skip();
const result = await resolveLocalPath(link, [tmpDir]);
expect(result).toEqual({ ok: true, path: link });
});
Expand Down
25 changes: 24 additions & 1 deletion packages/websocket/tests/models-api-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,30 @@
* outbound fetch (check_servers is left off), and production paths that would
* import HF helpers return early with []/false.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

// Keep the local-scope routes hermetic: on a developer machine the real
// readCachedHfModels scans a (potentially huge) HF cache and blows the test
// timeout; deleteCachedHfModel would touch the real cache.
vi.mock("@nodetool-ai/huggingface", async (orig) => {
const actual = await orig<typeof import("@nodetool-ai/huggingface")>();
return {
...actual,
readCachedHfModels: vi.fn().mockResolvedValue([]),
deleteCachedHfModel: vi.fn().mockResolvedValue(true)
};
});

// No provider is "configured" in tests: the real secret store on a developer
// machine holds API keys, which turns GET /all into live provider calls.
vi.mock("@nodetool-ai/models", async (orig) => {
const actual = await orig<typeof import("@nodetool-ai/models")>();
return {
...actual,
getSecret: vi.fn().mockResolvedValue(null)
};
});

import {
handleModelsApiRequest,
toUnifiedModelsFromLanguage,
Expand Down
13 changes: 13 additions & 0 deletions packages/websocket/tests/models-api-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@
* /ws/download socket sink.
*/
import { describe, it, expect, vi } from "vitest";

// Keep the local-scope routes hermetic: on a developer machine the real
// readCachedHfModels scans a (potentially huge) HF cache and blows the test
// timeout; deleteCachedHfModel would touch the real cache.
vi.mock("@nodetool-ai/huggingface", async (orig) => {
const actual = await orig<typeof import("@nodetool-ai/huggingface")>();
return {
...actual,
readCachedHfModels: vi.fn().mockResolvedValue([]),
deleteCachedHfModel: vi.fn().mockResolvedValue(true)
};
});

import {
handleModelsApiRequest,
relayWorkerDownload,
Expand Down
13 changes: 9 additions & 4 deletions packages/websocket/tests/resolve-media-urls-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { pathToFileURL } from "node:url";

// The source resolves paths through pathToFileURL, which on Windows prefixes
// the current drive (file:///C:/var/...). Build expectations the same way.
const fileUri = (name: string) => pathToFileURL(`/var/assets/${name}`).href;

vi.mock("@nodetool-ai/config", () => ({
buildAssetUrl: (name: string) => `https://assets.test/${name}`,
Expand Down Expand Up @@ -120,7 +125,7 @@ describe("resolveContentForProvider", () => {
it("resolves image asset_id to a file:// URI", () => {
const content = [{ type: "image", image: { asset_id: "abc", mimeType: "image/png" } }];
const out = resolveContentForProvider(content) as any[];
expect(out[0].image.uri).toBe("file:///var/assets/abc.png");
expect(out[0].image.uri).toBe(fileUri("abc.png"));
});

it("does not overwrite an existing uri", () => {
Expand All @@ -134,13 +139,13 @@ describe("resolveContentForProvider", () => {
it("resolves video to file:// URI with mp4 fallback", () => {
const content = [{ type: "video", video: { asset_id: "v" } }];
const out = resolveContentForProvider(content) as any[];
expect(out[0].video.uri).toBe("file:///var/assets/v.mp4");
expect(out[0].video.uri).toBe(fileUri("v.mp4"));
});

it("resolves audio to file:// URI with wav fallback", () => {
const content = [{ type: "audio", audio: { asset_id: "a" } }];
const out = resolveContentForProvider(content) as any[];
expect(out[0].audio.uri).toBe("file:///var/assets/a.wav");
expect(out[0].audio.uri).toBe(fileUri("a.wav"));
});

it("passes through primitives and unknown blocks", () => {
Expand All @@ -154,6 +159,6 @@ describe("resolveContentForProvider", () => {
{ type: "audio", audio: { asset_id: "a", mimeType: "audio/mpeg" } }
];
const out = resolveContentForProvider(content) as any[];
expect(out[0].audio.uri).toBe("file:///var/assets/a.mp3");
expect(out[0].audio.uri).toBe(fileUri("a.mp3"));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
* so every assistant-generated image 404'd on re-serve.
*/
import { describe, it, expect, vi } from "vitest";
import { pathToFileURL } from "node:url";

// pathToFileURL prefixes the current drive on Windows; build expectations
// the same way the source does.
const fileUri = (key: string) => pathToFileURL(`/var/assets/${key}`).href;

const fsMocks = vi.hoisted(() => ({ existing: new Set<string>() }));

Expand Down Expand Up @@ -66,7 +71,7 @@ describe("resolveContentForProvider with a known owner", () => {
[{ type: "image", image: { asset_id: "abc", mimeType: "image/png" } }],
"user-1"
) as Block[];
expect(out[0].image.uri).toBe("file:///var/assets/user-1/abc.png");
expect(out[0].image.uri).toBe(fileUri("user-1/abc.png"));
});

it("falls back to the flat legacy path for pre-migration objects", () => {
Expand All @@ -76,6 +81,6 @@ describe("resolveContentForProvider with a known owner", () => {
[{ type: "image", image: { asset_id: "abc", mimeType: "image/png" } }],
"user-1"
) as Block[];
expect(out[0].image.uri).toBe("file:///var/assets/abc.png");
expect(out[0].image.uri).toBe(fileUri("abc.png"));
});
});
9 changes: 7 additions & 2 deletions packages/websocket/tests/trpc-fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ vi.mock("node:os", async (orig) => {

import { existsSync, readdirSync } from "node:fs";
import { platform } from "node:os";
import { join } from "node:path";

// The router builds the user font dir with path.join (backslashes on
// Windows); mock and match it the same way.
const USER_FONTS = join("/home/user", "Library", "Fonts");

const createCaller = createCallerFactory(appRouter);

Expand Down Expand Up @@ -62,14 +67,14 @@ describe("fonts router", () => {
(platform as ReturnType<typeof vi.fn>).mockReturnValue("darwin");
(existsSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) =>
p === "/Library/Fonts" || p === "/home/user/Library/Fonts"
p === "/Library/Fonts" || p === USER_FONTS
);
(readdirSync as ReturnType<typeof vi.fn>).mockImplementation(
(dir: string) => {
if (dir === "/Library/Fonts") {
return ["Arial.ttf", "Helvetica.otf", "Readme.txt"];
}
if (dir === "/home/user/Library/Fonts") {
if (dir === USER_FONTS) {
return ["CustomFont.ttf"];
}
return [];
Expand Down
31 changes: 21 additions & 10 deletions packages/websocket/tests/trpc-mcp-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ import {
mkdirSync
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

// The router builds config paths with path.join, which uses backslashes on
// Windows. Build the expected paths the same way (content keys inside the
// JSON fixtures stay POSIX — they come from the mocked homedir()).
const HOME = "/home/user";
const CLAUDE_JSON = join(HOME, ".claude.json");
const CODEX_DIR = join(HOME, ".codex");
const CODEX_TOML = join(CODEX_DIR, "config.toml");
const OPENCODE_DIR = join(HOME, ".config", "opencode");
const OPENCODE_JSON = join(OPENCODE_DIR, "opencode.json");

const createCaller = createCallerFactory(appRouter);

Expand Down Expand Up @@ -119,7 +130,7 @@ describe("mcpConfig router", () => {

it("reads claude installation when .claude.json has nodetool MCP server", async () => {
(existsSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) => p === "/home/user/.claude.json"
(p: string) => p === CLAUDE_JSON
);
(readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
JSON.stringify({
Expand All @@ -138,12 +149,12 @@ describe("mcpConfig router", () => {
const claude = result.targets.find((t) => t.target === "claude");
expect(claude?.installed).toBe(true);
expect(claude?.url).toBe("http://127.0.0.1:7777/mcp");
expect(claude?.configPath).toBe("/home/user/.claude.json");
expect(claude?.configPath).toBe(CLAUDE_JSON);
});

it("reads codex installation by regex from config.toml", async () => {
(existsSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) => p === "/home/user/.codex/config.toml"
(p: string) => p === CODEX_TOML
);
(readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
`# BEGIN NODETOOL MCP
Expand All @@ -162,7 +173,7 @@ url = "http://127.0.0.1:7777/mcp"

it("reads opencode installation from opencode.json", async () => {
(existsSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) => p === "/home/user/.config/opencode/opencode.json"
(p: string) => p === OPENCODE_JSON
);
(readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
JSON.stringify({
Expand Down Expand Up @@ -216,7 +227,7 @@ url = "http://127.0.0.1:7777/mcp"
expect(result.results).toHaveLength(1);
expect(result.results[0]?.target).toBe("claude");
expect(result.results[0]?.success).toBe(true);
expect(result.results[0]?.configPath).toBe("/home/user/.claude.json");
expect(result.results[0]?.configPath).toBe(CLAUDE_JSON);
});

it("uses provided url when specified", async () => {
Expand All @@ -233,11 +244,11 @@ url = "http://127.0.0.1:7777/mcp"
(existsSync as ReturnType<typeof vi.fn>).mockReturnValue(false);
const caller = createCaller(makeCtx());
await caller.mcpConfig.install({ targets: ["codex", "opencode"] });
expect(mkdirSync).toHaveBeenCalledWith("/home/user/.codex", {
expect(mkdirSync).toHaveBeenCalledWith(CODEX_DIR, {
recursive: true
});
expect(mkdirSync).toHaveBeenCalledWith(
"/home/user/.config/opencode",
OPENCODE_DIR,
{ recursive: true }
);
});
Expand All @@ -246,7 +257,7 @@ url = "http://127.0.0.1:7777/mcp"
(existsSync as ReturnType<typeof vi.fn>).mockReturnValue(false);
(writeFileSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) => {
if (p === "/home/user/.claude.json") throw new Error("disk full");
if (p === CLAUDE_JSON) throw new Error("disk full");
}
);

Expand Down Expand Up @@ -281,7 +292,7 @@ url = "http://127.0.0.1:7777/mcp"

it("removes nodetool entry from .claude.json when present", async () => {
(existsSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) => p === "/home/user/.claude.json"
(p: string) => p === CLAUDE_JSON
);
(readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
JSON.stringify({
Expand Down Expand Up @@ -310,7 +321,7 @@ url = "http://127.0.0.1:7777/mcp"

it("removes block from codex config.toml", async () => {
(existsSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) => p === "/home/user/.codex/config.toml"
(p: string) => p === CODEX_TOML
);
(readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
`[something_else]
Expand Down
Loading
Loading