Files
openclaw/src/agents/skills-install-fallback.test.ts
Peter Steinberger 4f4d108639 chore(lint): remove underscore-dangle allow list (#83542)
* chore(lint): reduce underscore-dangle exceptions

* chore(lint): reduce more underscore exceptions

* chore(lint): remove underscore-dangle allow list

* fix(lint): repair underscore cleanup regressions

* test(lint): track version define suppression
2026-05-18 14:56:06 +01:00

316 lines
10 KiB
TypeScript

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 { captureEnv } from "../test-utils/env.js";
import { hasBinaryMock, runCommandWithTimeoutMock } from "./skills-install.test-mocks.js";
import type { SkillEntry, SkillInstallSpec } from "./skills.js";
const skillsMocks = vi.hoisted(() => ({
loadWorkspaceSkillEntries: vi.fn(),
}));
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
vi.mock("../plugins/install-security-scan.js", () => ({
scanSkillInstallSource: vi.fn(async () => undefined),
}));
vi.mock("./skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./skills.js")>();
return {
...actual,
loadWorkspaceSkillEntries: skillsMocks.loadWorkspaceSkillEntries,
};
});
let installSkill: typeof import("./skills-install.js").installSkill;
let skillsInstallTesting: typeof import("./skills-install.js").testing;
async function loadSkillsInstallModulesForTest() {
({ installSkill, testing: skillsInstallTesting } = await import("./skills-install.js"));
}
function makeSkillEntry(
workspaceDir: string,
name: string,
installSpec: SkillInstallSpec,
): SkillEntry {
const skillDir = path.join(workspaceDir, "skills", name);
return {
skill: {
name,
description: "test skill",
filePath: path.join(skillDir, "SKILL.md"),
baseDir: skillDir,
source: "openclaw-workspace",
} as SkillEntry["skill"],
frontmatter: {},
metadata: {
install: [{ id: "deps", ...installSpec }],
},
};
}
function mockAvailableBinaries(binaries: string[]) {
const available = new Set(binaries);
hasBinaryMock.mockImplementation((bin: string) => available.has(bin));
}
function assertNoAptGetFallbackCalls() {
const aptCalls = runCommandWithTimeoutMock.mock.calls.filter(
(call) => Array.isArray(call[0]) && (call[0] as string[]).includes("apt-get"),
);
expect(aptCalls).toHaveLength(0);
}
function commandCallAt(
index: number,
): [
string[],
{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>; timeoutMs?: number },
] {
const call =
index < 0
? runCommandWithTimeoutMock.mock.calls[runCommandWithTimeoutMock.mock.calls.length + index]
: runCommandWithTimeoutMock.mock.calls[index];
if (!call) {
throw new Error(`Expected command call ${index}`);
}
return call as [
string[],
{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>; timeoutMs?: number },
];
}
describe("skills-install fallback edge cases", () => {
let workspaceDir: string;
beforeAll(async () => {
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-test-"));
skillsMocks.loadWorkspaceSkillEntries.mockReturnValue([
makeSkillEntry(workspaceDir, "go-tool-single", {
kind: "go",
module: "example.com/tool@latest",
}),
makeSkillEntry(workspaceDir, "py-tool", {
kind: "uv",
package: "example-package",
}),
]);
await loadSkillsInstallModulesForTest();
});
beforeEach(() => {
runCommandWithTimeoutMock.mockReset();
hasBinaryMock.mockReset();
skillsInstallTesting.setDepsForTest({
hasBinary: (bin: string) => hasBinaryMock(bin),
resolveBrewExecutable: () => undefined,
isContainerEnvironment: () => false,
});
});
afterAll(async () => {
skillsInstallTesting.setDepsForTest();
await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined);
});
it("handles sudo probe failures for go install without apt fallback", async () => {
vi.spyOn(process, "getuid").mockReturnValue(1000);
for (const testCase of [
{
label: "sudo returns password required",
setup: () =>
runCommandWithTimeoutMock.mockResolvedValueOnce({
code: 1,
stdout: "",
stderr: "sudo: a password is required",
}),
assert: (result: { message: string; stderr: string }) => {
expect(result.message).toContain("sudo is not usable");
expect(result.message).toContain("https://go.dev/doc/install");
expect(result.stderr).toContain("sudo: a password is required");
},
},
{
label: "sudo probe throws executable-not-found",
setup: () =>
runCommandWithTimeoutMock.mockRejectedValueOnce(
new Error('Executable not found in $PATH: "sudo"'),
),
assert: (result: { message: string; stderr: string }) => {
expect(result.message).toContain("sudo is not usable");
expect(result.message).toContain("https://go.dev/doc/install");
expect(result.stderr).toContain("Executable not found");
},
},
]) {
runCommandWithTimeoutMock.mockClear();
mockAvailableBinaries(["apt-get", "sudo"]);
testCase.setup();
const result = await installSkill({
workspaceDir,
skillName: "go-tool-single",
installId: "deps",
});
expect(result.ok, testCase.label).toBe(false);
testCase.assert(result);
const sudoCall = commandCallAt(0);
expect(sudoCall?.[0], testCase.label).toEqual(["sudo", "-n", "true"]);
expect(sudoCall?.[1]?.timeoutMs, testCase.label).toBe(5_000);
assertNoAptGetFallbackCalls();
}
});
it("uv not installed and no brew returns helpful error without curl auto-install", async () => {
mockAvailableBinaries(["curl"]);
const result = await installSkill({
workspaceDir,
skillName: "py-tool",
installId: "deps",
});
expect(result.ok).toBe(false);
expect(result.message).toContain("https://docs.astral.sh/uv/getting-started/installation/");
// Verify NO curl command was attempted (no auto-install)
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
});
it("returns container-specific guidance when brew is missing in a Linux container", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
skillsInstallTesting.setDepsForTest({
hasBinary: (bin: string) => hasBinaryMock(bin),
resolveBrewExecutable: () => undefined,
isContainerEnvironment: () => true,
});
mockAvailableBinaries([]);
try {
skillsMocks.loadWorkspaceSkillEntries.mockReturnValueOnce([
makeSkillEntry(workspaceDir, "brew-tool-container", {
kind: "brew",
formula: "openai-whisper",
}),
]);
const result = await installSkill({
workspaceDir,
skillName: "brew-tool-container",
installId: "deps",
});
expect(result.ok).toBe(false);
expect(result.message).toContain("Linux container");
expect(result.message).toContain("Build a custom image");
expect(result.message).toContain("openai-whisper");
expect(result.message).not.toContain("https://brew.sh");
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
});
it("does not use HOMEBREW_PREFIX as a brew bin fallback for go installs", async () => {
const envSnapshot = captureEnv(["HOMEBREW_PREFIX"]);
try {
const maliciousPrefix = path.join(workspaceDir, "evil-brew");
process.env.HOMEBREW_PREFIX = maliciousPrefix;
mockAvailableBinaries([]);
skillsInstallTesting.setDepsForTest({
hasBinary: (bin: string) => hasBinaryMock(bin),
resolveBrewExecutable: () => "/safe/homebrew/bin/brew",
});
runCommandWithTimeoutMock.mockResolvedValue({
code: 0,
stdout: "ok",
stderr: "",
signal: null,
killed: false,
});
runCommandWithTimeoutMock.mockResolvedValueOnce({
code: 0,
stdout: "installed go",
stderr: "",
signal: null,
killed: false,
});
runCommandWithTimeoutMock.mockResolvedValueOnce({
code: 1,
stdout: "",
stderr: "prefix unavailable",
signal: null,
killed: false,
});
const result = await installSkill({
workspaceDir,
skillName: "go-tool-single",
installId: "deps",
});
expect(result.ok).toBe(true);
const brewInstallCall = commandCallAt(0);
const brewPrefixCall = commandCallAt(1);
expect(brewInstallCall?.[0]).toEqual(["/safe/homebrew/bin/brew", "install", "go"]);
expect(brewInstallCall?.[1]?.timeoutMs).toBe(300_000);
expect(brewPrefixCall?.[0]).toEqual(["/safe/homebrew/bin/brew", "--prefix"]);
expect(brewPrefixCall?.[1]?.timeoutMs).toBe(30_000);
const finalCall = commandCallAt(-1);
expect(finalCall?.[0]).toEqual(["go", "install", "example.com/tool@latest"]);
expect(finalCall?.[1]?.env?.GOBIN).not.toBe(path.join(maliciousPrefix, "bin"));
} finally {
envSnapshot.restore();
}
});
it("preserves system uv/python env vars when running uv installs", async () => {
mockAvailableBinaries(["uv"]);
runCommandWithTimeoutMock.mockResolvedValueOnce({
code: 0,
stdout: "ok",
stderr: "",
signal: null,
killed: false,
});
const envSnapshot = captureEnv([
"UV_PYTHON",
"UV_INDEX_URL",
"PIP_INDEX_URL",
"PYTHONPATH",
"VIRTUAL_ENV",
]);
try {
process.env.UV_PYTHON = "/tmp/attacker-python";
process.env.UV_INDEX_URL = "https://example.invalid/simple";
process.env.PIP_INDEX_URL = "https://example.invalid/pip";
process.env.PYTHONPATH = "/tmp/attacker-pythonpath";
process.env.VIRTUAL_ENV = "/tmp/attacker-venv";
const result = await installSkill({
workspaceDir,
skillName: "py-tool",
installId: "deps",
timeoutMs: 10_000,
});
expect(result.ok).toBe(true);
const firstCall = commandCallAt(0);
expect(firstCall?.[0]).toEqual(["uv", "tool", "install", "example-package"]);
expect(firstCall?.[1]?.timeoutMs).toBe(10_000);
const envArg = firstCall?.[1]?.env;
expect(envArg).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
});