test: optimize skills workspace fixtures

This commit is contained in:
Peter Steinberger
2026-04-18 19:50:30 +01:00
parent 5e7b5cf285
commit 6ccac3d208
11 changed files with 142 additions and 180 deletions

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
import { captureEnv } from "../test-utils/env.js";
import {
hasBinaryMock,
@@ -216,4 +217,44 @@ describe("skills-install fallback edge cases", () => {
envSnapshot.restore();
}
});
it("blocks workspace dotenv UV_PYTHON from uv install execution", async () => {
mockAvailableBinaries(["uv"]);
runCommandWithTimeoutMock.mockResolvedValueOnce({
code: 0,
stdout: "ok",
stderr: "",
signal: null,
killed: false,
});
const workspaceEnvPath = path.join(workspaceDir, ".env");
const envSnapshot = captureEnv(["UV_PYTHON"]);
try {
delete process.env.UV_PYTHON;
await fs.writeFile(workspaceEnvPath, "UV_PYTHON=/tmp/attacker-python\n", "utf-8");
loadWorkspaceDotEnvFile(workspaceEnvPath, { quiet: true });
expect(process.env.UV_PYTHON).toBeUndefined();
const result = await installSkill({
workspaceDir,
skillName: "py-tool",
installId: "deps",
timeoutMs: 10_000,
});
expect(result.ok).toBe(true);
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
["uv", "tool", "install", "example-package"],
expect.objectContaining({
timeoutMs: 10_000,
env: undefined,
}),
);
} finally {
envSnapshot.restore();
await fs.rm(workspaceEnvPath, { force: true });
}
});
});

View File

@@ -1,82 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
import { captureEnv } from "../test-utils/env.js";
import { installSkill } from "./skills-install.js";
describe("workspace .env UV_PYTHON handling for uv skill installs", () => {
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
afterEach(async () => {
envSnapshot?.restore();
envSnapshot = undefined;
});
it.runIf(process.platform !== "win32")(
"does not propagate UV_PYTHON from workspace dotenv into uv tool install execution",
async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-poc-uv-python-"));
const cwdDir = path.join(base, "cwd");
const binDir = path.join(base, "bin");
const markerPath = path.join(base, "uv-python-marker.txt");
const fakeUvPath = path.join(binDir, "uv");
try {
await fs.mkdir(cwdDir, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(path.join(cwdDir, "skills", "uv-skill"), { recursive: true });
await fs.writeFile(
path.join(cwdDir, "skills", "uv-skill", "SKILL.md"),
[
"---",
"name: uv-skill",
"description: uv install PoC",
'metadata: {"openclaw":{"install":[{"id":"deps","kind":"uv","package":"httpie==3.2.2"}]}}',
"---",
"",
"# uv-skill",
"",
].join("\n"),
"utf8",
);
await fs.writeFile(
fakeUvPath,
[
"#!/bin/sh",
'printf "%s\\n" "$UV_PYTHON" > "$OPENCLAW_POC_MARKER_PATH"',
"exit 0",
"",
].join("\n"),
"utf8",
);
await fs.chmod(fakeUvPath, 0o755);
const attackerPython = path.join(base, "attacker-python");
await fs.writeFile(path.join(cwdDir, ".env"), `UV_PYTHON=${attackerPython}\n`, "utf8");
envSnapshot = captureEnv(["PATH", "UV_PYTHON", "OPENCLAW_POC_MARKER_PATH"]);
delete process.env.UV_PYTHON;
process.env.OPENCLAW_POC_MARKER_PATH = markerPath;
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
expect(process.env.UV_PYTHON).toBeUndefined();
const result = await installSkill({
workspaceDir: cwdDir,
skillName: "uv-skill",
installId: "deps",
timeoutMs: 10_000,
});
expect(result.ok).toBe(true);
await expect(fs.readFile(markerPath, "utf8")).resolves.toBe("\n");
} finally {
await fs.rm(base, { recursive: true, force: true });
}
},
);
});

View File

