mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
feat(daemon): slice 28 Checkpoint C-1 — plugin files endpoints + PluginEntry.skillCount enrichment
Orch authorized OPT-A (qitem-20260513042155-f31c11c3): full implementation of plugin/skill docs-browser semantics. Crosses the original 'fleet — UI source changes' posture into daemon source for the bounded plugin file-listing surface that guard's BLOCKING-CONCERN identified as required to satisfy HG-6/HG-10 per the slice spec. SC-29 EXCEPTION #11 declared (per banked inline-ledger discipline; verbatim block at packages/daemon/src/routes/plugins.ts header): (a) two additive read-only HTTP routes: GET /api/plugins/:id/files/list?path=<rel> GET /api/plugins/:id/files/read?path=<rel> (b) one PluginEntry shape extension: skillCount: number (subdirectory count under <plugin>/skills/) DAEMON CHANGES: packages/daemon/src/domain/plugin-discovery-service.ts: - PluginEntry adds `skillCount: number` field. - detectPlugin() counts subdirectories under <plugin>/skills/ at detection time (one readdir; same enumeration as getPlugin's PluginSkillSummary[] collection so list-view + detail-view match). - Comment block on the new field references the SC-29 #11 inline ledger for auditability. packages/daemon/src/routes/plugins.ts: - Two new routes mounted BEFORE the bare /:id catchall (matching the /:id/used-by route-order discipline from Phase A R1 banked SSE lesson; route-order test added to plugin-files-routes.test.ts). - pluginRootAllowlist(absolutePath) helper: builds a synthetic single-root AllowlistRoot scoped to the discovered plugin's absolute path. Normalizes via fs.realpathSync (matches decodeAllowlist semantics so containment checks hold on macOS /var/folders → /private/var/folders symlink chains). - pathSafetyErrorResponse helper: copies the error→status mapping from files.ts (could be extracted as a shared util in a follow-up refactor; inline for now to avoid layering changes). - /files/list reuses resolveAllowedDirectory + same response shape as /api/files/list (entries with name/type/size/mtime + dir-before-file sort). - /files/read reuses resolveAllowedFile + FILE_READ_TRUNCATION_BYTES cap from files.ts. ContentHash via existing sha256Hex helper. Plugin folders are read-only at v0 so the hash is informational. TESTS: packages/daemon/test/plugin-files-routes.test.ts (NEW; 18 tests): - PluginEntry.skillCount enrichment (4 tests): zero-count, N-count, detail-endpoint parity, stray-file ignore (only subdirs counted). - /files/list (6 tests): root listing + nested listing + 404 unknown plugin + `..` escape rejection + absolute-path rejection + symlink escape rejection (realpath outside plugin folder). - /files/read (6 tests): README.md + nested SKILL.md + path_required + 404 unknown plugin + 404 stat_failed + `..` escape rejection. - Route-order discipline (2 tests): /files/list + /files/read do NOT get caught by the bare /:id catchall (discriminator: response shape includes `entries`/`content` not `entry`/`skills`). Pre-existing daemon tests (plugins-routes.test.ts + plugin-discovery- service.test.ts) continue to pass with the added skillCount field; no breaking changes to existing assertions. Daemon full suite: 3584 pass + 1 skipped + 1 pre-existing fail (test/precompact-hook.test.ts HG-7 OPENRIG_HOME expansion — verified identical pre/post via stash-and-recheck; orthogonal to slice 28). Following Checkpoint C-2: UI rewire — usePluginFiles hook + PluginDetailPage real file-browser tree + SkillDetailPage all-files + PluginsIndexPage skill-count column. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
ea8994b05a
commit
c44a69dfc2
3 files changed
+464
-3
No files matched your search
@@ -66,6 +66,18 @@ export interface PluginEntry {
|
||||
* scope at v0).
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
/**
|
||||
* Slice 28 — number of skill folders shipped under `<plugin>/skills/`.
|
||||
* Surfaced in the list response so the PluginsIndexPage can render a
|
||||
* skill-count column without an N+1 detail fetch per plugin row.
|
||||
* Counted at detectPlugin time (one readdir of skills/).
|
||||
*
|
||||
* SC-29 EXCEPTION #11 (slice 28 library-explorer-finishing):
|
||||
* adds skillCount field to PluginEntry — additive shape change to
|
||||
* the plugin discovery API contract. Per banked inline-ledger
|
||||
* discipline; declared verbatim in routes/plugins.ts header.
|
||||
*/
|
||||
skillCount: number;
|
||||
}
|
||||
|
||||
export interface PluginManifestSummary {
|
||||
@@ -413,6 +425,19 @@ export class PluginDiscoveryService {
|
||||
lastSeenAt = null;
|
||||
}
|
||||
|
||||
// Slice 28 — skillCount: count subdirectories under <plugin>/skills/.
|
||||
// Matches the detail-side enumeration in getPlugin() which also
|
||||
// collects subdirs under that path (no .md filtering at this level
|
||||
// — every shipped skill folder counts, whether or not it has
|
||||
// landed a SKILL.md yet).
|
||||
let skillCount = 0;
|
||||
const skillsDir = join(pluginPath, "skills");
|
||||
if (existsSync(skillsDir)) {
|
||||
for (const entry of safeReaddir(skillsDir)) {
|
||||
if (isDir(join(skillsDir, entry))) skillCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: explicitId,
|
||||
name,
|
||||
@@ -423,6 +448,7 @@ export class PluginDiscoveryService {
|
||||
runtimes,
|
||||
path: pluginPath,
|
||||
lastSeenAt,
|
||||
skillCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,52 @@
|
||||
// DESIGN.md §5.4. Per IMPL-PRD §3.3 'Code touches' this allocation is explicit;
|
||||
// documenting in compliance with banked SC-29 verbatim-declaration rule."
|
||||
//
|
||||
// SC-29 EXCEPTION #11 (slice 28 library-explorer-finishing) declared verbatim:
|
||||
// "Slice 28 (Library Explorer finishing — founder-walk follow-up) requires:
|
||||
// (a) two additive read-only HTTP routes — GET /api/plugins/:id/files/list
|
||||
// and GET /api/plugins/:id/files/read — that wrap the existing path-safety
|
||||
// machinery (resolveAllowedDirectory/File) with the discovered plugin's
|
||||
// absolute path acting as a synthetic single-root allowlist. Enables
|
||||
// docs-browser file navigation of a plugin's on-disk folder without
|
||||
// requiring the operator to allowlist ~/.openrig/plugins/ in
|
||||
// OPENRIG_FILES_ALLOWLIST.
|
||||
// (b) one PluginEntry shape extension — skillCount: number — populated in
|
||||
// detectPlugin() by readdir of <plugin>/skills/. Surfaces skill-count in
|
||||
// the plugin list response so PluginsIndexPage can render the column
|
||||
// without an N+1 detail fetch per row.
|
||||
// No additional state, no SQL migration, no write mutations on plugin
|
||||
// folders. Read-only docs-browser surface per slice 28 founder direction
|
||||
// 'finishing it is 0.3.1 work, not 0.3.2'. Routing decision
|
||||
// qitem-20260513042155-f31c11c3 (orch OPT-A authorization)."
|
||||
//
|
||||
// Endpoint shape:
|
||||
// GET /api/plugins → PluginEntry[] (?runtime=, ?source= filters)
|
||||
// GET /api/plugins/:id → PluginDetail (404 when unknown)
|
||||
// GET /api/plugins/:id/used-by → AgentReference[]
|
||||
// GET /api/plugins → PluginEntry[] (?runtime=, ?source= filters)
|
||||
// GET /api/plugins/:id → PluginDetail (404 when unknown)
|
||||
// GET /api/plugins/:id/used-by → AgentReference[]
|
||||
// GET /api/plugins/:id/files/list?path=<rel> → FilesListResponse (slice 28)
|
||||
// GET /api/plugins/:id/files/read?path=<rel> → FilesReadResponse (slice 28)
|
||||
//
|
||||
// All endpoints return 503 when the service is not provisioned in context
|
||||
// (consistent with the existing daemon route pattern for optional services).
|
||||
// /files/list + /files/read use the discovered plugin path as a synthetic
|
||||
// AllowlistRoot + reuse resolveAllowedDirectory/File for path-safety.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
PluginDiscoveryService,
|
||||
PluginRuntime,
|
||||
PluginSourceKind,
|
||||
} from "../domain/plugin-discovery-service.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;
|
||||
|
||||
@@ -39,6 +71,39 @@ function parseSourceFilter(value: string | undefined): PluginSourceKind | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pluginRootAllowlist(absolutePath: string): AllowlistRoot[] {
|
||||
// Synthetic single-root allowlist scoped to the plugin's folder.
|
||||
// Reuses the existing path-safety helpers for ../ escape / symlink
|
||||
// realpath containment checks without any allowlist env-var coupling
|
||||
// (operator does not need to declare plugin paths in OPENRIG_FILES_ALLOWLIST).
|
||||
//
|
||||
// Normalize via fs.realpathSync so the containment check (realpath vs
|
||||
// canonicalPath) holds on platforms where the input path is itself
|
||||
// a symlink chain (e.g. macOS /tmp → /private/tmp, /var/folders →
|
||||
// /private/var/folders). Matches decodeAllowlist's normalization in
|
||||
// path-safety.ts.
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = fs.realpathSync(absolutePath);
|
||||
} catch {
|
||||
canonical = path.resolve(absolutePath);
|
||||
}
|
||||
return [{ name: "plugin", 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 pluginsRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
@@ -70,6 +135,88 @@ export function pluginsRoutes(): Hono {
|
||||
return c.json(service.findUsedBy(id));
|
||||
});
|
||||
|
||||
// Slice 28 — GET /:id/files/list?path=<rel>
|
||||
// Lists directory entries inside the plugin's source folder. Uses the
|
||||
// existing path-safety helpers with a synthetic single-root allowlist
|
||||
// built from the discovered plugin's absolute path. Mounted BEFORE the
|
||||
// bare /:id route (same route-order discipline as /:id/used-by).
|
||||
router.get("/:id/files/list", (c) => {
|
||||
const service = getService(c);
|
||||
if (!service) return c.json({ error: "plugin_discovery_unavailable" }, 503);
|
||||
const id = c.req.param("id");
|
||||
const detail = service.getPlugin(id);
|
||||
if (!detail) return c.notFound();
|
||||
const relativePath = c.req.query("path") ?? "";
|
||||
try {
|
||||
const allowlist = pluginRootAllowlist(detail.entry.path);
|
||||
const resolved = resolveAllowedDirectory(allowlist, "plugin", relativePath);
|
||||
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
||||
return c.json({
|
||||
pluginId: 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);
|
||||
}
|
||||
});
|
||||
|
||||
// Slice 28 — GET /:id/files/read?path=<rel>
|
||||
// Reads a file inside the plugin's source folder. Same truncation cap
|
||||
// semantics as /api/files/read (FILE_READ_TRUNCATION_BYTES; hash over
|
||||
// full content for honest edit-conflict semantics — but plugin folders
|
||||
// are read-only at v0 so the hash is informational only).
|
||||
router.get("/:id/files/read", (c) => {
|
||||
const service = getService(c);
|
||||
if (!service) return c.json({ error: "plugin_discovery_unavailable" }, 503);
|
||||
const id = c.req.param("id");
|
||||
const detail = service.getPlugin(id);
|
||||
if (!detail) return c.notFound();
|
||||
const relativePath = c.req.query("path") ?? "";
|
||||
if (!relativePath) return c.json({ error: "path_required" }, 400);
|
||||
try {
|
||||
const allowlist = pluginRootAllowlist(detail.entry.path);
|
||||
const resolved = resolveAllowedFile(allowlist, "plugin", 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({
|
||||
pluginId: 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);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /:id — plugin detail
|
||||
router.get("/:id", (c) => {
|
||||
const service = getService(c);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// Slice 28 Checkpoint C-1 — plugin docs-browser routes + skillCount enrichment.
|
||||
//
|
||||
// SC-29 EXCEPTION #11 declared verbatim in packages/daemon/src/routes/plugins.ts
|
||||
// header. This test file exercises the two new endpoints + the additive
|
||||
// PluginEntry.skillCount field.
|
||||
|
||||
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 { PluginDiscoveryService } from "../src/domain/plugin-discovery-service.js";
|
||||
import { pluginsRoutes } from "../src/routes/plugins.js";
|
||||
|
||||
interface TestEnv {
|
||||
root: string;
|
||||
service: PluginDiscoveryService;
|
||||
openrigPluginsDir: string;
|
||||
claudeCacheDir: string;
|
||||
codexCacheDir: string;
|
||||
specLibraryDir: string;
|
||||
}
|
||||
|
||||
function setup(): TestEnv {
|
||||
const root = mkdtempSync(join(tmpdir(), "plugin-files-routes-"));
|
||||
const openrigPluginsDir = join(root, "openrig-plugins");
|
||||
const claudeCacheDir = join(root, "claude-cache");
|
||||
const codexCacheDir = join(root, "codex-cache");
|
||||
const specLibraryDir = join(root, "specs");
|
||||
mkdirSync(openrigPluginsDir, { recursive: true });
|
||||
mkdirSync(claudeCacheDir, { recursive: true });
|
||||
mkdirSync(codexCacheDir, { recursive: true });
|
||||
mkdirSync(specLibraryDir, { recursive: true });
|
||||
const service = new PluginDiscoveryService({
|
||||
openrigPluginsDir,
|
||||
claudeCacheDir,
|
||||
codexCacheDir,
|
||||
specLibraryDir,
|
||||
});
|
||||
return { root, service, openrigPluginsDir, claudeCacheDir, codexCacheDir, specLibraryDir };
|
||||
}
|
||||
|
||||
function writeClaudeManifest(pluginDir: string, manifest: Record<string, unknown>): void {
|
||||
const manifestDir = join(pluginDir, ".claude-plugin");
|
||||
mkdirSync(manifestDir, { recursive: true });
|
||||
writeFileSync(join(manifestDir, "plugin.json"), JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
function createApp(service: PluginDiscoveryService): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("pluginDiscoveryService" as never, service);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/plugins", pluginsRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
function makePluginWithFolders(pluginsDir: string, name: string, opts: { skills?: string[]; readme?: string; hooks?: boolean } = {}): string {
|
||||
const pluginDir = join(pluginsDir, name);
|
||||
writeClaudeManifest(pluginDir, { name, version: "1.0.0", description: `${name} test plugin` });
|
||||
if (opts.readme !== undefined) {
|
||||
writeFileSync(join(pluginDir, "README.md"), opts.readme);
|
||||
}
|
||||
if (opts.skills) {
|
||||
const skillsDir = join(pluginDir, "skills");
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
for (const skillName of opts.skills) {
|
||||
const skillDir = join(skillsDir, skillName);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), `# ${skillName}\nSkill content.`);
|
||||
}
|
||||
}
|
||||
if (opts.hooks) {
|
||||
const hooksDir = join(pluginDir, "hooks");
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
writeFileSync(join(hooksDir, "claude.json"), JSON.stringify({ hooks: {} }));
|
||||
}
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
describe("PluginEntry.skillCount enrichment (slice 28)", () => {
|
||||
let env: TestEnv;
|
||||
beforeEach(() => { env = setup(); });
|
||||
afterEach(() => { rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("populates skillCount = 0 when plugin has no skills/ folder", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "skinny");
|
||||
const res = await createApp(env.service).request("/api/plugins");
|
||||
const body = (await res.json()) as Array<{ id: string; skillCount: number }>;
|
||||
const skinny = body.find((p) => p.id === "skinny");
|
||||
expect(skinny).toBeDefined();
|
||||
expect(skinny!.skillCount).toBe(0);
|
||||
});
|
||||
|
||||
it("populates skillCount = N when plugin ships N skill folders", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", {
|
||||
skills: ["openrig-user", "openrig-architect", "queue-handoff"],
|
||||
});
|
||||
const res = await createApp(env.service).request("/api/plugins");
|
||||
const body = (await res.json()) as Array<{ id: string; skillCount: number }>;
|
||||
const core = body.find((p) => p.id === "openrig-core");
|
||||
expect(core).toBeDefined();
|
||||
expect(core!.skillCount).toBe(3);
|
||||
});
|
||||
|
||||
it("populates skillCount on PluginDetail.entry too (detail endpoint parity)", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", { skills: ["alpha", "beta"] });
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core");
|
||||
const body = (await res.json()) as { entry: { skillCount: number } };
|
||||
expect(body.entry.skillCount).toBe(2);
|
||||
});
|
||||
|
||||
it("skillCount counts only subdirectories under skills/ (ignores stray files)", async () => {
|
||||
const pluginDir = makePluginWithFolders(env.openrigPluginsDir, "core-with-stray", { skills: ["a", "b"] });
|
||||
// Drop a stray file inside skills/ — must NOT count.
|
||||
writeFileSync(join(pluginDir, "skills", "README.md"), "stray file");
|
||||
const res = await createApp(env.service).request("/api/plugins");
|
||||
const body = (await res.json()) as Array<{ id: string; skillCount: number }>;
|
||||
const core = body.find((p) => p.id === "core-with-stray");
|
||||
expect(core!.skillCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/plugins/: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 plugin root (path='')", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", {
|
||||
skills: ["alpha"],
|
||||
readme: "# OpenRig Core\nplugin docs",
|
||||
hooks: true,
|
||||
});
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/list?path=");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { pluginId: string; path: string; entries: Array<{ name: string; type: string }> };
|
||||
expect(body.pluginId).toBe("openrig-core");
|
||||
expect(body.path).toBe("");
|
||||
const names = body.entries.map((e) => e.name);
|
||||
// Dirs first (sorted by name), then files. Plugin root contains:
|
||||
// .claude-plugin/, hooks/, skills/, README.md.
|
||||
expect(names).toContain(".claude-plugin");
|
||||
expect(names).toContain("hooks");
|
||||
expect(names).toContain("skills");
|
||||
expect(names).toContain("README.md");
|
||||
// Verify dir-before-file ordering.
|
||||
const readmeIdx = names.indexOf("README.md");
|
||||
const skillsIdx = names.indexOf("skills");
|
||||
expect(skillsIdx).toBeLessThan(readmeIdx);
|
||||
});
|
||||
|
||||
it("lists nested directory contents (path='skills')", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", { skills: ["alpha", "beta"] });
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/list?path=skills");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { entries: Array<{ name: string; type: string }> };
|
||||
const names = body.entries.map((e) => e.name);
|
||||
expect(names).toContain("alpha");
|
||||
expect(names).toContain("beta");
|
||||
expect(body.entries.every((e) => e.type === "dir")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when plugin id unknown", async () => {
|
||||
const res = await createApp(env.service).request("/api/plugins/missing/files/list?path=");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects '..' escape attempt with 400 path_escape", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", { skills: ["alpha"] });
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/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 absolute path with 400 path_invalid", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core");
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/list?path=%2Fetc");
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("path_invalid");
|
||||
});
|
||||
|
||||
it("rejects symlink escape (realpath outside plugin folder)", async () => {
|
||||
const pluginDir = makePluginWithFolders(env.openrigPluginsDir, "openrig-core");
|
||||
// Create a symlink inside the plugin pointing OUTSIDE.
|
||||
const escapeTarget = join(env.root, "outside-target");
|
||||
mkdirSync(escapeTarget, { recursive: true });
|
||||
writeFileSync(join(escapeTarget, "secret.txt"), "out-of-bounds");
|
||||
symlinkSync(escapeTarget, join(pluginDir, "escape-link"));
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/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/plugins/:id/files/read (slice 28)", () => {
|
||||
let env: TestEnv;
|
||||
beforeEach(() => { env = setup(); });
|
||||
afterEach(() => { rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("reads README.md at plugin root", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", { readme: "# Plugin docs body" });
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/read?path=README.md");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
pluginId: string;
|
||||
path: string;
|
||||
content: string;
|
||||
contentHash: string;
|
||||
size: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
expect(body.pluginId).toBe("openrig-core");
|
||||
expect(body.path).toBe("README.md");
|
||||
expect(body.content).toContain("Plugin docs body");
|
||||
expect(body.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(body.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a nested skill SKILL.md file", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", { skills: ["openrig-user"] });
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/read?path=skills%2Fopenrig-user%2FSKILL.md");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { content: string };
|
||||
expect(body.content).toContain("# openrig-user");
|
||||
});
|
||||
|
||||
it("returns 400 path_required when path query missing", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core");
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/read");
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("path_required");
|
||||
});
|
||||
|
||||
it("returns 404 plugin-id unknown", async () => {
|
||||
const res = await createApp(env.service).request("/api/plugins/missing/files/read?path=README.md");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 stat_failed when file does not exist under plugin", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core");
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/read?path=nonexistent.md");
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("stat_failed");
|
||||
});
|
||||
|
||||
it("rejects '..' escape attempt with 400 path_escape", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core");
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/read?path=..%2Fsomewhere.md");
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("path_escape");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Route-order discipline (slice 28): /files/list + /files/read mounted BEFORE bare /:id", () => {
|
||||
let env: TestEnv;
|
||||
beforeEach(() => { env = setup(); });
|
||||
afterEach(() => { rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("/api/plugins/openrig-core/files/list does NOT get caught by /:id catchall (404 vs detail)", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", { skills: ["alpha"] });
|
||||
// /:id catchall would treat 'openrig-core' as the id + ignore the
|
||||
// remainder. /files/list mounted earlier intercepts the sub-path
|
||||
// BEFORE that catchall. Discriminator: response shape includes
|
||||
// `entries` (list) not `entry` + `skills` (detail).
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/list?path=");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { entries?: unknown; entry?: unknown };
|
||||
expect(body.entries).toBeDefined();
|
||||
expect(body.entry).toBeUndefined();
|
||||
});
|
||||
|
||||
it("/api/plugins/openrig-core/files/read does NOT get caught by /:id catchall", async () => {
|
||||
makePluginWithFolders(env.openrigPluginsDir, "openrig-core", { readme: "doc" });
|
||||
const res = await createApp(env.service).request("/api/plugins/openrig-core/files/read?path=README.md");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { content?: unknown; entry?: unknown };
|
||||
expect(body.content).toBeDefined();
|
||||
expect(body.entry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user