mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
## 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>
248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
createAcpRuntime,
|
|
createAgentRegistry,
|
|
createRuntimeStore,
|
|
type AcpRuntimeOptions,
|
|
} from "acpx/runtime";
|
|
import {
|
|
commandVersion,
|
|
createMcpIsolationRoot,
|
|
runCommand,
|
|
writeClaudeMcpConfig,
|
|
writeCodexMcpConfig,
|
|
} from "./test-support/mcp-isolation-harness.js";
|
|
|
|
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
|
|
const stdioFixturePath = path.join(repoRoot, "scripts/mcp-fixtures/servers/stdio-fixture.mjs");
|
|
const acpFixturePath = path.join(repoRoot, "scripts/mcp-fixtures/servers/acp-isolation-agent.mjs");
|
|
const cleanupRoots: string[] = [];
|
|
|
|
interface McpObservation {
|
|
name: string;
|
|
tools: string[];
|
|
}
|
|
|
|
type McpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
cleanupRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
function fixtureServer(name: string): McpServer {
|
|
return {
|
|
name,
|
|
command: process.execPath,
|
|
args: [stdioFixturePath],
|
|
env: [],
|
|
};
|
|
}
|
|
|
|
async function runAcpxFixtureSession(
|
|
root: string,
|
|
sessionName: string,
|
|
mcpServers: McpServer[],
|
|
): Promise<McpObservation[]> {
|
|
const runtime = createAcpRuntime({
|
|
cwd: repoRoot,
|
|
sessionStore: createRuntimeStore({ stateDir: path.join(root, `acpx-${sessionName}`) }),
|
|
agentRegistry: createAgentRegistry({
|
|
overrides: {
|
|
isolation_fixture: `${process.execPath} ${acpFixturePath}`,
|
|
},
|
|
}),
|
|
mcpServers,
|
|
permissionMode: "deny-all",
|
|
nonInteractivePermissions: "deny",
|
|
timeoutMs: 10_000,
|
|
});
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: `isolation-${sessionName}`,
|
|
agent: "isolation_fixture",
|
|
mode: "oneshot",
|
|
cwd: repoRoot,
|
|
});
|
|
|
|
let output = "";
|
|
for await (const event of runtime.runTurn({
|
|
handle,
|
|
text: "List the MCP tools visible to this session.",
|
|
mode: "prompt",
|
|
requestId: `request-${sessionName}`,
|
|
})) {
|
|
if (event.type === "text_delta" && event.stream !== "thought") output += event.text;
|
|
}
|
|
|
|
await runtime.close({
|
|
handle,
|
|
reason: "isolation test complete",
|
|
discardPersistentState: true,
|
|
});
|
|
return JSON.parse(output) as McpObservation[];
|
|
}
|
|
|
|
async function startUnauthorizedAnthropicFixture(): Promise<{
|
|
baseUrl: string;
|
|
close: () => Promise<void>;
|
|
}> {
|
|
const server = http.createServer((_request, response) => {
|
|
response.writeHead(401, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ type: "error", error: { type: "authentication_error" } }));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Failed to bind Claude API fixture");
|
|
return {
|
|
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
};
|
|
}
|
|
|
|
describe("same-machine MCP isolation", () => {
|
|
it("passes disjoint MCP server sets through acpx session/new and tools/list", async () => {
|
|
const root = await createMcpIsolationRoot("paperclip-acpx-mcp-isolation-");
|
|
cleanupRoots.push(root);
|
|
|
|
const [alpha, beta, zero] = await Promise.all([
|
|
runAcpxFixtureSession(root, "alpha", [fixtureServer("agent_alpha")]),
|
|
runAcpxFixtureSession(root, "beta", [fixtureServer("agent_beta")]),
|
|
runAcpxFixtureSession(root, "zero", []),
|
|
]);
|
|
|
|
expect(alpha.map((entry) => entry.name)).toEqual(["agent_alpha"]);
|
|
expect(beta.map((entry) => entry.name)).toEqual(["agent_beta"]);
|
|
expect(alpha[0]?.tools).toContain("echo.echo");
|
|
expect(beta[0]?.tools).toContain("echo.echo");
|
|
expect(JSON.stringify(alpha)).not.toContain("agent_beta");
|
|
expect(JSON.stringify(beta)).not.toContain("agent_alpha");
|
|
expect(zero).toEqual([]);
|
|
}, 20_000);
|
|
|
|
it("keeps concurrent Claude CLI MCP configs strict and disjoint", async () => {
|
|
const version = await commandVersion("claude");
|
|
if (!version) return;
|
|
const claudeVersionMatch = version.match(/^2\.1\.(\d+) \(Claude Code\)$/);
|
|
expect(claudeVersionMatch).not.toBeNull();
|
|
expect(Number(claudeVersionMatch?.[1])).toBeGreaterThanOrEqual(207);
|
|
|
|
const root = await createMcpIsolationRoot("paperclip-claude-mcp-isolation-");
|
|
cleanupRoots.push(root);
|
|
const home = path.join(root, "home");
|
|
const alphaConfig = path.join(root, "alpha.json");
|
|
const betaConfig = path.join(root, "beta.json");
|
|
await fs.mkdir(home, { recursive: true });
|
|
await writeClaudeMcpConfig(path.join(home, ".claude.json"), {
|
|
user_pollution: { command: process.execPath, args: [stdioFixturePath] },
|
|
});
|
|
await writeClaudeMcpConfig(alphaConfig, {
|
|
agent_alpha: { command: process.execPath, args: [stdioFixturePath] },
|
|
});
|
|
await writeClaudeMcpConfig(betaConfig, {
|
|
agent_beta: { command: process.execPath, args: [stdioFixturePath] },
|
|
});
|
|
const apiFixture = await startUnauthorizedAnthropicFixture();
|
|
|
|
const runClaude = async (name: string, configPath?: string) => {
|
|
const debugPath = path.join(root, `${name}.debug.log`);
|
|
const args = ["-p"];
|
|
if (configPath) args.push("--mcp-config", configPath);
|
|
args.push(
|
|
"--strict-mcp-config",
|
|
"--debug-file",
|
|
debugPath,
|
|
"Reply with OK.",
|
|
);
|
|
const result = await runCommand("claude", args, {
|
|
cwd: repoRoot,
|
|
timeoutMs: 4_000,
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
CLAUDE_CONFIG_DIR: undefined,
|
|
ANTHROPIC_API_KEY: "paperclip-invalid-test-key",
|
|
ANTHROPIC_BASE_URL: apiFixture.baseUrl,
|
|
},
|
|
});
|
|
expect(result.exitCode === 0 || result.timedOut || result.exitCode === 1).toBe(true);
|
|
return fs.readFile(debugPath, "utf8");
|
|
};
|
|
|
|
try {
|
|
const [alphaLog, betaLog, zeroLog] = await Promise.all([
|
|
runClaude("alpha", alphaConfig),
|
|
runClaude("beta", betaConfig),
|
|
runClaude("zero"),
|
|
]);
|
|
|
|
expect(alphaLog).toContain('MCP server "agent_alpha": Successfully connected');
|
|
expect(betaLog).toContain('MCP server "agent_beta": Successfully connected');
|
|
expect(alphaLog).not.toContain('MCP server "agent_beta"');
|
|
expect(betaLog).not.toContain('MCP server "agent_alpha"');
|
|
for (const log of [alphaLog, betaLog, zeroLog]) {
|
|
expect(log).not.toContain('MCP server "user_pollution"');
|
|
}
|
|
expect(zeroLog).not.toMatch(/MCP server "[^"]+": Successfully connected/);
|
|
} finally {
|
|
await apiFixture.close();
|
|
}
|
|
}, 20_000);
|
|
|
|
it("keeps concurrent Codex homes disjoint and supports CLI MCP overrides", async () => {
|
|
const version = await commandVersion("codex");
|
|
if (!version) return;
|
|
expect(version).toMatch(/^codex-cli \d+\.\d+\.\d+$/);
|
|
|
|
const root = await createMcpIsolationRoot("paperclip-codex-mcp-isolation-");
|
|
cleanupRoots.push(root);
|
|
const home = path.join(root, "home");
|
|
const alphaHome = path.join(root, "codex-alpha");
|
|
const betaHome = path.join(root, "codex-beta");
|
|
const zeroHome = path.join(root, "codex-zero");
|
|
await writeCodexMcpConfig(path.join(home, ".codex"), {
|
|
user_pollution: { command: process.execPath, args: [stdioFixturePath] },
|
|
});
|
|
await writeCodexMcpConfig(alphaHome, {
|
|
agent_alpha: { command: process.execPath, args: [stdioFixturePath] },
|
|
});
|
|
await writeCodexMcpConfig(betaHome, {
|
|
agent_beta: { command: process.execPath, args: [stdioFixturePath] },
|
|
});
|
|
await fs.mkdir(zeroHome, { recursive: true });
|
|
|
|
const runList = (codexHome: string, args: string[] = []) =>
|
|
runCommand("codex", [...args, "mcp", "list"], {
|
|
cwd: repoRoot,
|
|
env: { ...process.env, HOME: home, CODEX_HOME: codexHome },
|
|
});
|
|
const [alpha, beta, zero, override] = await Promise.all([
|
|
runList(alphaHome),
|
|
runList(betaHome),
|
|
runList(zeroHome),
|
|
runList(zeroHome, [
|
|
"-c",
|
|
`mcp_servers.override_agent.command=${JSON.stringify(process.execPath)}`,
|
|
"-c",
|
|
`mcp_servers.override_agent.args=${JSON.stringify([stdioFixturePath])}`,
|
|
]),
|
|
]);
|
|
|
|
for (const result of [alpha, beta, zero, override]) {
|
|
expect(result.timedOut).toBe(false);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(`${result.stdout}${result.stderr}`).not.toContain("user_pollution");
|
|
}
|
|
expect(alpha.stdout).toContain("agent_alpha");
|
|
expect(alpha.stdout).not.toContain("agent_beta");
|
|
expect(beta.stdout).toContain("agent_beta");
|
|
expect(beta.stdout).not.toContain("agent_alpha");
|
|
expect(zero.stdout).toContain("No MCP servers configured yet");
|
|
expect(override.stdout).toContain("override_agent");
|
|
}, 20_000);
|
|
});
|