fs-safe/test/pinned-python.test.ts
Sarah Fortune 3be7ba6ee3 ci+test: run check on windows and guard windows-only test behavior
Run the check job on windows-latest in addition to ubuntu so the
windows code paths (no O_NOFOLLOW, node fallbacks for fd-relative
ops, ACL inspection) are exercised on every PR rather than only
documented.

Make the test suite pass on the new windows runner by addressing
the platform-specific failures:

- Long happy-path tests that mix supported (mkdir, write, read) and
  unsupported (stat, list, move, exists) operations are guarded
  with skipIf(process.platform === "win32") since the pinned
  filesystem helper throws "unsupported-platform" on win32 by
  design (src/pinned-python.ts).
- Short focused tests where the unsupported operation is the whole
  point (pinned-python, pinned-write-fallback-coverage,
  write-boundary-bypass symlink-move) split into runIf(non-win32)
  and runIf(win32) tests, with the windows variant asserting
  unsupported-platform.
- The expectFsSafeCode helper accepts unsupported-platform on
  windows; new expectedFsSafeCode helper substitutes for
  per-rejects.toMatchObject sites where the windows code differs
  from posix (e.g. path-alias / not-found returning
  unsupported-platform via the helper layer).
- secure-file-reads test split into a posix happy-path runIf and a
  windows runIf that asserts permission-unverified, since ACL
  inspection has no portable equivalent on windows
  (src/secure-file.ts:177).
- safeFileURLToPath test uses hardcoded platform-specific input/
  output instead of building the URL via pathToFileURL+fileURLToPath
  so the assertion verifies the function directly.
- Fix expandHomePrefix to normalize path separators by splitting via
  path.normalize + path.sep and rejoining via path.join. Apply the
  same segment-based check to resolveHomeRelativePath and
  resolveOsHomeRelativePath. Drop input.trim() — whitespace is a
  valid filename character on both platforms and env-var inputs are
  already trimmed upstream via normalizeOptionalString.
- coverage-more's "normalizes empty temp names" decomposes the
  result with path.dirname/path.basename instead of regex-matching
  a path-separator literal.
- extracted-helpers' path-helpers test builds its root with
  path.resolve so the drive letter is present on windows.
- additional-boundary-bypass guards its "..\evil.txt" sanitizer
  assertion behind a non-win32 check (windows reserves "\" as a
  path separator and cannot have it in a filename).
- coverage-more's sibling temp test guards just the posix file-mode
  assertion (stat.mode & 0o777 === 0o600), which has no analog on
  windows. The syncing behaviour the test actually targets still
  runs on both platforms.
- Raise test/new-primitives.test.ts size budget to 1500 to
  accommodate the secure-file-reads test split.

After: 253 passed, 1 failed, 66 skipped on windows-11-arm64. The
single remaining failure is a separate library-side gap (a
SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS payload resolves on windows
instead of rejecting) and will be tracked in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:59:24 -07:00

259 lines
8.2 KiB
TypeScript

