Files
boss/tests/local-agent-skill-lifecycle-runner.test.mjs

290 lines
9.5 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import {
executeSkillLifecycleRequest,
getSkillLifecycleRunnerConfig,
slugifySkillName,
} from "../local-agent/skill-lifecycle-runner.mjs";
const execFileAsync = promisify(execFile);
async function git(args, cwd) {
await execFileAsync("git", args, { cwd });
}
async function createGitSkillRepo(tmp, name = "remote-skill") {
const repo = path.join(tmp, `${name}-repo`);
await mkdir(repo, { recursive: true });
await git(["init"], repo);
await git(["config", "user.email", "boss-tests@example.com"], repo);
await git(["config", "user.name", "Boss Tests"], repo);
await writeFile(path.join(repo, "SKILL.md"), "---\ndescription: old\n---\nold\n", "utf8");
await git(["add", "SKILL.md"], repo);
await git(["commit", "-m", "old"], repo);
const oldCommit = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repo })).stdout.trim();
await writeFile(path.join(repo, "SKILL.md"), "---\ndescription: new\n---\nnew\n", "utf8");
await git(["add", "SKILL.md"], repo);
await git(["commit", "-m", "new"], repo);
const newCommit = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repo })).stdout.trim();
return { repo, oldCommit, newCommit };
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
test("skill lifecycle runner derives enabled config from local-agent config", () => {
const config = getSkillLifecycleRunnerConfig({}, {
skillLifecycleEnabled: true,
skillsDir: "/tmp/boss-skills",
skillLifecycleTimeoutMs: 1234,
skillLifecycleAllowedSources: ["https://example.com/boss-skills/"],
});
assert.equal(config.enabled, true);
assert.equal(config.skillsDir, "/tmp/boss-skills");
assert.equal(config.timeoutMs, 1234);
assert.deepEqual(config.allowedSources, ["https://example.com/boss-skills/"]);
});
test("skill lifecycle runner writes version lock file", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-lock-"));
try {
const result = await executeSkillLifecycleRequest(
{
requestId: "request-lock",
action: "version_lock",
status: "running",
deviceId: "mac-studio",
skillId: "mac-studio:demo-skill",
lockedVersion: "1.2.3",
},
{
skillsDir: tmp,
skillLifecycleEnabled: true,
},
{ lastSkills: [] },
);
assert.equal(result.status, "completed");
const locks = JSON.parse(await readFile(path.join(tmp, ".boss-skill-locks.json"), "utf8"));
assert.equal(locks["mac-studio:demo-skill"].lockedVersion, "1.2.3");
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
test("skill lifecycle runner uninstalls only skills inside the configured skills directory", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-uninstall-"));
const skillDir = path.join(tmp, "demo-skill");
await mkdir(skillDir, { recursive: true });
await writeFile(path.join(skillDir, "SKILL.md"), "---\ndescription: demo\n---\n", "utf8");
try {
const result = await executeSkillLifecycleRequest(
{
requestId: "request-uninstall",
action: "uninstall",
status: "running",
deviceId: "mac-studio",
skillId: "mac-studio:demo-skill",
},
{
skillsDir: tmp,
skillLifecycleEnabled: true,
},
{
lastSkills: [
{
name: "demo-skill",
path: path.join(skillDir, "SKILL.md"),
},
],
},
);
assert.equal(result.status, "completed");
await assert.rejects(() => readFile(path.join(skillDir, "SKILL.md"), "utf8"));
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
test("skill lifecycle runner rejects deleting a skill path outside skillsDir", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-safe-"));
const outside = await mkdtemp(path.join(os.tmpdir(), "boss-skill-outside-"));
await writeFile(path.join(outside, "SKILL.md"), "---\ndescription: outside\n---\n", "utf8");
try {
const result = await executeSkillLifecycleRequest(
{
requestId: "request-uninstall-outside",
action: "uninstall",
status: "running",
deviceId: "mac-studio",
skillId: "mac-studio:outside-skill",
},
{
skillsDir: tmp,
skillLifecycleEnabled: true,
},
{
lastSkills: [
{
name: "outside-skill",
path: path.join(outside, "SKILL.md"),
},
],
},
);
assert.equal(result.status, "failed");
assert.equal(result.error, "SKILL_PATH_OUTSIDE_SKILLS_DIR");
assert.equal(await readFile(path.join(outside, "SKILL.md"), "utf8"), "---\ndescription: outside\n---\n");
} finally {
await rm(tmp, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
}
});
test("skill lifecycle runner rejects install sourceUrl when no source allowlist or trusted source is configured", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-install-source-"));
const { repo } = await createGitSkillRepo(tmp, "blocked-skill");
const skillsDir = path.join(tmp, "skills");
try {
const result = await executeSkillLifecycleRequest(
{
requestId: "request-install-blocked-source",
action: "install",
status: "running",
deviceId: "mac-studio",
sourceUrl: repo,
},
{
skillsDir,
skillLifecycleEnabled: true,
},
{ lastSkills: [] },
);
assert.equal(result.status, "failed");
assert.equal(result.error, "SKILL_SOURCE_NOT_ALLOWED");
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
test("skill lifecycle runner rejects source urls that only share an allowlist prefix", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-install-prefix-"));
const { repo } = await createGitSkillRepo(tmp, "trusted-evil");
const skillsDir = path.join(tmp, "skills");
try {
const result = await executeSkillLifecycleRequest(
{
requestId: "request-install-prefix-bypass",
action: "install",
status: "running",
deviceId: "mac-studio",
sourceUrl: repo,
},
{
skillsDir,
skillLifecycleEnabled: true,
skillLifecycleAllowedSources: [path.join(tmp, "trusted")],
},
{ lastSkills: [] },
);
assert.equal(result.status, "failed");
assert.equal(result.error, "SKILL_SOURCE_NOT_ALLOWED");
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
test("skill lifecycle runner removes a newly cloned skill when checksum verification fails", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-checksum-install-"));
const { repo } = await createGitSkillRepo(tmp, "checksum-skill");
const skillsDir = path.join(tmp, "skills");
try {
const result = await executeSkillLifecycleRequest(
{
requestId: "request-install-bad-checksum",
action: "install",
status: "running",
deviceId: "mac-studio",
sourceUrl: repo,
expectedChecksum: sha256("not the installed skill"),
},
{
skillsDir,
skillLifecycleEnabled: true,
skillLifecycleAllowedSources: [repo],
},
{ lastSkills: [] },
);
assert.equal(result.status, "failed");
assert.equal(result.error, "SKILL_CHECKSUM_MISMATCH");
await assert.rejects(() => readFile(path.join(skillsDir, "remote", "checksum-skill-repo", "SKILL.md"), "utf8"));
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
test("skill lifecycle runner backs up and restores an existing skill when update checksum verification fails", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-checksum-update-"));
const { repo, oldCommit } = await createGitSkillRepo(tmp, "update-skill");
const skillsDir = path.join(tmp, "skills");
const skillDir = path.join(skillsDir, "update-skill");
await mkdir(skillsDir, { recursive: true });
await git(["clone", repo, skillDir], tmp);
await git(["reset", "--hard", oldCommit], skillDir);
try {
const result = await executeSkillLifecycleRequest(
{
requestId: "request-update-bad-checksum",
action: "update",
status: "running",
deviceId: "mac-studio",
skillId: "mac-studio:update-skill",
expectedChecksum: sha256("wrong expected skill"),
},
{
skillsDir,
skillLifecycleEnabled: true,
},
{
lastSkills: [
{
name: "update-skill",
path: path.join(skillDir, "SKILL.md"),
},
],
},
);
assert.equal(result.status, "failed");
assert.equal(result.error, "SKILL_CHECKSUM_MISMATCH");
assert.equal(await readFile(path.join(skillDir, "SKILL.md"), "utf8"), "---\ndescription: old\n---\nold\n");
const backups = await readdir(path.join(skillsDir, ".boss-skill-backups"));
assert.equal(backups.length, 1);
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
test("skill lifecycle slug matches server skill id convention", () => {
assert.equal(slugifySkillName("Boss Server Debug"), "boss-server-debug");
});