feat(daemon): slice 28 Checkpoint C-3 — skill-library API (closes HG-5 root cause on VM)

QA BLOCKING verdict (qitem-20260513045711-39ccfdf3) on slice 28 C-2
diagnosed HG-5 not operationally closed on founder-walk VM: the
shared-skills tree on disk at /Users/admin/code/openrig/packages/
daemon/specs/agents/shared/skills (32 SKILL.md files) is NOT under
the operator's OPENRIG_FILES_ALLOWLIST workspace root. Therefore the
prior useLibrarySkills 3-path probe over /api/files/list could not
reach it, and /specs/skills showed 'No skills visible'.

Orch authorized OPT-Y2 (qitem-20260513042155-f31c11c3-followon... actually
qitem-20260513045940-b215d1d2 routing decision): new daemon skill-
library API symmetric with the plugin pattern shipped in C-1
(c44a69d). Daemon owns shared-skills discovery via its own install
location; UI consumes a single canonical endpoint.

SC-29 EXCEPTION #11 cumulative scope (verbatim block at
packages/daemon/src/routes/plugins.ts header references this skill
extension):
  Slice 28 daemon surface =
    3 plugin file endpoints (C-1) + PluginEntry.skillCount + 3 skill
    endpoints (this commit) + new LibrarySkill shape.

DAEMON CHANGES:

packages/daemon/src/domain/skill-library-discovery.ts (NEW):
- SkillLibraryDiscoveryService with listLibrarySkills / getSkill /
  listLibrarySkillsPublic methods.
- Discovery sources: sharedSkillsDir (daemon install-relative,
  resolved by startup via import.meta.url) + workspace allowlist
  roots probing `.openrig/skills/`.
- Recursive walker (MAX_NESTING_DEPTH=1) handles flat + category-
  nested skill layouts (mirrors the original slice 28.B fix shape;
  now executed daemon-side instead of via UI multi-fetch).
- Public response shape strips absolutePath; routes use it
  internally for the per-skill file endpoints.

packages/daemon/src/routes/skills.ts (NEW):
- GET /library                          → LibrarySkillPublic[]
- GET /:id/files/list?path=<rel>        → file listing (dir-then-file
                                          sort; all file types)
- GET /:id/files/read?path=<rel>        → content + FILE_READ_TRUNCATION_BYTES
                                          cap from files.ts
- skillRootAllowlist helper builds synthetic single-root allowlist
  scoped to skill.absolutePath; same pattern as pluginRootAllowlist
  in plugins.ts (C-1). fs.realpathSync normalization handles
  macOS /var/folders → /private/var/folders symlink chain.
- pathSafetyErrorResponse mirror of files.ts error→status mapping.

packages/daemon/src/server.ts:
- AppDeps adds optional skillLibraryDiscoveryService.
- Context-injection in route-middleware.
- /api/skills router mounted alongside /api/plugins.

packages/daemon/src/startup.ts:
- SkillLibraryDiscoveryService constructed AFTER decodeAllowlist so
  filesAllowlist is passed in. sharedSkillsDir resolves to
  `<daemon-src>/specs/agents/shared/skills` via import.meta.url
  pattern matching bootKernelIfNeeded specsDir resolution.

TESTS:

packages/daemon/test/skill-library-routes.test.ts (NEW; 20 tests):
- Discovery (8): flat depth-1 + nested depth-2 HG-5 fix + mixed
  layout + depth-cap (MAX_NESTING_DEPTH=1) + workspace + consolidation
  (workspace + managed) + absent shared dir + public shape strips
  absolutePath.
- GET /library (2): consolidated list response + 503 unprovisioned.
- GET /:id/files/list (5): root + nested + 404 unknown + .. escape
  + symlink escape.
- GET /:id/files/read (5): SKILL.md + nested file + path_required +
  404 stat_failed + .. escape.

Pre-existing test failures (orthogonal to slice 28):
- test/precompact-hook.test.ts HG-7 OPENRIG_HOME expansion
- test/restore-check-routes.test.ts hook-cwd test — verified failing
  on pristine main via checkout-and-test cycle (host's
  ~/.claude/settings.json is empty / malformed; not caused by C-3
  per banked feedback_verify_release_state_against_runtime_not_
  breadcrumb).

Daemon full suite: 3603 pass + 1 skipped + 2 pre-existing fails (was
3584/1/1 at C-1; +19 passes from C-3 tests; +1 fail count
re-verified pre-existing via on-main checkout test).