@@ -6,9 +6,8 @@ import {
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
import { captureEnv } from "../test-utils/env.js";
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
import { setTempStateDir } from "./skills-install.download-test-utils.js";
import { installSkill } from "./skills-install.js";
import {
runCommandWithTimeoutMock,
@@ -47,25 +46,28 @@ metadata: {"openclaw":{"install":[{"id":"deps","kind":"node","package":"example-
}
const workspaceSuite = createFixtureSuite("openclaw-skills-install-");
let tempHome: TempHomeEnv;
beforeAll(async () => {
tempHome = await createTempHomeEnv("openclaw-skills-install-home-");
await workspaceSuite.setup();
});
afterAll(async () => {
resetGlobalHookRunner();
await workspaceSuite.cleanup();
await tempHome.restore();
});
async function withWorkspaceCase(
run: (params: { workspaceDir: string; stateDir: string }) => Promise<void>,
): Promise<void> {
const workspaceDir = await workspaceSuite.createCaseDir("case");
const stateDir = setTempStateDir(workspaceDir);
await run({ workspaceDir, stateDir });
const stateDir = path.join(workspaceDir, "state");
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
try {
process.env.OPENCLAW_STATE_DIR = stateDir;
await run({ workspaceDir, stateDir });
} finally {
envSnapshot.restore();
}
}
describe("installSkill code safety scanning", () => {

View File

@@ -2,13 +2,13 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildWorkspaceSkillsPrompt } from "./skills.js";
import { writeSkill } from "./skills.test-helpers.js";
import {
restoreMockSkillsHomeEnv,
setMockSkillsHomeEnv,
type SkillsHomeEnvSnapshot,
} from "./skills/home-env.test-support.js";
import { buildWorkspaceSkillsPrompt } from "./skills/workspace.js";
vi.mock("./skills/plugin-skills.js", () => ({
resolvePluginSkillDirs: () => [],

View File

@@ -2,36 +2,47 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillsPrompt } from "./skills.js";
import { buildWorkspaceSkillsPrompt } from "./skills/workspace.js";
describe("buildWorkspaceSkillsPrompt", () => {
it("applies bundled allowlist without affecting workspace skills", async () => {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-"));
const bundledDir = path.join(workspaceDir, ".bundled");
const bundledSkillDir = path.join(bundledDir, "peekaboo");
const workspaceSkillDir = path.join(workspaceDir, "skills", "demo-skill");
try {
process.env.HOME = workspaceDir;
process.env.USERPROFILE = workspaceDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
const bundledDir = path.join(workspaceDir, ".bundled");
const bundledSkillDir = path.join(bundledDir, "peekaboo");
const workspaceSkillDir = path.join(workspaceDir, "skills", "demo-skill");
await writeSkill({
dir: bundledSkillDir,
name: "peekaboo",
description: "Capture UI",
body: "# Peekaboo\n",
});
await writeSkill({
dir: workspaceSkillDir,
name: "demo-skill",
description: "Workspace version",
body: "# Workspace\n",
});
await writeSkill({
dir: bundledSkillDir,
name: "peekaboo",
description: "Capture UI",
body: "# Peekaboo\n",
});
await writeSkill({
dir: workspaceSkillDir,
name: "demo-skill",
description: "Workspace version",
body: "# Workspace\n",
});
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
bundledSkillsDir: bundledDir,
managedSkillsDir: path.join(workspaceDir, ".managed"),
config: { skills: { allowBundled: ["missing-skill"] } },
});
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
bundledSkillsDir: bundledDir,
managedSkillsDir: path.join(workspaceDir, ".managed"),
config: { skills: { allowBundled: ["missing-skill"] } },
});
expect(prompt).toContain("Workspace version");
expect(prompt).not.toContain("peekaboo");
expect(prompt).toContain("Workspace version");
expect(prompt).not.toContain("peekaboo");
} finally {
env.restore();
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
});

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillsPrompt, syncSkillsToWorkspace } from "./skills.js";
import { buildWorkspaceSkillsPrompt, syncSkillsToWorkspace } from "./skills/workspace.js";
vi.mock("./skills/plugin-skills.js", () => ({
resolvePluginSkillDirs: () => [],
@@ -30,14 +30,12 @@ async function createCaseDir(prefix: string): Promise<string> {
}
async function syncSourceSkillsToTarget(sourceWorkspace: string, targetWorkspace: string) {
await withEnv({ HOME: sourceWorkspace, PATH: "" }, () =>
syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
managedSkillsDir: path.join(sourceWorkspace, ".managed"),
}),
);
await syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
managedSkillsDir: path.join(sourceWorkspace, ".managed"),
});
}
async function expectSyncedSkillConfinement(params: {
@@ -89,8 +87,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
const buildPrompt = (
workspaceDir: string,
opts?: Parameters<typeof buildWorkspaceSkillsPrompt>[1],
) =>
withEnv({ HOME: workspaceDir, PATH: "" }, () => buildWorkspaceSkillsPrompt(workspaceDir, opts));
) => withEnv({ HOME: workspaceDir }, () => buildWorkspaceSkillsPrompt(workspaceDir, opts));
const cloneSourceTemplate = async () => {
const sourceWorkspace = await createCaseDir("source");
@@ -114,15 +111,13 @@ describe("buildWorkspaceSkillsPrompt", () => {
"export {}",
);
await withEnv({ HOME: sourceWorkspace, PATH: "" }, () =>
syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
config: { skills: { load: { extraDirs: [extraDir] } } },
bundledSkillsDir: bundledDir,
managedSkillsDir: managedDir,
}),
);
await syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
config: { skills: { load: { extraDirs: [extraDir] } } },
bundledSkillsDir: bundledDir,
managedSkillsDir: managedDir,
});
const prompt = buildPrompt(targetWorkspace, {
bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
@@ -156,23 +151,21 @@ describe("buildWorkspaceSkillsPrompt", () => {
description: "Dot variant",
});
await withEnv({ HOME: sourceWorkspace, PATH: "" }, () =>
syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
agentId: "alpha",
config: {
agents: {
defaults: {
skills: ["foo_bar", "foo.dot"],
},
list: [{ id: "alpha", skills: ["foo_bar"] }],
await syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
agentId: "alpha",
config: {
agents: {
defaults: {
skills: ["foo_bar", "foo.dot"],
},
list: [{ id: "alpha", skills: ["foo_bar"] }],
},
bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
managedSkillsDir: path.join(sourceWorkspace, ".managed"),
}),
);
},
bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
managedSkillsDir: path.join(sourceWorkspace, ".managed"),
});
const prompt = buildPrompt(targetWorkspace, {
bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
@@ -329,31 +322,29 @@ describe("buildWorkspaceSkillsPrompt", () => {
metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}',
});
await withEnv({ HOME: sourceWorkspace, PATH: "" }, () =>
syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
agentId: "alpha",
config: {
agents: {
defaults: {
skills: ["remote-only"],
},
list: [{ id: "alpha" }],
await syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
agentId: "alpha",
config: {
agents: {
defaults: {
skills: ["remote-only"],
},
list: [{ id: "alpha" }],
},
eligibility: {
remote: {
platforms: ["linux"],
hasBin: () => false,
hasAnyBin: (bins: string[]) => bins.includes("sandboxbin"),
note: "sandbox",
},
},
eligibility: {
remote: {
platforms: ["linux"],
hasBin: () => false,
hasAnyBin: (bins: string[]) => bins.includes("sandboxbin"),
note: "sandbox",
},
bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
managedSkillsDir: path.join(sourceWorkspace, ".managed"),
}),
);
},
bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
managedSkillsDir: path.join(sourceWorkspace, ".managed"),
});
expect(await pathExists(path.join(targetWorkspace, "skills", "remote-only", "SKILL.md"))).toBe(
true,

View File

@@ -5,12 +5,12 @@ import { withPathResolutionEnv } from "../test-utils/env.js";
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills.js";
import {
restoreMockSkillsHomeEnv,
setMockSkillsHomeEnv,
type SkillsHomeEnvSnapshot,
} from "./skills/home-env.test-support.js";
import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills/workspace.js";
vi.mock("./skills/plugin-skills.js", () => ({
resolvePluginSkillDirs: () => [],

View File

@@ -1,8 +1,8 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildWorkspaceSkillsPrompt } from "./skills.js";
import { createCanonicalFixtureSkill } from "./skills.test-helpers.js";
import { buildWorkspaceSkillsPrompt } from "./skills/workspace.js";
describe("compactSkillPaths", () => {
it("replaces home directory prefix with ~ in skill locations", () => {

View File

@@ -6,13 +6,13 @@ import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { withPathResolutionEnv } from "../test-utils/env.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { loadWorkspaceSkillEntries } from "./skills.js";
import {
restoreMockSkillsHomeEnv,
setMockSkillsHomeEnv,
type SkillsHomeEnvSnapshot,
} from "./skills/home-env.test-support.js";
import { readSkillFrontmatterSafe } from "./skills/local-loader.js";
import { loadWorkspaceSkillEntries } from "./skills/workspace.js";
import { writePluginWithSkill } from "./test-helpers/skill-plugin-fixtures.js";
const tempDirs: string[] = [];

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveSkillsPromptForRun } from "./skills.js";
import { createCanonicalFixtureSkill } from "./skills.test-helpers.js";
import type { SkillEntry } from "./skills/types.js";
import { resolveSkillsPromptForRun } from "./skills/workspace.js";
describe("resolveSkillsPromptForRun", () => {
it("prefers snapshot prompt when available", () => {

View File

@@ -12,20 +12,19 @@ import { withPathResolutionEnv } from "../test-utils/env.js";
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillCommandSpecs } from "./skills/command-specs.js";
import {
applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot,
buildWorkspaceSkillCommandSpecs,
buildWorkspaceSkillsPrompt,
type SkillEntry,
type SkillSnapshot,
} from "./skills.js";
import { getActiveSkillEnvKeys } from "./skills/env-overrides.js";
getActiveSkillEnvKeys,
} from "./skills/env-overrides.js";
import {
restoreMockSkillsHomeEnv,
setMockSkillsHomeEnv,
type SkillsHomeEnvSnapshot,
} from "./skills/home-env.test-support.js";
import type { SkillEntry, SkillSnapshot } from "./skills/types.js";
import { buildWorkspaceSkillsPrompt } from "./skills/workspace.js";
vi.mock("./skills/plugin-skills.js", () => ({
resolvePluginSkillDirs: () => [],