547 lines
17 KiB
TypeScript
547 lines
17 KiB
TypeScript
/**
|
|
* Unit tests for sandboxProcessWrapper
|
|
*
|
|
* Tests buildSandboxedSpawnArgs() behavior:
|
|
* - enabled=false: returns original command/args with NOOP_CLEANUP
|
|
* - docker type: logs warning and returns original command/args
|
|
* - macos-seatbelt type: returns sandbox-exec command
|
|
* - linux-bwrap type: returns bwrap command
|
|
* - error handling: re-throws errors from SandboxInvoker.buildInvocation
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import log from "electron-log";
|
|
import { SandboxInvoker } from "./SandboxInvoker";
|
|
import { buildSandboxedSpawnArgs } from "./sandboxProcessWrapper";
|
|
import type { SandboxProcessConfig } from "@shared/types/sandbox";
|
|
|
|
// =============================================================================
|
|
// Mock electron-log
|
|
// =============================================================================
|
|
|
|
const mockLogWarn = vi.fn();
|
|
const mockLogInfo = vi.fn();
|
|
const mockLogError = vi.fn();
|
|
|
|
vi.mock("electron-log", () => ({
|
|
default: {
|
|
warn: (...args: unknown[]) => mockLogWarn(...args),
|
|
info: (...args: unknown[]) => mockLogInfo(...args),
|
|
error: (...args: unknown[]) => mockLogError(...args),
|
|
},
|
|
}));
|
|
|
|
// =============================================================================
|
|
// Mock fs
|
|
// =============================================================================
|
|
|
|
const mockExistsSync = vi.fn();
|
|
const mockMkdirSync = vi.fn();
|
|
const mockUnlinkSync = vi.fn();
|
|
const mockRealpathSync = vi.fn();
|
|
|
|
vi.mock("fs", () => ({
|
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
|
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
|
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
|
|
realpathSync: (...args: unknown[]) => mockRealpathSync(...args),
|
|
}));
|
|
|
|
// =============================================================================
|
|
// Mock SandboxInvoker
|
|
// =============================================================================
|
|
|
|
const mockBuildInvocation = vi.fn();
|
|
|
|
vi.mock("./SandboxInvoker", () => ({
|
|
SandboxInvoker: vi.fn().mockImplementation(() => ({
|
|
buildInvocation: mockBuildInvocation,
|
|
})),
|
|
}));
|
|
|
|
// =============================================================================
|
|
// Test helpers
|
|
// =============================================================================
|
|
|
|
const NOOP_CLEANUP = () => {};
|
|
|
|
const createBaseConfig = (): SandboxProcessConfig => ({
|
|
enabled: true,
|
|
type: "macos-seatbelt",
|
|
projectWorkspaceDir: "/tmp/ws",
|
|
networkEnabled: false,
|
|
fallback: "degrade_to_off",
|
|
});
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe("buildSandboxedSpawnArgs", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockExistsSync.mockReturnValue(true); // Default: paths exist
|
|
mockMkdirSync.mockReturnValue(undefined);
|
|
mockUnlinkSync.mockReturnValue(undefined);
|
|
mockRealpathSync.mockImplementation((p: string) => p);
|
|
mockBuildInvocation.mockReset();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test: enabled=false returns original command/args with NOOP_CLEANUP
|
|
// ---------------------------------------------------------------------------
|
|
describe("when enabled is false", () => {
|
|
it("should return original command and args unchanged", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
enabled: false,
|
|
type: "macos-seatbelt",
|
|
projectWorkspaceDir: "/tmp/ws",
|
|
networkEnabled: false,
|
|
fallback: "degrade_to_off",
|
|
};
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
expect(result.command).toBe("/bin/ls");
|
|
expect(result.args).toEqual(["-la"]);
|
|
});
|
|
|
|
it("should return NOOP_CLEANUP function", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
enabled: false,
|
|
type: "macos-seatbelt",
|
|
projectWorkspaceDir: "/tmp/ws",
|
|
networkEnabled: false,
|
|
fallback: "degrade_to_off",
|
|
};
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
// cleanupSandbox should be a noop function (truthy and returns undefined)
|
|
expect(typeof result.cleanupSandbox).toBe("function");
|
|
expect(result.cleanupSandbox()).toBeUndefined();
|
|
});
|
|
|
|
it("should not call SandboxInvoker when disabled", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
enabled: false,
|
|
type: "macos-seatbelt",
|
|
projectWorkspaceDir: "/tmp/ws",
|
|
networkEnabled: false,
|
|
fallback: "degrade_to_off",
|
|
};
|
|
|
|
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config);
|
|
|
|
expect(SandboxInvoker).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test: docker type logs warning and returns original command/args
|
|
// ---------------------------------------------------------------------------
|
|
describe("when type is docker", () => {
|
|
it("should log warning about docker not supported", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "docker",
|
|
};
|
|
|
|
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config);
|
|
|
|
expect(mockLogWarn).toHaveBeenCalledWith(
|
|
expect.stringContaining("Docker process-level sandbox not supported"),
|
|
);
|
|
});
|
|
|
|
it("should return original command and args unchanged", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "docker",
|
|
};
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
expect(result.command).toBe("/bin/ls");
|
|
expect(result.args).toEqual(["-la"]);
|
|
});
|
|
|
|
it("should return NOOP_CLEANUP", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "docker",
|
|
};
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
// cleanupSandbox should be a noop function (truthy and returns undefined)
|
|
expect(typeof result.cleanupSandbox).toBe("function");
|
|
expect(result.cleanupSandbox()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test: macos-seatbelt type returns sandbox-exec command
|
|
// ---------------------------------------------------------------------------
|
|
describe("when type is macos-seatbelt", () => {
|
|
it("should return sandbox-exec command", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "macos-seatbelt",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/sandbox-exec",
|
|
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
|
|
});
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
expect(result.command).toBe("/usr/bin/sandbox-exec");
|
|
expect(result.args[0]).toBe("-f");
|
|
expect(result.args[2]).toBe("/bin/ls");
|
|
});
|
|
|
|
it("should create workspace directory if it does not exist", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
projectWorkspaceDir: "/tmp/nonexistent-ws",
|
|
};
|
|
|
|
mockExistsSync.mockImplementation((p: string) => {
|
|
if (p === "/tmp/nonexistent-ws") return false;
|
|
return true;
|
|
});
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/sandbox-exec",
|
|
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
|
|
});
|
|
|
|
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config);
|
|
|
|
expect(mockMkdirSync).toHaveBeenCalledWith("/tmp/nonexistent-ws", {
|
|
recursive: true,
|
|
});
|
|
});
|
|
|
|
it("should return a cleanup function that deletes seatbelt profile", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "macos-seatbelt",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/sandbox-exec",
|
|
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
|
|
});
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
// cleanupSandbox should be a function (not NOOP_CLEANUP)
|
|
expect(typeof result.cleanupSandbox).toBe("function");
|
|
expect(result.cleanupSandbox).not.toBe(NOOP_CLEANUP);
|
|
|
|
// Calling cleanup should delete the profile file
|
|
result.cleanupSandbox();
|
|
expect(mockUnlinkSync).toHaveBeenCalledWith(
|
|
"/tmp/qimingclaw-sandbox-123.sb",
|
|
);
|
|
});
|
|
|
|
it("should handle cleanup gracefully when profile file does not exist", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "macos-seatbelt",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/sandbox-exec",
|
|
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
|
|
});
|
|
|
|
mockUnlinkSync.mockImplementation(() => {
|
|
throw new Error("ENOENT: no such file");
|
|
});
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
// Should not throw
|
|
expect(() => result.cleanupSandbox()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test: linux-bwrap type returns bwrap command
|
|
// ---------------------------------------------------------------------------
|
|
describe("when type is linux-bwrap", () => {
|
|
it("should return bwrap command", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "linux-bwrap",
|
|
projectWorkspaceDir: "/tmp/ws",
|
|
linuxBwrapPath: "/usr/bin/bwrap",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/bwrap",
|
|
args: [
|
|
"--die-with-parent",
|
|
"--new-session",
|
|
"--unshare-user-try",
|
|
"--unshare-pid",
|
|
"--unshare-uts",
|
|
"--unshare-cgroup-try",
|
|
"--dev-bind",
|
|
"/dev",
|
|
"/dev",
|
|
"--proc",
|
|
"/proc",
|
|
"--tmpfs",
|
|
"/tmp",
|
|
"--ro-bind",
|
|
"/",
|
|
"/",
|
|
"--bind",
|
|
"/tmp/ws",
|
|
"/tmp/ws",
|
|
"--chdir",
|
|
"/tmp",
|
|
"--",
|
|
"/bin/ls",
|
|
"-la",
|
|
],
|
|
cwd: "/tmp",
|
|
});
|
|
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
config,
|
|
);
|
|
|
|
expect(result.command).toBe("/usr/bin/bwrap");
|
|
expect(result.args).toContain("--bind");
|
|
expect(result.args).toContain("/tmp/ws");
|
|
});
|
|
|
|
it("should pass writablePaths to SandboxInvoker", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "linux-bwrap",
|
|
projectWorkspaceDir: "/tmp/ws",
|
|
linuxBwrapPath: "/usr/bin/bwrap",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/bwrap",
|
|
args: ["--die-with-parent", "--", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
});
|
|
|
|
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, [
|
|
"/tmp/extra-writable",
|
|
]);
|
|
|
|
expect(mockBuildInvocation).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
writablePaths: ["/tmp/ws", "/tmp/extra-writable"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should filter out empty writablePaths", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "linux-bwrap",
|
|
projectWorkspaceDir: "/tmp/ws",
|
|
linuxBwrapPath: "/usr/bin/bwrap",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/bwrap",
|
|
args: ["--die-with-parent", "--", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
});
|
|
|
|
// Only truly empty strings (length 0) are filtered; whitespace strings pass through
|
|
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, [
|
|
"",
|
|
"/tmp/valid-path",
|
|
]);
|
|
|
|
expect(mockBuildInvocation).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
writablePaths: ["/tmp/ws", "/tmp/valid-path"],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test: error handling - re-throws errors from SandboxInvoker.buildInvocation
|
|
// ---------------------------------------------------------------------------
|
|
describe("error handling", () => {
|
|
it("should re-throw errors from SandboxInvoker.buildInvocation", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "macos-seatbelt",
|
|
};
|
|
|
|
const expectedError = new Error("Sandbox invocation failed");
|
|
mockBuildInvocation.mockRejectedValue(expectedError);
|
|
|
|
await expect(
|
|
buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config),
|
|
).rejects.toThrow("Sandbox invocation failed");
|
|
});
|
|
|
|
it("should log error before re-throwing", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "linux-bwrap",
|
|
};
|
|
|
|
const expectedError = new Error("bwrap not found");
|
|
mockBuildInvocation.mockRejectedValue(expectedError);
|
|
|
|
await expect(
|
|
buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config),
|
|
).rejects.toThrow();
|
|
|
|
expect(mockLogError).toHaveBeenCalledWith(
|
|
"[SandboxProcessWrapper] Sandbox wrapping failed:",
|
|
expectedError,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test: extraWritablePaths parameter
|
|
// ---------------------------------------------------------------------------
|
|
describe("extraWritablePaths parameter", () => {
|
|
it("should include extraWritablePaths in writable paths", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "macos-seatbelt",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/sandbox-exec",
|
|
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
|
|
});
|
|
|
|
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, [
|
|
"/tmp/extra1",
|
|
"/tmp/extra2",
|
|
]);
|
|
|
|
expect(mockBuildInvocation).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
writablePaths: ["/tmp/ws", "/tmp/extra1", "/tmp/extra2"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should handle empty extraWritablePaths", async () => {
|
|
const config: SandboxProcessConfig = {
|
|
...createBaseConfig(),
|
|
type: "macos-seatbelt",
|
|
};
|
|
|
|
mockBuildInvocation.mockResolvedValue({
|
|
command: "/usr/bin/sandbox-exec",
|
|
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
|
|
cwd: "/tmp",
|
|
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
|
|
});
|
|
|
|
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, []);
|
|
|
|
expect(mockBuildInvocation).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
writablePaths: ["/tmp/ws"],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test: sandboxConfig undefined (same as enabled=false)
|
|
// ---------------------------------------------------------------------------
|
|
describe("when sandboxConfig is undefined", () => {
|
|
it("should return original command and args", async () => {
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
undefined,
|
|
);
|
|
|
|
expect(result.command).toBe("/bin/ls");
|
|
expect(result.args).toEqual(["-la"]);
|
|
});
|
|
|
|
it("should return NOOP_CLEANUP", async () => {
|
|
const result = await buildSandboxedSpawnArgs(
|
|
"/bin/ls",
|
|
["-la"],
|
|
"/tmp",
|
|
undefined,
|
|
);
|
|
|
|
// cleanupSandbox should be a noop function (truthy and returns undefined)
|
|
expect(typeof result.cleanupSandbox).toBe("function");
|
|
expect(result.cleanupSandbox()).toBeUndefined();
|
|
});
|
|
});
|
|
});
|