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"); });