Following C-4 UI migration: useLibrarySkills + SkillDetailPage
migrate to consume the new endpoints (single fetch instead of
3-path probe; per-skill file endpoints instead of /api/files/).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-12 22:08:37 -07:00
1 parent 1c605f9109
commit 11c2162862
5 files changed
+708

No files matched your search

@@ -0,0 +1,217 @@
// Slice 28 Checkpoint C-3 — SkillLibraryDiscoveryService.
//
// SC-29 EXCEPTION #11 (slice 28 library-explorer-finishing) — cumulative scope.
// C-1 (plugin endpoints) declared #11 verbatim at routes/plugins.ts header.
// This file extends the slice 28 surface with skill-library discovery to
// close HG-5 / HG-7 / HG-8 / HG-9 cascade on the founder-walk VM (QA verdict
// qitem-20260513045711-39ccfdf3): the operator's allowlist does not include
// the daemon's source tree, so the prior /api/files-based useLibrarySkills
// 3-path probe could not reach `packages/daemon/specs/agents/shared/skills`.
//
// Daemon-owned discovery: resolves shared-skills directory via the daemon's
// install location (import.meta.url + ../specs/agents/shared/skills) +
// optional workspace allowlist roots for user-defined skills at
// `.openrig/skills/`. UI consumes /api/skills/library directly (1 fetch
// replaces N-fetch probe). Symmetric with the plugin pattern from C-1.
import { existsSync, readdirSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import type { AllowlistRoot } from "./files/path-safety.js";
export type LibrarySkillSource = "workspace" | "openrig-managed";
export interface LibrarySkillFile {
/** Filename only (no directory prefix). */
name: string;
/** Path relative to the skill folder root (e.g., "SKILL.md" or "examples/basic.md"). */
path: string;
/** File size in bytes. */
size: number;
/** Last-modified ISO timestamp. */
mtime: string;
}
export interface LibrarySkill {
/**
* Stable id including source + relative path within the source tree.
* Examples:
* "openrig-managed:claude-compact-in-place" (flat skill at shared root)
* "openrig-managed:core/openrig-user" (nested skill inside category)
* "workspace:.openrig/skills/operator-skill" (workspace skill)
* Stable across daemon restarts as long as the disk layout is unchanged.
*/
id: string;
/** Leaf skill folder name (e.g., "openrig-user"). */
name: string;
source: LibrarySkillSource;
/** Top-level markdown files in the skill folder (SKILL.md first if present). */
files: LibrarySkillFile[];
/** Internal: absolute filesystem path to the skill folder. NOT serialized
* in the public API response. The routes layer uses this for the
* /api/skills/:id/files/{list,read} endpoints. */
absolutePath: string;
}
/** Public response shape (omits absolutePath). */
export type LibrarySkillPublic = Omit<LibrarySkill, "absolutePath">;
export interface SkillLibraryDiscoveryOpts {
/**
* Absolute path to the daemon-installed shared-skills directory
* (typically `<daemon-install>/specs/agents/shared/skills`).
* Resolved by startup.ts via import.meta.url + relative resolve.
*/
sharedSkillsDir: string;
/**
* Workspace allowlist roots (operator-declared via OPENRIG_FILES_ALLOWLIST).
* Workspace skills live at `<allowlist-root>/.openrig/skills/<skill-name>/`.
* Empty array → no workspace-source skills discovered (managed-only mode).
*/
filesAllowlist: AllowlistRoot[];
}
const MAX_NESTING_DEPTH = 1;
function isDir(absolutePath: string): boolean {
try {
return statSync(absolutePath).isDirectory();
} catch {
return false;
}
}
function safeReaddir(absolutePath: string): string[] {
try {
return readdirSync(absolutePath);
} catch {
return [];
}
}
function collectMarkdownFiles(absoluteDir: string): LibrarySkillFile[] {
const entries: LibrarySkillFile[] = [];
for (const name of safeReaddir(absoluteDir)) {
if (!/\.(md|mdx)$/i.test(name)) continue;
const fullPath = join(absoluteDir, name);
try {
const stat = statSync(fullPath);
if (!stat.isFile()) continue;
entries.push({
name,
path: name,
size: stat.size,
mtime: stat.mtime.toISOString(),
});
} catch {
continue;
}
}
return entries.sort((a, b) => {
if (a.name.toLowerCase() === "skill.md") return -1;
if (b.name.toLowerCase() === "skill.md") return 1;
return a.name.localeCompare(b.name);
});
}
/**
* Recursive skill discovery: walks a base directory and emits a LibrarySkill
* for every subdirectory that contains markdown files. Categories
* (subdirs without markdown but containing more subdirs) recurse one level
* up to MAX_NESTING_DEPTH (matches the on-disk shape of the shared-skills
* tree: flat skill folders + one level of category nesting like core/,
* pm/, pods/, process/).
*
* `idPrefix` is prepended to each emitted skill id (e.g., "openrig-managed:");
* `relativeId` accumulates the path-within-source for stable id construction.
*/
function collectSkillsRecursive(
absoluteDir: string,
source: LibrarySkillSource,
idPrefix: string,
relativeId: string,
depth: number,
): LibrarySkill[] {
const out: LibrarySkill[] = [];
for (const childName of safeReaddir(absoluteDir)) {
const childAbsolute = join(absoluteDir, childName);
if (!isDir(childAbsolute)) continue;
const childRelativeId = relativeId ? `${relativeId}/${childName}` : childName;
const files = collectMarkdownFiles(childAbsolute);
if (files.length > 0) {
// Leaf — this dir is a skill folder.
out.push({
id: `${idPrefix}${childRelativeId}`,
name: childName,
source,
files,
absolutePath: childAbsolute,
});
} else if (depth < MAX_NESTING_DEPTH) {
// Category — recurse one level deeper.
out.push(...collectSkillsRecursive(childAbsolute, source, idPrefix, childRelativeId, depth + 1));
}
// else: empty leaf (no markdown, can't recurse further) — skip.
}
return out;
}
export class SkillLibraryDiscoveryService {
constructor(private readonly opts: SkillLibraryDiscoveryOpts) {}
/**
* Returns the consolidated list of OpenRig-managed + workspace skills.
* Sources scanned:
* - opts.sharedSkillsDir (daemon-known absolute path; openrig-managed)
* - <opts.filesAllowlist root>/.openrig/skills/ for each root (workspace)
* Dedupe rule: managed wins over workspace when (source-key-collision).
* v0 collision shape is unusual; defensive only.
*/
listLibrarySkills(): LibrarySkill[] {
const all: LibrarySkill[] = [];
if (existsSync(this.opts.sharedSkillsDir)) {
all.push(
...collectSkillsRecursive(
this.opts.sharedSkillsDir,
"openrig-managed",
"openrig-managed:",
"",
0,
),
);
}
for (const root of this.opts.filesAllowlist) {
const workspaceSkillsDir = join(root.canonicalPath, ".openrig", "skills");
if (!existsSync(workspaceSkillsDir)) continue;
// Workspace skills are identified by .openrig/skills/<name>/ paths.
// Skills under workspace allowlist roots are scanned at depth-0 only
// (workspace skill folders historically live flat, no category
// structure — but we recurse for consistency).
all.push(
...collectSkillsRecursive(
workspaceSkillsDir,
"workspace",
`workspace:${root.name}:`,
"",
0,
),
);
}
return all.sort((a, b) => {
if (a.source !== b.source) return a.source === "workspace" ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
/** Lookup a single skill by its stable id. */
getSkill(id: string): LibrarySkill | null {
return this.listLibrarySkills().find((s) => s.id === id) ?? null;
}
/** Public-facing list (strips absolutePath from each entry). */
listLibrarySkillsPublic(): LibrarySkillPublic[] {
return this.listLibrarySkills().map(({ absolutePath: _absolutePath, ...rest }) => rest);
}
}
+152
View File
@@ -0,0 +1,152 @@
// Slice 28 Checkpoint C-3 — Skill library HTTP routes.
//
// SC-29 EXCEPTION #11 cumulative (verbatim declaration at
// packages/daemon/src/routes/plugins.ts header; this file adds the skill
// surface symmetric with the plugin endpoints landed in C-1):
//
// Slice 28 SKILL-side scope (additive read-only endpoints):
// GET /api/skills/library → LibrarySkillPublic[]
// GET /api/skills/:id/files/list?path=<rel> → file listing
// GET /api/skills/:id/files/read?path=<rel> → file content
//
// Why this exists: see velocity-qa BLOCKING verdict for slice 28 C-final
// (qitem-20260513045711-39ccfdf3) — on the founder-walk VM the daemon's
// allowlist doesn't include the openrig source tree, so the prior
// useLibrarySkills 3-path probe over /api/files/list couldn't reach
// `packages/daemon/specs/agents/shared/skills`. Daemon-owned discovery
// resolves shared-skills via the daemon install path (symmetric with how
// /api/plugins/:id/files/* resolve via pluginDiscoveryService).
import { Hono } from "hono";
import * as fs from "node:fs";
import * as path from "node:path";
import type { SkillLibraryDiscoveryService } from "../domain/skill-library-discovery.js";
import {
resolveAllowedDirectory,
resolveAllowedFile,
FilePathSafetyError,
type AllowlistRoot,
} from "../domain/files/path-safety.js";
import { sha256Hex } from "../domain/files/file-write-service.js";
import { FILE_READ_TRUNCATION_BYTES } from "./files.js";
type ContextGetter = (key: string) => unknown;
function getService(c: { get: ContextGetter }): SkillLibraryDiscoveryService | undefined {
return c.get("skillLibraryDiscoveryService" as never) as SkillLibraryDiscoveryService | undefined;
}
function skillRootAllowlist(absolutePath: string): AllowlistRoot[] {
// Synthetic single-root allowlist scoped to the skill's folder.
// Same pattern as pluginRootAllowlist (plugins.ts) — reuses the
// existing path-safety helpers via a one-element allowlist.
let canonical: string;
try {
canonical = fs.realpathSync(absolutePath);
} catch {
canonical = path.resolve(absolutePath);
}
return [{ name: "skill", canonicalPath: canonical }];
}
function pathSafetyErrorResponse(
c: { json: (body: unknown, status?: number) => Response },
err: FilePathSafetyError,
): Response {
const status =
err.code === "root_unknown" ? 400
: err.code === "path_invalid" || err.code === "path_escape" ? 400
: err.code === "stat_failed" ? 404
: err.code === "not_a_file" || err.code === "not_a_directory" ? 400
: 500;
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
}
export function skillsRoutes(): Hono {
const router = new Hono();
// GET /library — consolidated skill list (workspace + openrig-managed).
router.get("/library", (c) => {
const service = getService(c);
if (!service) return c.json({ error: "skill_library_unavailable" }, 503);
return c.json(service.listLibrarySkillsPublic());
});
// GET /:id/files/list — list a directory inside the skill folder.
// Mounted BEFORE any bare /:id route (defensive — there's no current
// bare /:id endpoint, but the order discipline matches plugins.ts).
router.get("/:id/files/list", (c) => {
const service = getService(c);
if (!service) return c.json({ error: "skill_library_unavailable" }, 503);
const id = c.req.param("id");
const skill = service.getSkill(id);
if (!skill) return c.notFound();
const relativePath = c.req.query("path") ?? "";
try {
const allowlist = skillRootAllowlist(skill.absolutePath);
const resolved = resolveAllowedDirectory(allowlist, "skill", relativePath);
const entries = fs.readdirSync(resolved, { withFileTypes: true });
return c.json({
skillId: id,
path: relativePath,
entries: entries
.map((entry) => {
const fullPath = path.join(resolved, entry.name);
let stat: fs.Stats | null = null;
try { stat = fs.statSync(fullPath); } catch { /* skip stat-failed */ }
return {
name: entry.name,
type: entry.isDirectory() ? "dir" as const : entry.isFile() ? "file" as const : "other" as const,
size: stat?.isFile() ? stat.size : null,
mtime: stat ? stat.mtime.toISOString() : null,
};
})
.sort((a, b) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
}),
});
} catch (err) {
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
return c.json({ error: "list_failed", message: err instanceof Error ? err.message : String(err) }, 500);
}
});
// GET /:id/files/read — read a file inside the skill folder.
router.get("/:id/files/read", (c) => {
const service = getService(c);
if (!service) return c.json({ error: "skill_library_unavailable" }, 503);
const id = c.req.param("id");
const skill = service.getSkill(id);
if (!skill) return c.notFound();
const relativePath = c.req.query("path") ?? "";
if (!relativePath) return c.json({ error: "path_required" }, 400);
try {
const allowlist = skillRootAllowlist(skill.absolutePath);
const resolved = resolveAllowedFile(allowlist, "skill", relativePath);
const stat = fs.statSync(resolved);
const fullContent = fs.readFileSync(resolved);
const truncated = stat.size > FILE_READ_TRUNCATION_BYTES;
const returnedContent = truncated
? fullContent.subarray(0, FILE_READ_TRUNCATION_BYTES)
: fullContent;
return c.json({
skillId: id,
path: relativePath,
absolutePath: resolved,
content: returnedContent.toString("utf-8"),
mtime: stat.mtime.toISOString(),
contentHash: sha256Hex(fullContent),
size: stat.size,
truncated,
truncatedAtBytes: truncated ? FILE_READ_TRUNCATION_BYTES : null,
totalBytes: stat.size,
});
} catch (err) {
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
return c.json({ error: "read_failed", message: err instanceof Error ? err.message : String(err) }, 500);
}
});
return router;
}
+14
View File
@@ -59,6 +59,10 @@ import { specLibraryRoutes } from "./routes/spec-library.js";
// header for full declaration.
import { pluginsRoutes } from "./routes/plugins.js";
import type { PluginDiscoveryService } from "./domain/plugin-discovery-service.js";
// Slice 28 Checkpoint C-3 — skill-library discovery routes (read-only).
// SC-29 EXCEPTION #11 cumulative; full declaration in routes/plugins.ts header.
import { skillsRoutes } from "./routes/skills.js";
import type { SkillLibraryDiscoveryService } from "./domain/skill-library-discovery.js";
import { configRoutes } from "./routes/config.js";
import { contextPacksRoutes } from "./routes/context-packs.js";
import { agentImagesRoutes } from "./routes/agent-images.js";
@@ -209,6 +213,13 @@ export interface AppDeps {
* Read-only; no SQL. SC-29 #8 verbatim declaration in routes/plugins.ts.
*/
pluginDiscoveryService?: PluginDiscoveryService;
/**
* Slice 28 Checkpoint C-3 — skill-library discovery service.
* Consolidates workspace + openrig-managed skill sources; resolves
* shared-skills via daemon install path (independent of operator's
* OPENRIG_FILES_ALLOWLIST). Read-only; no SQL. SC-29 #11.
*/
skillLibraryDiscoveryService?: SkillLibraryDiscoveryService;
/** Workflows in Spec Library v0 — active workflow lens persistence. */
activeLensStore?: import("./domain/active-lens-store.js").ActiveLensStore;
/** Rig Context / Composable Context Injection v0 (PL-014) — context_packs library service. */
@@ -414,6 +425,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("specReviewService" as never, deps.specReviewService);
c.set("specLibraryService" as never, deps.specLibraryService);
c.set("pluginDiscoveryService" as never, deps.pluginDiscoveryService);
c.set("skillLibraryDiscoveryService" as never, deps.skillLibraryDiscoveryService);
c.set("activeLensStore" as never, deps.activeLensStore);
c.set("contextPackLibrary" as never, deps.contextPackLibrary);
c.set("agentImageLibrary" as never, deps.agentImageLibrary);
@@ -464,6 +476,8 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/specs/review", specReviewRoutes());
app.route("/api/specs/library", specLibraryRoutes());
app.route("/api/plugins", pluginsRoutes());
// Slice 28 C-3 — skill-library + per-skill file endpoints (SC-29 #11).
app.route("/api/skills", skillsRoutes());
app.route("/api/config", configRoutes());
app.route("/api/context-packs", contextPacksRoutes());
app.route("/api/agent-images", agentImagesRoutes({
+23
View File
@@ -77,6 +77,8 @@ import { SpecReviewService } from "./domain/spec-review-service.js";
import { SpecLibraryService } from "./domain/spec-library-service.js";
// Phase 3a slice 3.3 — plugin discovery service.
import { PluginDiscoveryService } from "./domain/plugin-discovery-service.js";
// Slice 28 Checkpoint C-3 — skill-library discovery (SC-29 #11 cumulative).
import { SkillLibraryDiscoveryService } from "./domain/skill-library-discovery.js";
import { ContextPackLibraryService } from "./domain/context-packs/context-pack-library-service.js";
import { AgentImageLibraryService } from "./domain/agent-images/agent-image-library-service.js";
import { SnapshotCapturer } from "./domain/agent-images/snapshot-capturer.js";
@@ -794,6 +796,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
codexCacheDir: nodePath.join(os.homedir(), ".codex", "plugins", "cache"),
specLibraryDir: getDefaultOpenRigPath("specs"),
}),
// Slice 28 Checkpoint C-3 — skillLibraryDiscoveryService is constructed
// AFTER filesAllowlist resolution below (deps.skillLibraryDiscoveryService
// assignment near filesAllowlist binding).
// PL-014 Item 6: same instance hoisted earlier so the
// PodRigInstantiator can resolve `kind: context_pack` startup_files
// entries — sharing the cache means /api/context-packs/* + the
@@ -1110,6 +1115,24 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
auditFilePath: nodePath.join(OPENRIG_HOME, "file-edit-audit.jsonl"),
})
: null;
// Slice 28 Checkpoint C-3 — SkillLibraryDiscoveryService. sharedSkillsDir
// resolves to the daemon's bundled `specs/agents/shared/skills/`
// directory via import.meta.url (independent of operator allowlist
// configuration; closes HG-5 on the founder-walk VM where the operator
// allowlist doesn't include the daemon source tree). filesAllowlist
// is also passed so workspace `.openrig/skills/` skills surface via
// the same daemon endpoint.
deps.skillLibraryDiscoveryService = new SkillLibraryDiscoveryService({
sharedSkillsDir: nodePath.resolve(
nodePath.dirname(new URL(import.meta.url).pathname),
"..",
"specs",
"agents",
"shared",
"skills",
),
filesAllowlist,
});
deps.progressIndexer = new ProgressIndexer({ roots: decodeProgressScanRoots(cfg.progressScanRootsRaw) });
// Operator Surface Reconciliation v0 — steering composer (item 1).
@@ -0,0 +1,302 @@
// Slice 28 Checkpoint C-3 — skill-library API tests.
//
// SC-29 EXCEPTION #11 cumulative (verbatim declaration at
// packages/daemon/src/routes/plugins.ts header).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SkillLibraryDiscoveryService } from "../src/domain/skill-library-discovery.js";
import { skillsRoutes } from "../src/routes/skills.js";
interface TestEnv {
root: string;
sharedSkillsDir: string;
workspaceRoot: string;
service: SkillLibraryDiscoveryService;
}
function setup(opts: { withWorkspace?: boolean } = {}): TestEnv {
const root = mkdtempSync(join(tmpdir(), "skill-library-routes-"));
const sharedSkillsDir = join(root, "openrig-shared-skills");
const workspaceRoot = join(root, "workspace");
mkdirSync(sharedSkillsDir, { recursive: true });
mkdirSync(workspaceRoot, { recursive: true });
const allowlist = opts.withWorkspace
? [{ name: "workspace", canonicalPath: workspaceRoot }]
: [];
const service = new SkillLibraryDiscoveryService({
sharedSkillsDir,
filesAllowlist: allowlist,
});
return { root, sharedSkillsDir, workspaceRoot, service };
}
function createApp(service: SkillLibraryDiscoveryService): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("skillLibraryDiscoveryService" as never, service);
await next();
});
app.route("/api/skills", skillsRoutes());
return app;
}
function makeSkill(baseDir: string, relativePath: string, files: Array<{ name: string; content: string }>): string {
const skillDir = join(baseDir, relativePath);
mkdirSync(skillDir, { recursive: true });
for (const f of files) {
writeFileSync(join(skillDir, f.name), f.content);
}
return skillDir;
}
describe("SkillLibraryDiscoveryService — discovery (slice 28 HG-5 fix)", () => {
let env: TestEnv;
beforeEach(() => { env = setup(); });
afterEach(() => { rmSync(env.root, { recursive: true, force: true }); });
it("flat openrig-managed: discovers a skill at the shared-skills root", () => {
makeSkill(env.sharedSkillsDir, "claude-compact-in-place", [{ name: "SKILL.md", content: "# body" }]);
const skills = env.service.listLibrarySkills();
expect(skills).toHaveLength(1);
expect(skills[0]?.id).toBe("openrig-managed:claude-compact-in-place");
expect(skills[0]?.name).toBe("claude-compact-in-place");
expect(skills[0]?.source).toBe("openrig-managed");
expect(skills[0]?.files.map((f) => f.name)).toEqual(["SKILL.md"]);
});
it("HG-5 ROOT CAUSE FIX: nested openrig-managed skill (category/skill/SKILL.md) discovered", () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
makeSkill(env.sharedSkillsDir, "core/openrig-architect", [{ name: "SKILL.md", content: "# body" }]);
makeSkill(env.sharedSkillsDir, "pm/requirements-writer", [{ name: "SKILL.md", content: "# body" }]);
const skills = env.service.listLibrarySkills();
expect(skills.map((s) => s.id).sort()).toEqual([
"openrig-managed:core/openrig-architect",
"openrig-managed:core/openrig-user",
"openrig-managed:pm/requirements-writer",
]);
});
it("MIXED layout: flat + nested skills discovered in the same shared root", () => {
makeSkill(env.sharedSkillsDir, "claude-compact-in-place", [{ name: "SKILL.md", content: "# body" }]);
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
const skills = env.service.listLibrarySkills();
const ids = skills.map((s) => s.id).sort();
expect(ids).toContain("openrig-managed:claude-compact-in-place");
expect(ids).toContain("openrig-managed:core/openrig-user");
// Category folder name itself should NOT appear.
expect(ids).not.toContain("openrig-managed:core");
});
it("DEPTH-CAP: depth-3+ skills NOT discovered (MAX_NESTING_DEPTH=1)", () => {
makeSkill(env.sharedSkillsDir, "outer/inner/deep-skill", [{ name: "SKILL.md", content: "# body" }]);
const skills = env.service.listLibrarySkills();
expect(skills).toHaveLength(0);
});
it("workspace source: discovers .openrig/skills/<name> under allowlist roots", () => {
const envWith = setup({ withWorkspace: true });
try {
makeSkill(envWith.workspaceRoot, ".openrig/skills/operator-skill", [{ name: "SKILL.md", content: "# body" }]);
const skills = envWith.service.listLibrarySkills();
expect(skills).toHaveLength(1);
expect(skills[0]?.id).toBe("workspace:workspace:operator-skill");
expect(skills[0]?.source).toBe("workspace");
} finally {
rmSync(envWith.root, { recursive: true, force: true });
}
});
it("CONSOLIDATION: workspace + openrig-managed both surface in one call", () => {
const envWith = setup({ withWorkspace: true });
try {
makeSkill(envWith.sharedSkillsDir, "claude-compact-in-place", [{ name: "SKILL.md", content: "# body" }]);
makeSkill(envWith.workspaceRoot, ".openrig/skills/operator-skill", [{ name: "SKILL.md", content: "# body" }]);
const skills = envWith.service.listLibrarySkills();
const sources = skills.map((s) => s.source).sort();
expect(sources).toEqual(["openrig-managed", "workspace"]);
} finally {
rmSync(envWith.root, { recursive: true, force: true });
}
});
it("absent shared-skills directory: returns workspace-only", () => {
const envWith = setup({ withWorkspace: true });
try {
// Wipe the shared-skills directory; service must not throw.
rmSync(envWith.sharedSkillsDir, { recursive: true, force: true });
makeSkill(envWith.workspaceRoot, ".openrig/skills/operator-skill", [{ name: "SKILL.md", content: "# body" }]);
const skills = envWith.service.listLibrarySkills();
expect(skills).toHaveLength(1);
expect(skills[0]?.source).toBe("workspace");
} finally {
rmSync(envWith.root, { recursive: true, force: true });
}
});
it("listLibrarySkillsPublic: omits absolutePath from response shape", () => {
makeSkill(env.sharedSkillsDir, "claude-compact-in-place", [{ name: "SKILL.md", content: "# body" }]);
const pub = env.service.listLibrarySkillsPublic();
expect(pub).toHaveLength(1);
expect("absolutePath" in (pub[0] ?? {})).toBe(false);
});
});
describe("GET /api/skills/library (slice 28)", () => {
let env: TestEnv;
beforeEach(() => { env = setup(); });
afterEach(() => { rmSync(env.root, { recursive: true, force: true }); });
it("returns the consolidated skill list", async () => {
makeSkill(env.sharedSkillsDir, "claude-compact-in-place", [{ name: "SKILL.md", content: "# top" }]);
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# nested" }]);
const res = await createApp(env.service).request("/api/skills/library");
expect(res.status).toBe(200);
const body = (await res.json()) as Array<{ id: string; source: string }>;
const ids = body.map((s) => s.id).sort();
expect(ids).toEqual([
"openrig-managed:claude-compact-in-place",
"openrig-managed:core/openrig-user",
]);
// Public shape: absolutePath stripped.
expect("absolutePath" in (body[0] ?? {})).toBe(false);
});
it("returns 503 when service is not provisioned in context", async () => {
const app = new Hono();
app.use("*", async (_c, next) => { await next(); });
app.route("/api/skills", skillsRoutes());
const res = await app.request("/api/skills/library");
expect(res.status).toBe(503);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("skill_library_unavailable");
});
});
describe("GET /api/skills/:id/files/list (slice 28)", () => {
let env: TestEnv;
beforeEach(() => { env = setup(); });
afterEach(() => { rmSync(env.root, { recursive: true, force: true }); });
it("lists files + dirs at the skill root (path='')", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [
{ name: "SKILL.md", content: "# body" },
{ name: "config.json", content: "{}" },
{ name: "fixture.yaml", content: "k: v" },
]);
// Add a subfolder to verify the listing surfaces it.
mkdirSync(join(env.sharedSkillsDir, "core/openrig-user/examples"), { recursive: true });
writeFileSync(join(env.sharedSkillsDir, "core/openrig-user/examples/basic.md"), "# basic");
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/list?path=`);
expect(res.status).toBe(200);
const body = (await res.json()) as { skillId: string; entries: Array<{ name: string; type: string }> };
expect(body.skillId).toBe(id);
const names = body.entries.map((e) => e.name);
// ALL files surfaced (HG-7 spec: not markdown-only).
expect(names).toContain("SKILL.md");
expect(names).toContain("config.json");
expect(names).toContain("fixture.yaml");
expect(names).toContain("examples");
// Dirs sorted before files.
expect(names.indexOf("examples")).toBeLessThan(names.indexOf("SKILL.md"));
});
it("HG-8 lists nested directory contents (path='examples')", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
mkdirSync(join(env.sharedSkillsDir, "core/openrig-user/examples"), { recursive: true });
writeFileSync(join(env.sharedSkillsDir, "core/openrig-user/examples/basic.md"), "# basic");
writeFileSync(join(env.sharedSkillsDir, "core/openrig-user/examples/advanced.md"), "# advanced");
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/list?path=examples`);
expect(res.status).toBe(200);
const body = (await res.json()) as { entries: Array<{ name: string }> };
const names = body.entries.map((e) => e.name);
expect(names).toContain("basic.md");
expect(names).toContain("advanced.md");
});
it("returns 404 when skill id unknown", async () => {
const res = await createApp(env.service).request("/api/skills/missing-skill/files/list?path=");
expect(res.status).toBe(404);
});
it("rejects '..' escape attempt with 400 path_escape", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/list?path=..%2Fsomewhere`);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("path_escape");
});
it("rejects symlink escape (realpath outside skill folder)", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
const escapeTarget = join(env.root, "outside-target");
mkdirSync(escapeTarget, { recursive: true });
writeFileSync(join(escapeTarget, "secret.txt"), "out-of-bounds");
symlinkSync(escapeTarget, join(env.sharedSkillsDir, "core/openrig-user/escape-link"));
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/list?path=escape-link`);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("path_escape");
});
});
describe("GET /api/skills/:id/files/read (slice 28)", () => {
let env: TestEnv;
beforeEach(() => { env = setup(); });
afterEach(() => { rmSync(env.root, { recursive: true, force: true }); });
it("reads SKILL.md content from a nested skill", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# OpenRig User skill body" }]);
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/read?path=SKILL.md`);
expect(res.status).toBe(200);
const body = (await res.json()) as { skillId: string; path: string; content: string; contentHash: string };
expect(body.skillId).toBe(id);
expect(body.path).toBe("SKILL.md");
expect(body.content).toContain("OpenRig User skill body");
expect(body.contentHash).toMatch(/^[0-9a-f]{64}$/);
});
it("HG-8 reads a nested-subfolder file (examples/basic.md)", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# root" }]);
mkdirSync(join(env.sharedSkillsDir, "core/openrig-user/examples"), { recursive: true });
writeFileSync(join(env.sharedSkillsDir, "core/openrig-user/examples/basic.md"), "# basic example");
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/read?path=examples%2Fbasic.md`);
expect(res.status).toBe(200);
const body = (await res.json()) as { content: string };
expect(body.content).toContain("basic example");
});
it("returns 400 path_required when path query missing", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/read`);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("path_required");
});
it("returns 404 stat_failed when file does not exist", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/read?path=nonexistent.md`);
expect(res.status).toBe(404);
});
it("rejects '..' escape attempt with 400 path_escape", async () => {
makeSkill(env.sharedSkillsDir, "core/openrig-user", [{ name: "SKILL.md", content: "# body" }]);
const id = "openrig-managed:core/openrig-user";
const res = await createApp(env.service).request(`/api/skills/${encodeURIComponent(id)}/files/read?path=..%2Fsomewhere.md`);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("path_escape");
});
});