chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user