import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { configureFsSafePython, root } from "../src/index.js";
import {
canFallbackFromPythonError,
getFsSafePythonConfig,
} from "../src/pinned-python-config.js";
import {
__resetPinnedPythonWorkerForTest,
runPinnedPythonOperation,
} from "../src/pinned-python.js";
import * as configSubpath from "../src/config.js";
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
type FakeChild = EventEmitter & {
kill: ReturnType<typeof vi.fn>;
ref: ReturnType<typeof vi.fn>;
stderr: EventEmitter & {
ref: ReturnType<typeof vi.fn>;
setEncoding: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
};
stdin: EventEmitter & {
ref: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
};
stdout: EventEmitter & {
ref: ReturnType<typeof vi.fn>;
setEncoding: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
};
unref: ReturnType<typeof vi.fn>;
};
const tempDirs = new Set<string>();
const originalEnv = {
FS_SAFE_PYTHON: process.env.FS_SAFE_PYTHON,
FS_SAFE_PYTHON_MODE: process.env.FS_SAFE_PYTHON_MODE,
OPENCLAW_FS_SAFE_PYTHON: process.env.OPENCLAW_FS_SAFE_PYTHON,
OPENCLAW_FS_SAFE_PYTHON_MODE: process.env.OPENCLAW_FS_SAFE_PYTHON_MODE,
OPENCLAW_PINNED_PYTHON: process.env.OPENCLAW_PINNED_PYTHON,
OPENCLAW_PINNED_WRITE_PYTHON: process.env.OPENCLAW_PINNED_WRITE_PYTHON,
};
function restoreEnv(): void {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
async function tempRoot(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.add(dir);
return dir;
}
function makeChild(
write?: (line: string, callback?: (error?: Error | null) => void) => void,
): FakeChild {
const child = new EventEmitter() as FakeChild;
child.ref = vi.fn();
child.unref = vi.fn();
child.kill = vi.fn();
child.stdout = Object.assign(new EventEmitter(), {
ref: vi.fn(),
setEncoding: vi.fn(),
unref: vi.fn(),
});
child.stderr = Object.assign(new EventEmitter(), {
ref: vi.fn(),
setEncoding: vi.fn(),
unref: vi.fn(),
});
child.stdin = Object.assign(new EventEmitter(), {
ref: vi.fn(),
unref: vi.fn(),
write: vi.fn((line: string, callback?: (error?: Error | null) => void) => {
write?.(line, callback);
callback?.();
return true;
}),
});
return child;
}
function makeRespondingChild(): FakeChild {
const child = makeChild((line) => {
const request = JSON.parse(line) as { id: number };
queueMicrotask(() => {
child.stdout.emit(
"data",
`${JSON.stringify({ id: request.id, ok: true, result: { ok: true } })}\n`,
);
});
});
return child;
}
function makeFailingChild(): FakeChild {
const child = makeChild();
queueMicrotask(() => {
const error = Object.assign(new Error("spawn ENOENT"), {
code: "ENOENT",
syscall: "spawn python3",
});
child.emit("error", error);
});
return child;
}
afterEach(async () => {
configureFsSafePython({ mode: "auto", pythonPath: undefined });
__resetPinnedPythonWorkerForTest();
restoreEnv();
spawnMock.mockReset();
for (const dir of tempDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
tempDirs.clear();
});
describe("Python helper configuration", () => {
it("reads environment mode and python path aliases", () => {
delete process.env.FS_SAFE_PYTHON_MODE;
delete process.env.OPENCLAW_FS_SAFE_PYTHON_MODE;
delete process.env.FS_SAFE_PYTHON;
delete process.env.OPENCLAW_FS_SAFE_PYTHON;
delete process.env.OPENCLAW_PINNED_PYTHON;
delete process.env.OPENCLAW_PINNED_WRITE_PYTHON;
expect(getFsSafePythonConfig()).toEqual({ mode: "auto", pythonPath: undefined });
process.env.FS_SAFE_PYTHON_MODE = "off";
process.env.FS_SAFE_PYTHON = "/tmp/python-a";
expect(getFsSafePythonConfig()).toEqual({ mode: "off", pythonPath: "/tmp/python-a" });
delete process.env.FS_SAFE_PYTHON_MODE;
delete process.env.FS_SAFE_PYTHON;
process.env.OPENCLAW_FS_SAFE_PYTHON_MODE = "required";
process.env.OPENCLAW_PINNED_WRITE_PYTHON = "/tmp/python-b";
expect(getFsSafePythonConfig()).toEqual({
mode: "require",
pythonPath: "/tmp/python-b",
});
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/python-c" });
expect(getFsSafePythonConfig()).toEqual({ mode: "auto", pythonPath: "/tmp/python-c" });
expect(configSubpath.getFsSafePythonConfig()).toEqual({
mode: "auto",
pythonPath: "/tmp/python-c",
});
});
it("only allows helper-unavailable fallback outside require mode", () => {
const error = Object.assign(new Error("missing"), { code: "helper-unavailable" });
configureFsSafePython({ mode: "auto" });
expect(canFallbackFromPythonError(error)).toBe(true);
configureFsSafePython({ mode: "off" });
expect(canFallbackFromPythonError(error)).toBe(true);
configureFsSafePython({ mode: "require" });
expect(canFallbackFromPythonError(error)).toBe(false);
});
});
describe("persistent Python helper worker", () => {
it("reuses one worker and unreferences it while idle", async () => {
if (process.platform === "win32") {
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/fake-python" });
await expect(
runPinnedPythonOperation<{ ok: boolean }>({
operation: "stat",
rootPath: "/tmp/root",
payload: { relativePath: "a.txt" },
}),
).rejects.toMatchObject({ code: "unsupported-platform" });
return;
}
const child = makeRespondingChild();
spawnMock.mockReturnValue(child);
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/fake-python" });
await expect(
runPinnedPythonOperation<{ ok: boolean }>({
operation: "stat",
rootPath: "/tmp/root",
payload: { relativePath: "a.txt" },
}),
).resolves.toEqual({ ok: true });
await expect(
runPinnedPythonOperation<{ ok: boolean }>({
operation: "stat",
rootPath: "/tmp/root",
payload: { relativePath: "b.txt" },
}),
).resolves.toEqual({ ok: true });
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(child.ref).toHaveBeenCalled();
expect(child.unref).toHaveBeenCalled();
expect(child.stdin.write).toHaveBeenCalledTimes(2);
});
it("falls back in auto mode but fails closed in require mode", async () => {
const rootDir = await tempRoot("fs-safe-python-policy-");
await fs.writeFile(path.join(rootDir, "file.txt"), "ok");
if (process.platform === "win32") {
spawnMock.mockImplementation(makeFailingChild);
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/missing-python" });
const autoRoot = await root(rootDir);
await expect(autoRoot.stat("file.txt")).rejects.toMatchObject({
code: "unsupported-platform",
});
configureFsSafePython({ mode: "require", pythonPath: "/tmp/missing-python" });
await expect((await root(rootDir)).stat("file.txt")).rejects.toMatchObject({
code: "unsupported-platform",
});
return;
}
spawnMock.mockImplementation(makeFailingChild);
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/missing-python" });
const autoRoot = await root(rootDir);
await expect(autoRoot.stat("file.txt")).resolves.toMatchObject({
isFile: true,
});
await expect(autoRoot.list("")).resolves.toEqual(["file.txt"]);
await fs.writeFile(path.join(rootDir, "move.txt"), "move");
await autoRoot.move("move.txt", "moved.txt");
await expect(fs.readFile(path.join(rootDir, "moved.txt"), "utf8")).resolves.toBe("move");
__resetPinnedPythonWorkerForTest();
spawnMock.mockClear();
spawnMock.mockImplementation(makeFailingChild);
configureFsSafePython({ mode: "require", pythonPath: "/tmp/missing-python" });
await expect((await root(rootDir)).stat("file.txt")).rejects.toMatchObject({
code: "helper-unavailable",
});
});
});