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**

![Choose a
project](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/01-pick-project.png)

**Review discovered skills**

![Review discovered
skills](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/03-select.png)

**Mobile selection footer**

![Mobile selection
footer](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/select-390.png)

**Import result**

![Import
result](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/06-result.png)

## 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:
authored and GitHub committed 2026-07-15 18:01:44 -05:00
1 parent 9af96461d5
commit 3ae2c30f2f
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([]);
});
});
+35 -4
View File
@@ -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";