mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
feat(skills): import skills from projects (#9620)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Company skills make reusable agent behavior discoverable and editable from one place. > - Projects already contain skill directories, but operators had to import each skill path manually. > - Copying those skills would break the desired write-through workflow between Skill Studio and the source project. > - The server therefore needs a safe preview/select/import contract that only accepts rediscovered, workspace-contained candidates. > - The UI needs a guided project picker that explains reference semantics, handles conflicts, and remains usable on mobile. > - This pull request adds that end-to-end project skill import flow with authorization, tenant-scope, traversal, and symlink regression coverage. > - The benefit is faster bulk onboarding while keeping project files as the single source of truth. ## Linked Issues or Issue Description **Feature request** **Problem:** Importing several skills already stored in a Paperclip project requires operators to discover and submit each local path individually. This is slow, hides which well-known directories were searched, and makes conflict/already-imported states difficult to evaluate before mutation. **Proposed solution:** Add an “Import skills from project” flow that previews skills from well-known directories, lets operators selectively import eligible candidates, and stores local-path references so Skill Studio edits write through to the project files. **Alternatives considered:** Copying files into company-managed skill storage was rejected because it creates divergent copies. Trusting client-supplied paths was rejected because imports must be constrained to server-rediscovered, workspace-contained candidates. **Additional context:** GitHub duplicate search found no existing issue or PR for this exact workflow. Refs #3799 for related skill-import inventory behavior; this PR does not claim to close that issue. ## What Changed - Extend `scan-projects` with backward-compatible preview and selective-import modes, typed validation, candidate statuses, and OpenAPI coverage. - Discover project skills under `skills`, `.agents/skills`, `.claude/skills`, `.codex/skills`, `.cursor/skills`, `.opencode/skills`, and `.gemini/skills`. - Re-discover selections server-side, enforce company/project/workspace scope, and reject traversal or symlink escapes before creating `local_path` references. - Add the Skills-page menu entry and responsive project import dialog with project selection, grouped candidates, select all/deselect all, conflicts, empty/error/403 states, and import results. - Add route, service, and component regressions for preview authorization, cross-tenant selections, traversal/symlink safety, selection counts, grouping, and result semantics. ### Screenshots **Choose a project**  **Review discovered skills**  **Mobile selection footer**  **Import result**  ## Verification - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/company-skills-routes.test.ts ui/src/pages/skills/ImportSkillsFromProjectDialog.test.tsx` — 3 files, 81 tests passed. - `pnpm check:token-gates` — all token gates clean. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - Security review passed after adding tenant-scope and unauthorized-preview regressions; UX re-review approved desktop/mobile surfaces; QA passed all seven acceptance areas including write-through editing, deduplication, conflicts, empty state, and permission denial. ## Risks - Files remain referenced in project workspaces, so moving or deleting a source directory can make an imported skill unavailable; the UI explicitly communicates the reference behavior. - New well-known directory scans may discover more candidates than older versions, but preview mode prevents mutation until the operator confirms a selection. - The endpoint remains backward compatible: omitting `mode` preserves the prior full-import behavior. - No schema migration or telemetry event changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 4.8 with tool use/code execution assisted with the UI implementation and UX polish. OpenAI Codex CLI with tool use/code execution assisted with server implementation, security fixes, regression coverage, integration, and PR preparation; the runtime did not expose Codex's exact backing model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22 files changed
+2310
-26
No files matched your search
@@ -233,6 +233,17 @@ describe("company skill mutation permissions", () => {
|
||||
imported: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
||||
scannedProjects: 0,
|
||||
scannedWorkspaces: 0,
|
||||
discovered: 0,
|
||||
imported: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
conflicts: [],
|
||||
candidates: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]);
|
||||
mockCompanySkillService.list.mockResolvedValue([]);
|
||||
mockCompanySkillService.categoryCounts.mockResolvedValue([]);
|
||||
@@ -706,6 +717,93 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards preview and selective scan-projects requests through the existing skill mutation gate", async () => {
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
||||
scannedProjects: 1,
|
||||
scannedWorkspaces: 1,
|
||||
discovered: 1,
|
||||
imported: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
conflicts: [],
|
||||
candidates: [{
|
||||
slug: "review",
|
||||
name: "Review",
|
||||
description: null,
|
||||
workspaceId,
|
||||
workspaceName: "Primary",
|
||||
projectId: "22222222-2222-4222-8222-222222222222",
|
||||
projectName: "Paperclip",
|
||||
directoryRoot: ".codex/skills",
|
||||
relativePath: ".codex/skills/review",
|
||||
status: "new",
|
||||
}],
|
||||
warnings: [],
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const preview = await request(app)
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({ mode: "preview", workspaceIds: [workspaceId] });
|
||||
expect(preview.status, JSON.stringify(preview.body)).toBe(200);
|
||||
expect(preview.body.candidates).toHaveLength(1);
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
|
||||
const selective = await request(app)
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/review", slug: "review-project" }],
|
||||
});
|
||||
expect(selective.status, JSON.stringify(selective.body)).toBe(200);
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenLastCalledWith("company-1", {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/review", slug: "review-project" }],
|
||||
});
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skills_scanned",
|
||||
details: expect.objectContaining({ mode: "import", candidateCount: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("blocks unauthorized preview scan-projects requests before candidate data is returned", async () => {
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision(
|
||||
"deny_actor_restricted",
|
||||
"Actor is restricted from changing skill configuration.",
|
||||
));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({ mode: "preview", workspaceIds: [workspaceId] });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Actor is restricted from changing skill configuration.");
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows board users with skills:create to create, import, install, update, delete, audit, and reset company skills", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
||||
@@ -4,7 +4,16 @@ import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, authUsers, companies, companySkillVersions, companySkills, createDb } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companySkillVersions,
|
||||
companySkills,
|
||||
createDb,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
@@ -52,6 +61,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
afterEach(async () => {
|
||||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
@@ -1849,4 +1860,471 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
versions = await svc.listVersions(companyId, skill.id);
|
||||
expect(versions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("previews project workspace skill candidates without importing them", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-preview-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
const codexSkillDir = path.join(workspaceDir, ".codex", "skills", "preview-codex");
|
||||
const cursorSkillDir = path.join(workspaceDir, ".cursor", "skills", "preview-cursor");
|
||||
await fs.mkdir(codexSkillDir, { recursive: true });
|
||||
await fs.mkdir(cursorSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(codexSkillDir, "SKILL.md"), "---\nname: Preview Codex\ndescription: Codex candidate\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(cursorSkillDir, "SKILL.md"), "---\nname: Preview Cursor\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, { mode: "preview", workspaceIds: [workspaceId] });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
scannedProjects: 1,
|
||||
scannedWorkspaces: 1,
|
||||
discovered: 2,
|
||||
imported: [],
|
||||
updated: [],
|
||||
conflicts: [],
|
||||
});
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Preview Codex",
|
||||
description: "Codex candidate",
|
||||
workspaceId,
|
||||
directoryRoot: ".codex/skills",
|
||||
relativePath: ".codex/skills/preview-codex",
|
||||
status: "new",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Preview Cursor",
|
||||
workspaceId,
|
||||
directoryRoot: ".cursor/skills",
|
||||
relativePath: ".cursor/skills/preview-cursor",
|
||||
status: "new",
|
||||
}),
|
||||
]);
|
||||
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
expect(persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a project skill as already installed when the source path matches", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-same-path-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
const skillDir = path.join(workspaceDir, ".codex", "skills", "same-path");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Same Path\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const imported = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/same-path" }],
|
||||
});
|
||||
expect(imported.imported).toHaveLength(1);
|
||||
|
||||
const preview = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
expect(preview.conflicts).toEqual([]);
|
||||
expect(preview.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
relativePath: ".codex/skills/same-path",
|
||||
status: "already_imported",
|
||||
existingSkillId: imported.imported[0]!.id,
|
||||
reason: "This skill is already installed from the same path.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports project skills that duplicate built-in slugs as already available", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const bundledSkillId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-built-in-"));
|
||||
const bundledSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-bundled-source-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(bundledSkillDir);
|
||||
const skillDir = path.join(workspaceDir, ".claude", "skills", "built-in-review");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Built In Review\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(bundledSkillDir, "SKILL.md"), "---\nname: Built In Review\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companySkills).values({
|
||||
id: bundledSkillId,
|
||||
companyId,
|
||||
key: "paperclipai/paperclip/built-in-review",
|
||||
slug: "built-in-review",
|
||||
name: "Built In Review",
|
||||
markdown: "---\nname: Built In Review\n---\n",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: bundledSkillDir,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: { sourceKind: "paperclip_bundled" },
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const preview = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
|
||||
expect(preview.conflicts).toEqual([]);
|
||||
expect(preview.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "built-in-review",
|
||||
status: "already_imported",
|
||||
existingSkillId: bundledSkillId,
|
||||
reason: "This skill is already available as a built-in.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports a conflicting project skill under a selected replacement slug", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const existingSkillId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-rename-"));
|
||||
const existingSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-existing-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(existingSkillDir);
|
||||
const skillDir = path.join(workspaceDir, ".cursor", "skills", "shared-skill");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Shared Skill\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(existingSkillDir, "SKILL.md"), "---\nname: Shared Skill\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companySkills).values({
|
||||
id: existingSkillId,
|
||||
companyId,
|
||||
key: "local/existing/shared-skill",
|
||||
slug: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
markdown: "---\nname: Shared Skill\n---\n",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: existingSkillDir,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: { sourceKind: "local_path" },
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const preview = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
expect(preview.candidates).toEqual([
|
||||
expect.objectContaining({ slug: "shared-skill", status: "conflict", existingSkillId }),
|
||||
]);
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{
|
||||
workspaceId,
|
||||
path: ".cursor/skills/shared-skill",
|
||||
slug: "shared-skill-project",
|
||||
}],
|
||||
});
|
||||
|
||||
expect(result.conflicts).toEqual([]);
|
||||
expect(result.imported).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "shared-skill-project",
|
||||
key: expect.stringMatching(/^local\/[a-f0-9]+\/shared-skill-project$/),
|
||||
sourceLocator: skillDir,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports only selections rediscovered inside project workspaces", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-selective-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
const selectedSkillDir = path.join(workspaceDir, ".gemini", "skills", "selected-skill");
|
||||
const ignoredSkillDir = path.join(workspaceDir, ".opencode", "skills", "ignored-skill");
|
||||
const ignoredLinkedSkillDir = path.join(workspaceDir, ".claude", "skills", "ignored-link");
|
||||
const outsideSkillFile = path.join(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-selective-outside-")),
|
||||
"SKILL.md",
|
||||
);
|
||||
cleanupDirs.add(path.dirname(outsideSkillFile));
|
||||
await fs.mkdir(selectedSkillDir, { recursive: true });
|
||||
await fs.mkdir(ignoredSkillDir, { recursive: true });
|
||||
await fs.mkdir(ignoredLinkedSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(selectedSkillDir, "SKILL.md"), "---\nname: Selected Skill\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(ignoredSkillDir, "SKILL.md"), "---\nname: Ignored Skill\n---\n", "utf8");
|
||||
await fs.writeFile(outsideSkillFile, "---\nname: Ignored Linked Skill\n---\n", "utf8");
|
||||
await fs.symlink(outsideSkillFile, path.join(ignoredLinkedSkillDir, "SKILL.md"));
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [
|
||||
{ workspaceId, path: ".gemini/skills/selected-skill" },
|
||||
{ workspaceId, path: "../../outside-workspace" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.imported).toHaveLength(1);
|
||||
expect(result.imported[0]).toMatchObject({
|
||||
name: "Selected Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: selectedSkillDir,
|
||||
metadata: expect.objectContaining({ sourceKind: "project_scan", workspaceId, projectId }),
|
||||
});
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({ relativePath: ".gemini/skills/selected-skill", status: "new" }),
|
||||
]);
|
||||
expect(result.warnings.join("\n")).not.toContain("symbolic link");
|
||||
expect(result.skipped).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: expect.stringContaining("symbolic link") }),
|
||||
]),
|
||||
);
|
||||
expect(result.skipped).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
workspaceId,
|
||||
path: "../../outside-workspace",
|
||||
reason: expect.stringContaining("was not rediscovered"),
|
||||
}),
|
||||
]));
|
||||
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
const projectScanSkills = persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan");
|
||||
expect(projectScanSkills).toHaveLength(1);
|
||||
expect(projectScanSkills[0]?.sourceLocator).toBe(selectedSkillDir);
|
||||
});
|
||||
|
||||
it("treats out-of-scope workspace selections as unmatched without leaking workspace metadata", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-scope-"));
|
||||
const otherCompanyId = randomUUID();
|
||||
const otherProjectId = randomUUID();
|
||||
const otherWorkspaceId = randomUUID();
|
||||
const otherWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-scope-other-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(otherWorkspaceDir);
|
||||
|
||||
const selectedSkillDir = path.join(workspaceDir, ".gemini", "skills", "selected-skill");
|
||||
const otherCompanySkillDir = path.join(otherWorkspaceDir, ".codex", "skills", "foreign-skill");
|
||||
await fs.mkdir(selectedSkillDir, { recursive: true });
|
||||
await fs.mkdir(otherCompanySkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(selectedSkillDir, "SKILL.md"), "---\nname: Selected Skill\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(otherCompanySkillDir, "SKILL.md"), "---\nname: Foreign Skill\n---\n", "utf8");
|
||||
|
||||
await db.insert(companies).values([
|
||||
{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
{
|
||||
id: otherCompanyId,
|
||||
name: "Other Company",
|
||||
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
]);
|
||||
await db.insert(projects).values([
|
||||
{ id: projectId, companyId, name: "Skills Project" },
|
||||
{ id: otherProjectId, companyId: otherCompanyId, name: "Other Project" },
|
||||
]);
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: otherWorkspaceId,
|
||||
companyId: otherCompanyId,
|
||||
projectId: otherProjectId,
|
||||
name: "Other Primary",
|
||||
cwd: otherWorkspaceDir,
|
||||
isPrimary: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
projectIds: [projectId],
|
||||
workspaceIds: [workspaceId, otherWorkspaceId],
|
||||
selection: [
|
||||
{ workspaceId, path: ".gemini/skills/selected-skill" },
|
||||
{ workspaceId: otherWorkspaceId, path: ".codex/skills/foreign-skill" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.scannedProjects).toBe(1);
|
||||
expect(result.scannedWorkspaces).toBe(1);
|
||||
expect(result.discovered).toBe(1);
|
||||
expect(result.imported).toHaveLength(1);
|
||||
expect(result.imported[0]).toMatchObject({
|
||||
name: "Selected Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: selectedSkillDir,
|
||||
metadata: expect.objectContaining({ sourceKind: "project_scan", workspaceId, projectId }),
|
||||
});
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
workspaceId,
|
||||
projectId,
|
||||
relativePath: ".gemini/skills/selected-skill",
|
||||
status: "new",
|
||||
}),
|
||||
]);
|
||||
expect(result.skipped).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
workspaceId: otherWorkspaceId,
|
||||
workspaceName: null,
|
||||
path: ".codex/skills/foreign-skill",
|
||||
reason: expect.stringContaining("was not rediscovered"),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("skips a selected project skill whose SKILL.md is a symlink outside the workspace", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-symlink-"));
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-outside-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(outsideDir);
|
||||
const linkedSkillDir = path.join(workspaceDir, ".codex", "skills", "linked-skill");
|
||||
const outsideSkillFile = path.join(outsideDir, "outside-skill.md");
|
||||
await fs.mkdir(linkedSkillDir, { recursive: true });
|
||||
await fs.writeFile(outsideSkillFile, "---\nname: Outside Skill\n---\n", "utf8");
|
||||
await fs.symlink(outsideSkillFile, path.join(linkedSkillDir, "SKILL.md"));
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/linked-skill" }],
|
||||
});
|
||||
|
||||
expect(result.imported).toEqual([]);
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
relativePath: ".codex/skills/linked-skill",
|
||||
status: "skipped",
|
||||
reason: expect.stringContaining("symbolic link"),
|
||||
}),
|
||||
]);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({
|
||||
workspaceId,
|
||||
path: linkedSkillDir,
|
||||
reason: expect.stringContaining("symbolic link"),
|
||||
}),
|
||||
]);
|
||||
expect(result.candidates[0]?.reason).not.toContain(workspaceDir);
|
||||
expect(result.candidates[0]?.reason).not.toContain(outsideDir);
|
||||
expect(result.skipped[0]?.reason).not.toContain(workspaceDir);
|
||||
expect(result.skipped[0]?.reason).not.toContain(outsideDir);
|
||||
expect(result.warnings.join("\n")).not.toContain(workspaceDir);
|
||||
expect(result.warnings.join("\n")).not.toContain(outsideDir);
|
||||
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
expect(persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -141,10 +141,25 @@ describe("project workspace skill discovery", () => {
|
||||
});
|
||||
|
||||
expect(discovered).toEqual([
|
||||
{ skillDir: path.resolve(workspace), inventoryMode: "project_root" },
|
||||
{ skillDir: path.resolve(workspace, ".agents", "skills", "release"), inventoryMode: "full" },
|
||||
{ skillDir: path.resolve(workspace, "skills", ".system", "paperclip"), inventoryMode: "full" },
|
||||
{ skillDir: path.resolve(workspace, "skills", "find-skills"), inventoryMode: "full" },
|
||||
{ skillDir: path.resolve(workspace), directoryRoot: ".", relativePath: ".", inventoryMode: "project_root" },
|
||||
{
|
||||
skillDir: path.resolve(workspace, ".agents", "skills", "release"),
|
||||
directoryRoot: ".agents/skills",
|
||||
relativePath: ".agents/skills/release",
|
||||
inventoryMode: "full",
|
||||
},
|
||||
{
|
||||
skillDir: path.resolve(workspace, "skills", ".system", "paperclip"),
|
||||
directoryRoot: "skills/.system",
|
||||
relativePath: "skills/.system/paperclip",
|
||||
inventoryMode: "full",
|
||||
},
|
||||
{
|
||||
skillDir: path.resolve(workspace, "skills", "find-skills"),
|
||||
directoryRoot: "skills",
|
||||
relativePath: "skills/find-skills",
|
||||
inventoryMode: "full",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -177,6 +192,22 @@ describe("project workspace skill discovery", () => {
|
||||
expect(imported.metadata?.sourceKind).toBe("project_scan");
|
||||
});
|
||||
|
||||
it("rejects symlinks reachable from a project-scanned skill", async () => {
|
||||
const workspace = await makeTempDir("paperclip-linked-skill-file-");
|
||||
const skillDir = path.join(workspace, ".codex", "skills", "linked-file");
|
||||
const outsideFile = path.join(await makeTempDir("paperclip-linked-skill-outside-"), "outside.md");
|
||||
await writeSkillDir(skillDir, "Linked File");
|
||||
await fs.mkdir(path.join(skillDir, "references"), { recursive: true });
|
||||
await fs.writeFile(outsideFile, "outside workspace\n", "utf8");
|
||||
await fs.symlink(outsideFile, path.join(skillDir, "references", "outside.md"));
|
||||
|
||||
await expect(readLocalSkillImportFromDirectory(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
skillDir,
|
||||
{ inventoryMode: "full", workspaceRoot: workspace },
|
||||
)).rejects.toThrow(/symbolic link/);
|
||||
});
|
||||
|
||||
it("parses inline object array items in skill frontmatter metadata", async () => {
|
||||
const workspace = await makeTempDir("paperclip-inline-skill-yaml-");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
|
||||
@@ -114,7 +114,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
async function seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
|
||||
@@ -917,7 +917,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
afterAll(async () => {
|
||||
await db.$client.end();
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
|
||||
const companyId = randomUUID();
|
||||
|
||||
@@ -165,6 +165,16 @@ describe("openapi routes", () => {
|
||||
},
|
||||
required: ["name"],
|
||||
});
|
||||
expect(JSON.stringify(res.body.paths["/api/companies"].post.responses)).not.toContain("candidates");
|
||||
expect(res.body.paths["/api/companies/{companyId}/skills/scan-projects"].post.responses["200"].content[
|
||||
"application/json"
|
||||
].schema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
candidates: { type: "array" },
|
||||
},
|
||||
required: expect.arrayContaining(["candidates"]),
|
||||
});
|
||||
expect(res.body.paths["/api/agents/{id}/keys"].post.requestBody.content["application/json"].schema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
@@ -299,7 +299,7 @@ describeEmbeddedPostgres("plugin install auto-build route", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("auto-builds bundled local plugins during POST /api/plugins/install when dist is missing", async () => {
|
||||
const fixture = await createBundledPluginFixture("success");
|
||||
|
||||
@@ -48,7 +48,7 @@ describeEmbeddedPostgres("productivity review service", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
async function seedAssignedIssue(opts?: {
|
||||
status?: "todo" | "in_progress";
|
||||
|
||||
Reference in new issue
Block a user