290 lines
9.5 KiB
JavaScript
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");
|
|
});
|