mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user