diff --git a/Dockerfile b/Dockerfile index 6fa685d9..ec82a186 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw- COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ COPY packages/plugins/sdk/package.json packages/plugins/sdk/ +COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/ COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/ COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/ COPY patches/ patches/ diff --git a/cli/src/__tests__/home-paths.test.ts b/cli/src/__tests__/home-paths.test.ts index 1d9c654e..f06d2c22 100644 --- a/cli/src/__tests__/home-paths.test.ts +++ b/cli/src/__tests__/home-paths.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -16,13 +17,14 @@ describe("home path resolution", () => { }); it("defaults to ~/.paperclip and default instance", () => { - delete process.env.PAPERCLIP_HOME; + const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-")); + process.env.PAPERCLIP_HOME = home; delete process.env.PAPERCLIP_INSTANCE_ID; const paths = describeLocalInstancePaths(); - expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip")); + expect(paths.homeDir).toBe(home); expect(paths.instanceId).toBe("default"); - expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json")); + expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "config.json")); }); it("supports PAPERCLIP_HOME and explicit instance ids", () => { @@ -34,7 +36,7 @@ describe("home path resolution", () => { }); it("rejects invalid instance ids", () => { - expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/); + expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid PAPERCLIP_INSTANCE_ID/); }); it("expands ~ prefixes", () => { diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index b66c20fa..0c694f4b 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -6,6 +6,7 @@ import { onboard } from "../commands/onboard.js"; import type { PaperclipConfig } from "../config/schema.js"; const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_CWD = process.cwd(); function createExistingConfigFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-")); @@ -85,10 +86,18 @@ describe("onboard", () => { delete process.env.PAPERCLIP_AGENT_JWT_SECRET; delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_CONFIG; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_BIND; + delete process.env.PAPERCLIP_BIND_HOST; + delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + delete process.env.HOST; }); afterEach(() => { process.env = { ...ORIGINAL_ENV }; + process.chdir(ORIGINAL_CWD); }); it("preserves an existing config when rerun without flags", async () => { @@ -125,6 +134,27 @@ describe("onboard", () => { expect(raw.server.host).toBe("127.0.0.1"); }); + it("creates instance-root config and data paths for a fresh PAPERCLIP_HOME", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-home-")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-cwd-")); + process.chdir(cwd); + process.env.PAPERCLIP_HOME = home; + + await onboard({ yes: true, invokedByRun: true }); + + const instanceRoot = path.join(home, "instances", "default"); + const configPath = path.join(instanceRoot, "config.json"); + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; + + expect(raw.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(raw.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups")); + expect(raw.logging.logDir).toBe(path.join(instanceRoot, "logs")); + expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage")); + expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true); + expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true); + }); + it("supports authenticated/private quickstart bind presets", async () => { const configPath = createFreshConfigPath(); process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8"; diff --git a/cli/src/config/home.ts b/cli/src/config/home.ts index ef4d8e09..86f1ab4a 100644 --- a/cli/src/config/home.ts +++ b/cli/src/config/home.ts @@ -1,32 +1,31 @@ -import os from "node:os"; import path from "node:path"; +import { + expandHomePrefix, + resolveDefaultBackupDir as resolveSharedDefaultBackupDir, + resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir, + resolveDefaultLogsDir as resolveSharedDefaultLogsDir, + resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath, + resolveDefaultStorageDir as resolveSharedDefaultStorageDir, + resolveHomeAwarePath, + resolvePaperclipConfigPathForInstance, + resolvePaperclipHomeDir, + resolvePaperclipInstanceId, + resolvePaperclipInstanceRoot as resolveSharedPaperclipInstanceRoot, +} from "@paperclipai/shared/home-paths"; -const DEFAULT_INSTANCE_ID = "default"; -const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/; - -export function resolvePaperclipHomeDir(): string { - const envHome = process.env.PAPERCLIP_HOME?.trim(); - if (envHome) return path.resolve(expandHomePrefix(envHome)); - return path.resolve(os.homedir(), ".paperclip"); -} - -export function resolvePaperclipInstanceId(override?: string): string { - const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; - if (!INSTANCE_ID_RE.test(raw)) { - throw new Error( - `Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`, - ); - } - return raw; -} +export { + expandHomePrefix, + resolveHomeAwarePath, + resolvePaperclipHomeDir, + resolvePaperclipInstanceId, +}; export function resolvePaperclipInstanceRoot(instanceId?: string): string { - const id = resolvePaperclipInstanceId(instanceId); - return path.resolve(resolvePaperclipHomeDir(), "instances", id); + return resolveSharedPaperclipInstanceRoot({ instanceId }); } export function resolveDefaultConfigPath(instanceId?: string): string { - return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json"); + return resolvePaperclipConfigPathForInstance({ instanceId }); } export function resolveDefaultContextPath(): string { @@ -38,29 +37,23 @@ export function resolveDefaultCliAuthPath(): string { } export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string { - return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db"); + return resolveSharedDefaultEmbeddedPostgresDir({ instanceId }); } export function resolveDefaultLogsDir(instanceId?: string): string { - return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs"); + return resolveSharedDefaultLogsDir({ instanceId }); } export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string { - return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key"); + return resolveSharedDefaultSecretsKeyFilePath({ instanceId }); } export function resolveDefaultStorageDir(instanceId?: string): string { - return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage"); + return resolveSharedDefaultStorageDir({ instanceId }); } export function resolveDefaultBackupDir(instanceId?: string): string { - return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups"); -} - -export function expandHomePrefix(value: string): string { - if (value === "~") return os.homedir(); - if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); - return value; + return resolveSharedDefaultBackupDir({ instanceId }); } export function describeLocalInstancePaths(instanceId?: string) { diff --git a/doc/CLI.md b/doc/CLI.md index 29c494e3..3ce01142 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -204,7 +204,28 @@ pnpm paperclipai heartbeat run --agent-id [--api-base http://localhos ## Local Storage Defaults -Default local instance root is `~/.paperclip/instances/default`: +Local Paperclip data lives under the selected instance root. `PAPERCLIP_HOME` chooses the home directory and `PAPERCLIP_INSTANCE_ID` chooses the instance. + +```text +~/.paperclip/ # PAPERCLIP_HOME +└── instances/ + └── default/ # instance root (PAPERCLIP_INSTANCE_ID) + ├── config.json # runtime config + ├── .env # instance env file + ├── db/ # embedded PostgreSQL data + ├── data/ + │ ├── storage/ # local_disk uploads + │ └── backups/ # automatic DB backups + ├── logs/ + ├── secrets/ + │ └── master.key # local_encrypted master key + ├── workspaces/ # default agent workspaces + ├── projects/ # project execution workspaces + ├── companies/ # per-company adapter homes (e.g. codex-home) + └── codex-home/ # per-instance codex home (when not company-scoped) +``` + +Default paths for the canonical install: - config: `~/.paperclip/instances/default/config.json` - embedded db: `~/.paperclip/instances/default/db` diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c9a2a194..f41465e2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -157,6 +157,27 @@ See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`. +## Local Instance Layout + +Every local install keeps runtime state directly under the selected instance root: + +```text +~/.paperclip/instances/default/ # instance root + config.json # runtime config + .env # instance env file + db/ # embedded PostgreSQL data + data/ + storage/ # local_disk uploads + backups/ # automatic DB backups + logs/ + secrets/master.key # local_encrypted master key + workspaces// # default agent workspaces + projects/ # project execution workspaces + companies//codex-home/ # per-company codex_local home +``` + +`PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` override the home root and instance id respectively. `paperclipai onboard` echoes the resolved values in its banner (`Local home: | instance: | config: `) so you can confirm where state will land before continuing. + ## Database in Dev (Auto-Handled) For local development, leave `DATABASE_URL` unset. @@ -164,7 +185,7 @@ The server will automatically use embedded PostgreSQL and persist data at: - `~/.paperclip/instances/default/db` -Override home and instance: +Override home or instance: ```sh PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run @@ -280,7 +301,7 @@ paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --force ``` -Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install: +Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install. Point `--from-config` at the instance config: ```sh cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component diff --git a/doc/plans/2026-05-06-llm-wiki-paperclip-asset-security-gate.md b/doc/plans/2026-05-06-llm-wiki-paperclip-asset-security-gate.md new file mode 100644 index 00000000..cb527ae7 --- /dev/null +++ b/doc/plans/2026-05-06-llm-wiki-paperclip-asset-security-gate.md @@ -0,0 +1,135 @@ +# LLM Wiki Paperclip Asset And Work-Product Security Gate + +Status: accepted Phase 5 policy +Date: 2026-05-06 +Owner: Security engineering +Scope: Paperclip-derived ingestion into the LLM Wiki before any asset or work-product content indexing ships + +## Decision + +Phase 5 remains **fail-closed** for Paperclip assets and work products. + +- Paperclip-derived **text extraction is allowed only** for issue titles/descriptions, issue comments, and issue documents. +- Paperclip **assets/attachments** and **issue work products** are **metadata-only** in Phase 5. +- **Linked summaries** and **content extraction** for assets/work products are **not approved** in Phase 5. +- No implementation may fetch `/api/assets/:id/content`, dereference a work-product `url`, scrape preview pages, or embed binary/blob content into source bundles or source snapshots. + +This keeps the secure path easier than the insecure one and avoids broadening the wiki into a second content-distribution channel. + +## Allowed Source Kinds + +These source kinds may contribute body text to Paperclip-derived source bundles: + +| Source kind | Allowed body fields | Reason | +| --- | --- | --- | +| Issue | `title`, `description`, identifier/status metadata | First-party Paperclip text under company ACL | +| Comment | `body` | First-party Paperclip text under company ACL | +| Document | `body`, `title`, `key`, revision metadata | First-party Paperclip text under company ACL | + +## Assets And Work Products + +### Assets / attachments + +Allowed in Phase 5: + +- metadata-only references built from allowlisted structured fields already stored in Paperclip +- recommended fields: `issueId`, `issueCommentId`, `attachmentId`, `assetId`, `originalFilename`, `contentType`, `byteSize`, `sha256`, `createdAt`, `createdByAgentId`, `createdByUserId` + +Disallowed in Phase 5: + +- fetching asset bytes from `/api/assets/:id/content` +- parsing any blob body, including `text/plain`, `text/markdown`, `application/json`, images, SVG, PDFs, archives, or office formats +- storing `contentPath` in wiki source bundles or source snapshots +- model summarization of attachment bodies + +### Work products + +Allowed in Phase 5: + +- metadata-only references built from allowlisted structured fields already stored in Paperclip +- recommended fields: `issueId`, `workProductId`, `type`, `provider`, `title`, `status`, `reviewState`, `healthStatus`, `externalId`, `isPrimary`, `createdAt`, `updatedAt` +- optional boolean/derived metadata such as `hasUrl: true` + +Disallowed in Phase 5: + +- fetching or crawling the work-product `url` +- scraping preview pages, artifacts, pull requests, branches, commits, or custom provider targets through the wiki ingestion path +- storing raw `url` values in wiki source bundles or source snapshots +- model-authored linked summaries derived from off-record content + +## MIME Allowlists And Size Caps + +No MIME allowlist is approved for asset content extraction in Phase 5 because **no asset body extraction is approved at all**. + +- Every asset MIME type is treated as opaque for Paperclip-derived indexing. +- Existing upload limits remain storage concerns, not ingestion approvals. +- Work-product destinations are also opaque regardless of MIME type or size. + +Any future issue that wants blob parsing must define: + +- a positive MIME allowlist +- per-type parser strategy +- per-source size caps +- sandbox/isolation requirements +- prompt-injection handling +- regression tests for refusal paths + +## Redaction Rules + +Metadata-only means **structured facts only**, not capability-bearing links. + +- Do not persist `contentPath` for assets. +- Do not persist raw work-product `url` values. +- Do not persist query strings, fragments, signed URL tokens, or userinfo. +- Prefer stable identifiers (`assetId`, `workProductId`, `externalId`) over links. + +This addresses Sensitive Information Disclosure, Unsafe Consumption of APIs, and Insecure Output Handling risks. + +## Provenance Rules + +Every metadata-only reference must preserve enough provenance to explain where it came from without reading the underlying content: + +- `companyId` +- `issueId` +- attachment/work-product id +- producer identity when available +- timestamps +- an explicit `metadata_only` marker in any future reference/snapshot schema + +## Review-Required Behavior + +Human review is **not** required for plain metadata-only references that stay inside the allowlisted fields above. + +Human review **is required**, with a separate security sign-off issue, before enabling any of the following: + +- asset body extraction +- work-product URL fetching +- linked summaries generated from asset/work-product content +- storing raw blob links or raw remote URLs in wiki source material +- non-default-space routing for Paperclip-derived asset/work-product references + +## Security Rationale + +This gate exists because the current host surfaces have different trust properties: + +- issue/comment/document text is first-party Paperclip content already exposed through company-scoped issue/document APIs +- asset content is a blob download surface (`/api/assets/:id/content`) and can carry prompt-injection or parser-risk payloads +- work products can point at arbitrary destinations through `url`, which reintroduces SSRF, token leakage, and prompt-injection risk if dereferenced automatically + +Relevant threat classes: + +- OWASP LLM Top 10: Prompt Injection, Sensitive Information Disclosure, Insecure Output Handling, Excessive Agency +- OWASP API Top 10: SSRF, Unsafe Consumption of APIs, Broken Object Property Level Authorization +- Saltzer & Schroeder: Least Privilege, Fail Securely, Complete Mediation, Secure Defaults + +## Follow-Up Implementation Scope + +A follow-up implementation issue is justified only for **metadata-only references**. + +That implementation must: + +- keep assets/work products out of source-bundle body text +- never fetch blob bytes or remote URLs +- redact capability-bearing link fields +- mark references as `metadata_only` +- ship tests proving source bundles/snapshots never contain `contentPath` or raw work-product `url` fields diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index bc3bc4a4..4624f637 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js"; @@ -78,6 +79,8 @@ export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024; +const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; +const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/; const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; const REDACTED_LOG_VALUE = "***REDACTED***"; const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ @@ -88,6 +91,25 @@ const MATERIALIZED_SKILL_SENTINEL = ".paperclip-materialized-skill.json"; const MATERIALIZED_SKILL_LOCK_OWNER = "owner.json"; const MATERIALIZED_SKILL_LOCK_STALE_MS = 30_000; +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +export function resolvePaperclipInstanceRootForAdapter(input: { + homeDir?: string; + instanceId?: string; + env?: NodeJS.ProcessEnv; +} = {}): string { + const env = input.env ?? process.env; + const homeRaw = input.homeDir?.trim() || env.PAPERCLIP_HOME?.trim(); + const homeDir = path.resolve(homeRaw ? expandHomePrefix(homeRaw) : path.resolve(os.homedir(), ".paperclip")); + const instanceId = input.instanceId?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID; + if (!PATH_SEGMENT_RE.test(instanceId)) throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${instanceId}'.`); + return path.resolve(homeDir, "instances", instanceId); +} + export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", "", diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 3450cb9e..4914af44 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -21,6 +21,7 @@ import { readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, renderTemplate, + resolvePaperclipInstanceRootForAdapter, resolvePaperclipDesiredSkillNames, rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, @@ -115,7 +116,10 @@ function shortHash(value: unknown): string { function defaultPaperclipInstanceDir(): string { const home = process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip"); const instanceId = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default"; - return path.join(home, "instances", instanceId); + return resolvePaperclipInstanceRootForAdapter({ + homeDir: home, + instanceId, + }); } function defaultStateDir(companyId: string, agentId: string): string { diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index 5e4f2278..c7c0a88e 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -3,8 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; -const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; const SEEDED_SHARED_FILES = [ ".credentials.json", "credentials.json", @@ -92,11 +92,14 @@ export function resolveManagedClaudeConfigSeedDir( env: NodeJS.ProcessEnv, companyId?: string, ): string { - const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip"); - const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ + homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, + env, + }); return companyId - ? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-config-seed") - : path.resolve(paperclipHome, "instances", instanceId, "claude-config-seed"); + ? path.resolve(instanceRoot, "companies", companyId, "claude-config-seed") + : path.resolve(instanceRoot, "claude-config-seed"); } export async function prepareClaudeConfigSeed( diff --git a/packages/adapters/claude-local/src/server/prompt-cache.ts b/packages/adapters/claude-local/src/server/prompt-cache.ts index ce719799..19c18809 100644 --- a/packages/adapters/claude-local/src/server/prompt-cache.ts +++ b/packages/adapters/claude-local/src/server/prompt-cache.ts @@ -1,12 +1,13 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { createHash, type Hash } from "node:crypto"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { ensurePaperclipSkillSymlink, type PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; - -const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; +import { + ensurePaperclipSkillSymlink, + resolvePaperclipInstanceRootForAdapter, + type PaperclipSkillEntry, +} from "@paperclipai/adapter-utils/server-utils"; type SkillEntry = PaperclipSkillEntry; @@ -25,12 +26,13 @@ function resolveManagedClaudePromptCacheRoot( env: NodeJS.ProcessEnv, companyId: string, ): string { - const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip"); - const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ + homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, + env, + }); return path.resolve( - paperclipHome, - "instances", - instanceId, + instanceRoot, "companies", companyId, "claude-prompt-cache", diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 10b3ae96..0cb737bb 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -2,11 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i; const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const; const SYMLINKED_SHARED_FILES = ["auth.json"] as const; -const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; function nonEmpty(value: string | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -31,11 +31,14 @@ export function resolveManagedCodexHomeDir( env: NodeJS.ProcessEnv, companyId?: string, ): string { - const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip"); - const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ + homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, + env, + }); return companyId - ? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home") - : path.resolve(paperclipHome, "instances", instanceId, "codex-home"); + ? path.resolve(instanceRoot, "companies", companyId, "codex-home") + : path.resolve(instanceRoot, "codex-home"); } async function ensureParentDir(target: string): Promise { diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index c11822c9..13633a70 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -1,7 +1,11 @@ import { existsSync, readFileSync } from "node:fs"; -import os from "node:os"; import path from "node:path"; import { formatDatabaseBackupResult, runDatabaseBackup } from "./backup-lib.js"; +import { + expandHomePrefix, + resolveDefaultBackupDir, + resolvePaperclipConfigPathForInstance, +} from "@paperclipai/shared/home-paths"; type PartialConfig = { database?: { @@ -15,30 +19,6 @@ type PartialConfig = { }; }; -function expandHomePrefix(value: string): string { - if (value === "~") return os.homedir(); - if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); - return value; -} - -function resolvePaperclipHomeDir(): string { - const envHome = process.env.PAPERCLIP_HOME?.trim(); - if (envHome) return path.resolve(expandHomePrefix(envHome)); - return path.resolve(os.homedir(), ".paperclip"); -} - -function resolvePaperclipInstanceId(): string { - const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default"; - if (!/^[a-zA-Z0-9_-]+$/.test(raw)) { - throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); - } - return raw; -} - -function resolveDefaultConfigPath(): string { - return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "config.json"); -} - function readConfig(configPath: string): PartialConfig | null { if (!existsSync(configPath)) return null; try { @@ -72,10 +52,6 @@ function resolveConnectionString(config: PartialConfig | null): string { return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; } -function resolveDefaultBackupDir(): string { - return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "data", "backups"); -} - function resolveBackupDir(config: PartialConfig | null): string { const raw = config?.database?.backup?.dir; if (typeof raw === "string" && raw.trim().length > 0) { @@ -89,7 +65,7 @@ function resolveRetentionDays(config: PartialConfig | null): number { } async function main() { - const configPath = resolveDefaultConfigPath(); + const configPath = resolvePaperclipConfigPathForInstance(); const config = readConfig(configPath); const connectionString = resolveConnectionString(config); const backupDir = resolveBackupDir(config); diff --git a/packages/db/src/runtime-config.test.ts b/packages/db/src/runtime-config.test.ts index 4627e691..a958b89c 100644 --- a/packages/db/src/runtime-config.test.ts +++ b/packages/db/src/runtime-config.test.ts @@ -105,4 +105,25 @@ describe("resolveDatabaseTarget", () => { source: "embedded-postgres@55444", }); }); + + it("uses the instance root for a fresh default embedded postgres target", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-home-")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-cwd-")); + process.chdir(cwd); + process.env.PAPERCLIP_HOME = home; + delete process.env.PAPERCLIP_CONFIG; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.DATABASE_URL; + + const target = resolveDatabaseTarget(); + + expect(target).toMatchObject({ + mode: "embedded-postgres", + dataDir: path.join(home, "instances", "default", "db"), + port: 54329, + source: "embedded-postgres@54329", + configPath: path.join(home, "instances", "default", "config.json"), + envPath: path.join(home, "instances", "default", ".env"), + }); + }); }); diff --git a/packages/db/src/runtime-config.ts b/packages/db/src/runtime-config.ts index c6c64a38..3ad275c0 100644 --- a/packages/db/src/runtime-config.ts +++ b/packages/db/src/runtime-config.ts @@ -1,11 +1,13 @@ import { existsSync, readFileSync } from "node:fs"; -import os from "node:os"; import path from "node:path"; +import { + expandHomePrefix, + resolveDefaultEmbeddedPostgresDir, + resolvePaperclipConfigPathForInstance, + resolvePaperclipEnvPathForConfig, +} from "@paperclipai/shared/home-paths"; -const DEFAULT_INSTANCE_ID = "default"; const CONFIG_BASENAME = "config.json"; -const ENV_BASENAME = ".env"; -const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/; type PartialConfig = { database?: { @@ -35,39 +37,6 @@ export type ResolvedDatabaseTarget = envPath: string; }; -function expandHomePrefix(value: string): string { - if (value === "~") return os.homedir(); - if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); - return value; -} - -function resolvePaperclipHomeDir(): string { - const envHome = process.env.PAPERCLIP_HOME?.trim(); - if (envHome) return path.resolve(expandHomePrefix(envHome)); - return path.resolve(os.homedir(), ".paperclip"); -} - -function resolvePaperclipInstanceId(): string { - const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; - if (!INSTANCE_ID_RE.test(raw)) { - throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); - } - return raw; -} - -function resolveDefaultConfigPath(): string { - return path.resolve( - resolvePaperclipHomeDir(), - "instances", - resolvePaperclipInstanceId(), - CONFIG_BASENAME, - ); -} - -function resolveDefaultEmbeddedPostgresDir(): string { - return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db"); -} - function resolveHomeAwarePath(value: string): string { return path.resolve(expandHomePrefix(value)); } @@ -89,11 +58,11 @@ function resolvePaperclipConfigPath(): string { if (process.env.PAPERCLIP_CONFIG?.trim()) { return path.resolve(process.env.PAPERCLIP_CONFIG.trim()); } - return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(); + return findConfigFileFromAncestors(process.cwd()) ?? resolvePaperclipConfigPathForInstance(); } function resolvePaperclipEnvPath(configPath: string): string { - return path.resolve(path.dirname(configPath), ENV_BASENAME); + return resolvePaperclipEnvPathForConfig(configPath); } function parseEnvFile(contents: string): Record { diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 13f0a592..603ecd4f 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -98,6 +98,7 @@ export interface HostServices { list(params: WorkerToHostMethods["localFolders.list"][0]): Promise; readText(params: WorkerToHostMethods["localFolders.readText"][0]): Promise; writeTextAtomic(params: WorkerToHostMethods["localFolders.writeTextAtomic"][0]): Promise; + deleteFile(params: WorkerToHostMethods["localFolders.deleteFile"][0]): Promise; }; /** Provides `state.get`, `state.set`, `state.delete`. */ @@ -189,6 +190,13 @@ export interface HostServices { managedRun(params: WorkerToHostMethods["routines.managed.run"][0]): Promise; }; + /** Provides `skills.managed.*`. */ + skills: { + managedGet(params: WorkerToHostMethods["skills.managed.get"][0]): Promise; + managedReconcile(params: WorkerToHostMethods["skills.managed.reconcile"][0]): Promise; + managedReset(params: WorkerToHostMethods["skills.managed.reset"][0]): Promise; + }; + /** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */ issues: { list(params: WorkerToHostMethods["issues.list"][0]): Promise; @@ -313,6 +321,7 @@ const METHOD_CAPABILITY_MAP: Record { return services.localFolders.writeTextAtomic(params); }), + "localFolders.deleteFile": gated("localFolders.deleteFile", async (params) => { + return services.localFolders.deleteFile(params); + }), // State "state.get": gated("state.get", async (params) => { @@ -620,6 +635,17 @@ export function createHostClientHandlers( return services.routines.managedRun(params); }), + // Skills + "skills.managed.get": gated("skills.managed.get", async (params) => { + return services.skills.managedGet(params); + }), + "skills.managed.reconcile": gated("skills.managed.reconcile", async (params) => { + return services.skills.managedReconcile(params); + }), + "skills.managed.reset": gated("skills.managed.reset", async (params) => { + return services.skills.managedReset(params); + }), + // Issues "issues.list": gated("issues.list", async (params) => { return services.issues.list(params); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 3d0bd07e..2753d72c 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -197,6 +197,7 @@ export type { PluginStateClient, PluginEntitiesClient, PluginProjectsClient, + PluginSkillsClient, PluginCompaniesClient, PluginIssuesClient, PluginIssueMutationActor, @@ -268,6 +269,10 @@ export type { PluginManagedProjectResolution, PluginManagedRoutineDeclaration, PluginManagedRoutineResolution, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, + PluginManagedSkillResolution, + CompanySkill, PluginManagedResourceKind, PluginManagedResourceRef, PluginUiSlotDeclaration, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 843f479b..c297c6b0 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -27,11 +27,13 @@ import type { IssueComment, IssueDocument, IssueDocumentSummary, + IssueAssigneeAdapterOverrides, IssueThreadInteraction, CreateIssueThreadInteraction, PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, Routine, RoutineRun, Agent, @@ -611,6 +613,10 @@ export interface WorkerToHostMethods { }, result: PluginLocalFolderStatus, ]; + "localFolders.deleteFile": [ + params: { companyId: string; folderKey: string; relativePath: string }, + result: PluginLocalFolderStatus, + ]; // State "state.get": [ @@ -821,6 +827,18 @@ export interface WorkerToHostMethods { }, result: RoutineRun, ]; + "skills.managed.get": [ + params: { skillKey: string; companyId: string }, + result: PluginManagedSkillResolution, + ]; + "skills.managed.reconcile": [ + params: { skillKey: string; companyId: string }, + result: PluginManagedSkillResolution, + ]; + "skills.managed.reset": [ + params: { skillKey: string; companyId: string }, + result: PluginManagedSkillResolution, + ]; // Issues "issues.list": [ @@ -852,12 +870,12 @@ export interface WorkerToHostMethods { title: string; description?: string; status?: string; - workMode?: string; priority?: string; assigneeAgentId?: string; assigneeUserId?: string | null; requestDepth?: number; billingCode?: string | null; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; surfaceVisibility?: string | null; originKind?: string | null; originId?: string | null; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index fdc81f1c..ce2e2d40 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -7,6 +7,8 @@ import type { PluginIssueOriginKind, PluginManagedAgentResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, + CompanySkill, Company, Project, Routine, @@ -33,6 +35,8 @@ import type { PluginWorkspace, AgentSession, AgentSessionEvent, + PluginLocalFolderEntry, + PluginLocalFolderStatus, } from "./types.js"; import type { PluginEnvironmentValidateConfigParams, @@ -434,6 +438,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const agents = new Map(); const goals = new Map(); const projectWorkspaces = new Map(); + const localFolderStatuses = new Map(); + const localFolderFiles = new Map(); const sessions = new Map(); const sessionEventCallbacks = new Map void>(); @@ -445,6 +451,43 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const actionHandlers = new Map) => Promise>(); const toolHandlers = new Map Promise>(); + function localFolderKey(companyId: string, folderKey: string): string { + return `${companyId}:${folderKey}`; + } + + function localFolderFileKey(companyId: string, folderKey: string, relativePath: string): string { + return `${localFolderKey(companyId, folderKey)}:${relativePath}`; + } + + function normalizeLocalFolderRelativePath(relativePath: string): string { + const parts: string[] = []; + for (const segment of relativePath.split(/[\\/]+/)) { + if (!segment || segment === ".") continue; + if (segment === "..") throw new Error("Local folder path traversal is not allowed"); + parts.push(segment); + } + return parts.join("/"); + } + + function notConfiguredLocalFolderStatus(folderKey: string): PluginLocalFolderStatus { + return { + folderKey, + configured: false, + path: null, + realPath: null, + access: "readWrite", + readable: false, + writable: false, + requiredDirectories: [], + requiredFiles: [], + missingDirectories: [], + missingFiles: [], + healthy: false, + problems: [{ code: "not_configured", message: "No local folder path is configured." }], + checkedAt: new Date().toISOString(), + }; + } + function issueRelationSummary(issueId: string) { const issue = issues.get(issueId); if (!issue) throw new Error(`Issue not found: ${issueId}`); @@ -541,7 +584,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, async configure(input) { requireCapability(manifest, capabilitySet, "local.folders"); - return { + const status = { folderKey: input.folderKey, configured: true, path: input.path, @@ -556,58 +599,98 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { healthy: true, problems: [], checkedAt: new Date().toISOString(), - }; + } satisfies PluginLocalFolderStatus; + localFolderStatuses.set(localFolderKey(input.companyId, input.folderKey), status); + return status; }, - async status(_companyId, folderKey) { + async status(companyId, folderKey) { requireCapability(manifest, capabilitySet, "local.folders"); - return { - folderKey, - configured: false, - path: null, - realPath: null, - access: "readWrite", - readable: false, - writable: false, - requiredDirectories: [], - requiredFiles: [], - missingDirectories: [], - missingFiles: [], - healthy: false, - problems: [{ code: "not_configured", message: "No local folder path is configured." }], - checkedAt: new Date().toISOString(), - }; + return localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? notConfiguredLocalFolderStatus(folderKey); }, - async list(_companyId, folderKey, options) { + async list(companyId, folderKey, options) { requireCapability(manifest, capabilitySet, "local.folders"); + const status = localFolderStatuses.get(localFolderKey(companyId, folderKey)); + if (!status?.configured) throw new Error("Local folder is not configured"); + const prefix = normalizeLocalFolderRelativePath(options?.relativePath ?? ""); + const prefixWithSlash = prefix ? `${prefix}/` : ""; + const entries = new Map(); + for (const [key, contents] of localFolderFiles) { + const filePrefix = `${localFolderKey(companyId, folderKey)}:`; + if (!key.startsWith(filePrefix)) continue; + const filePath = key.slice(filePrefix.length); + if (prefix && filePath !== prefix && !filePath.startsWith(prefixWithSlash)) continue; + const remainder = prefix ? filePath.slice(prefixWithSlash.length) : filePath; + const [name] = remainder.split("/"); + if (!name) continue; + const entryPath = prefix ? `${prefix}/${name}` : name; + const isNested = remainder.includes("/"); + if (!options?.recursive && isNested) { + entries.set(entryPath, { + path: entryPath, + name, + kind: "directory", + size: null, + modifiedAt: null, + }); + continue; + } + entries.set(filePath, { + path: filePath, + name: filePath.split("/").pop() ?? filePath, + kind: "file", + size: Buffer.byteLength(contents, "utf8"), + modifiedAt: null, + }); + } + const maxEntries = options?.maxEntries && options.maxEntries > 0 ? options.maxEntries : entries.size; + const allEntries = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path)); return { folderKey, relativePath: options?.relativePath ?? null, - entries: [], - truncated: false, + entries: allEntries.slice(0, maxEntries), + truncated: allEntries.length > maxEntries, }; }, - async readText() { + async readText(companyId, folderKey, relativePath) { requireCapability(manifest, capabilitySet, "local.folders"); - throw new Error("Test harness local folder readText is not implemented"); + const normalizedPath = normalizeLocalFolderRelativePath(relativePath); + const contents = localFolderFiles.get(localFolderFileKey(companyId, folderKey, normalizedPath)); + if (contents === undefined) throw new Error(`Local folder file not found: ${relativePath}`); + return contents; }, - async writeTextAtomic(_companyId, folderKey) { + async writeTextAtomic(companyId, folderKey, relativePath, contents) { requireCapability(manifest, capabilitySet, "local.folders"); - return { + const status = localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? { folderKey, - configured: false, - path: null, - realPath: null, + configured: true, + path: `memory://${manifest.id}/${companyId}/${folderKey}`, + realPath: `memory://${manifest.id}/${companyId}/${folderKey}`, access: "readWrite", - readable: false, - writable: false, + readable: true, + writable: true, requiredDirectories: [], requiredFiles: [], missingDirectories: [], missingFiles: [], - healthy: false, - problems: [{ code: "not_configured", message: "No local folder path is configured." }], + healthy: true, + problems: [], checkedAt: new Date().toISOString(), - }; + } satisfies PluginLocalFolderStatus; + if (status.access !== "readWrite" || !status.writable) { + throw new Error("Local folder is not configured for writes"); + } + localFolderStatuses.set(localFolderKey(companyId, folderKey), status); + localFolderFiles.set(localFolderFileKey(companyId, folderKey, normalizeLocalFolderRelativePath(relativePath)), contents); + return status; + }, + async deleteFile(companyId, folderKey, relativePath) { + requireCapability(manifest, capabilitySet, "local.folders"); + const status = localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? notConfiguredLocalFolderStatus(folderKey); + if (status.configured && (status.access !== "readWrite" || !status.writable)) { + throw new Error("Local folder is not configured for writes"); + } + localFolderFiles.delete(localFolderFileKey(companyId, folderKey, normalizeLocalFolderRelativePath(relativePath))); + return status; }, }, events: { @@ -991,14 +1074,14 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", variables: declaration.variables ?? [], + latestRevisionId: null, + latestRevisionNumber: 1, createdByAgentId: null, createdByUserId: null, updatedByAgentId: null, updatedByUserId: null, lastTriggeredAt: null, lastEnqueuedAt: null, - latestRevisionId: null, - latestRevisionNumber: 1, createdAt: now, updatedAt: now, managedByPlugin: { @@ -1087,6 +1170,174 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, }, + skills: { + managed: { + async get(skillKey, companyId) { + requireCapability(manifest, capabilitySet, "skills.managed"); + const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey); + if (!declaration) { + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: null, + skill: null, + status: "missing", + defaultDrift: null, + } satisfies PluginManagedSkillResolution; + } + const externalId = `${manifest.id}:skill:${skillKey}`; + const existingEntity = [...entities.values()].find((entity) => + entity.entityType === "managed_resource" + && entity.scopeKind === "company" + && entity.scopeId === companyId + && entity.externalId === externalId + ); + const existingSkill = existingEntity?.data?.skill as CompanySkill | undefined; + if (existingSkill && existingSkill.companyId === companyId) { + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: existingSkill.id, + skill: existingSkill, + status: "resolved", + defaultDrift: null, + } satisfies PluginManagedSkillResolution; + } + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: null, + skill: null, + status: "missing", + defaultDrift: null, + } satisfies PluginManagedSkillResolution; + }, + async reconcile(skillKey, companyId) { + const existing = await this.get(skillKey, companyId); + if (existing.skill) return existing; + const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey); + if (!declaration) return existing; + const now = new Date(); + const skill = { + id: randomUUID(), + companyId, + key: `plugin/${manifest.id.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}/${skillKey}`, + slug: declaration.slug ?? skillKey, + name: declaration.displayName, + description: declaration.description ?? null, + markdown: declaration.markdown ?? `# ${declaration.displayName}\n`, + sourceType: "catalog", + sourceLocator: null, + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { + sourceKind: "catalog", + pluginManagedResource: { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + }, + }, + createdAt: now, + updatedAt: now, + } satisfies CompanySkill; + const nowIso = now.toISOString(); + const record: PluginEntityRecord = { + id: randomUUID(), + entityType: "managed_resource", + scopeKind: "company", + scopeId: companyId, + externalId: `${manifest.id}:skill:${skillKey}`, + title: declaration.displayName, + status: null, + data: { resourceKind: "skill", resourceKey: skillKey, skillId: skill.id, skill }, + createdAt: nowIso, + updatedAt: nowIso, + }; + entities.set(record.id, record); + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: skill.id, + skill, + status: "created", + defaultDrift: null, + } satisfies PluginManagedSkillResolution; + }, + async reset(skillKey, companyId) { + requireCapability(manifest, capabilitySet, "skills.managed"); + const existing = await this.get(skillKey, companyId); + const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey); + if (!declaration) return existing; + const now = new Date(); + const skill = { + id: existing.skill?.id ?? randomUUID(), + companyId, + key: `plugin/${manifest.id.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}/${skillKey}`, + slug: declaration.slug ?? skillKey, + name: declaration.displayName, + description: declaration.description ?? null, + markdown: declaration.markdown ?? `# ${declaration.displayName}\n`, + sourceType: "catalog", + sourceLocator: null, + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { + sourceKind: "catalog", + pluginManagedResource: { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + }, + }, + createdAt: existing.skill?.createdAt ?? now, + updatedAt: now, + } satisfies CompanySkill; + const nowIso = now.toISOString(); + const existingEntity = [...entities.values()].find((entity) => + entity.entityType === "managed_resource" && + entity.scopeKind === "company" && + entity.scopeId === companyId && + entity.externalId === `${manifest.id}:skill:${skillKey}`, + ); + const record: PluginEntityRecord = { + id: existingEntity?.id ?? randomUUID(), + entityType: "managed_resource", + scopeKind: "company", + scopeId: companyId, + externalId: `${manifest.id}:skill:${skillKey}`, + title: declaration.displayName, + status: null, + data: { resourceKind: "skill", resourceKey: skillKey, skillId: skill.id, skill }, + createdAt: existingEntity?.createdAt ?? nowIso, + updatedAt: nowIso, + }; + entities.set(record.id, record); + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: skill.id, + skill, + status: "reset", + defaultDrift: null, + } satisfies PluginManagedSkillResolution; + }, + }, + }, companies: { async list(input) { requireCapability(manifest, capabilitySet, "companies.read"); @@ -1147,7 +1398,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { title: input.title, description: input.description ?? null, status: input.status ?? "todo", - workMode: input.workMode ?? "standard", + workMode: "standard", priority: input.priority ?? "medium", assigneeAgentId: input.assigneeAgentId ?? null, assigneeUserId: input.assigneeUserId ?? null, @@ -1164,7 +1415,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { originRunId: input.originRunId ?? null, requestDepth: input.requestDepth ?? 0, billingCode: input.billingCode ?? null, - assigneeAdapterOverrides: null, + assigneeAdapterOverrides: input.assigneeAdapterOverrides ?? null, executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index a7b7a168..bb9b86d3 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -22,6 +22,7 @@ import type { IssueDocument, IssueDocumentSummary, IssueRelationIssueSummary, + IssueAssigneeAdapterOverrides, IssueThreadInteraction, SuggestTasksInteraction, AskUserQuestionsInteraction, @@ -32,6 +33,8 @@ import type { PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, + CompanySkill, Routine, RoutineRun, Agent, @@ -54,6 +57,10 @@ export type { PluginManagedProjectResolution, PluginManagedRoutineDeclaration, PluginManagedRoutineResolution, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, + PluginManagedSkillResolution, + CompanySkill, Routine, RoutineRun, PluginLocalFolderDeclaration, @@ -450,6 +457,8 @@ export interface PluginLocalFoldersClient { relativePath: string, contents: string, ): Promise; + /** Delete a file below a configured folder after containment checks. Missing files are treated as already deleted. */ + deleteFile(companyId: string, folderKey: string, relativePath: string): Promise; } /** @@ -840,6 +849,19 @@ export interface PluginRoutinesClient { }; } +/** + * `ctx.skills` — resolve and reconcile plugin-managed company skills. + * + * Requires `skills.managed` capability. + */ +export interface PluginSkillsClient { + managed: { + get(skillKey: string, companyId: string): Promise; + reconcile(skillKey: string, companyId: string): Promise; + reset(skillKey: string, companyId: string): Promise; + }; +} + /** * `ctx.data` — register `getData` handlers that back `usePluginData()` in the * plugin's frontend components. @@ -1257,12 +1279,12 @@ export interface PluginIssuesClient { title: string; description?: string; status?: Issue["status"]; - workMode?: Issue["workMode"]; priority?: Issue["priority"]; assigneeAgentId?: string; assigneeUserId?: string | null; requestDepth?: number; billingCode?: string | null; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; surfaceVisibility?: IssueSurfaceVisibility; originKind?: PluginIssueOriginKind; originId?: string | null; @@ -1281,7 +1303,6 @@ export interface PluginIssuesClient { | "title" | "description" | "status" - | "workMode" | "priority" | "assigneeAgentId" | "assigneeUserId" @@ -1624,6 +1645,9 @@ export interface PluginContext { /** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */ routines: PluginRoutinesClient; + /** Resolve and reconcile plugin-managed company skills. Requires `skills.managed`. */ + skills: PluginSkillsClient; + /** Read company metadata. Requires `companies.read`. */ companies: PluginCompaniesClient; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index ad578d60..81104fea 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -430,6 +430,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost contents, }); }, + + async deleteFile(companyId: string, folderKey: string, relativePath: string) { + return callHost("localFolders.deleteFile", { companyId, folderKey, relativePath }); + }, }, events: { @@ -671,6 +675,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + skills: { + managed: { + async get(skillKey: string, companyId: string) { + return callHost("skills.managed.get", { skillKey, companyId }); + }, + async reconcile(skillKey: string, companyId: string) { + return callHost("skills.managed.reconcile", { skillKey, companyId }); + }, + async reset(skillKey: string, companyId: string) { + return callHost("skills.managed.reset", { skillKey, companyId }); + }, + }, + }, + companies: { async list(input) { return callHost("companies.list", { @@ -714,12 +732,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost title: input.title, description: input.description, status: input.status, - workMode: input.workMode, priority: input.priority, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId, requestDepth: input.requestDepth, billingCode: input.billingCode, + assigneeAdapterOverrides: input.assigneeAdapterOverrides, surfaceVisibility: input.surfaceVisibility, originKind: input.originKind, originId: input.originId, diff --git a/packages/shared/src/config-schema.test.ts b/packages/shared/src/config-schema.test.ts new file mode 100644 index 00000000..9e2a75cc --- /dev/null +++ b/packages/shared/src/config-schema.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { paperclipConfigSchema } from "./config-schema.js"; + +describe("paperclip config schema", () => { + it("defaults omitted runtime paths to legacy instance-root locations", () => { + const parsed = paperclipConfigSchema.parse({ + $meta: { + version: 1, + updatedAt: "2026-05-10T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + }, + logging: { + mode: "file", + }, + server: {}, + }); + + expect(parsed.database.embeddedPostgresDataDir).toBe("~/.paperclip/instances/default/db"); + expect(parsed.database.backup.dir).toBe("~/.paperclip/instances/default/data/backups"); + expect(parsed.logging.logDir).toBe("~/.paperclip/instances/default/logs"); + expect(parsed.storage.localDisk.baseDir).toBe("~/.paperclip/instances/default/data/storage"); + expect(parsed.secrets.localEncrypted.keyFilePath).toBe("~/.paperclip/instances/default/secrets/master.key"); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 640f7563..3b018371 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -715,6 +715,7 @@ export const PLUGIN_CAPABILITIES = [ "issue.documents.write", "projects.managed", "routines.managed", + "skills.managed", "agents.pause", "agents.resume", "agents.invoke", diff --git a/packages/shared/src/home-paths.test.ts b/packages/shared/src/home-paths.test.ts new file mode 100644 index 00000000..81235c17 --- /dev/null +++ b/packages/shared/src/home-paths.test.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resolveDefaultBackupDir, + resolveDefaultEmbeddedPostgresDir, + resolveDefaultLogsDir, + resolveDefaultSecretsKeyFilePath, + resolveDefaultStorageDir, + resolvePaperclipConfigPathForInstance, + resolvePaperclipInstanceRoot, +} from "./home-paths.js"; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("home path resolution", () => { + it("resolves config and runtime data directly under the instance root", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-")); + process.env.PAPERCLIP_HOME = home; + delete process.env.PAPERCLIP_INSTANCE_ID; + + const instanceRoot = path.join(home, "instances", "default"); + expect(resolvePaperclipInstanceRoot()).toBe(instanceRoot); + expect(resolvePaperclipConfigPathForInstance()).toBe(path.join(instanceRoot, "config.json")); + expect(resolveDefaultEmbeddedPostgresDir()).toBe(path.join(instanceRoot, "db")); + expect(resolveDefaultBackupDir()).toBe(path.join(instanceRoot, "data", "backups")); + expect(resolveDefaultLogsDir()).toBe(path.join(instanceRoot, "logs")); + expect(resolveDefaultStorageDir()).toBe(path.join(instanceRoot, "data", "storage")); + expect(resolveDefaultSecretsKeyFilePath()).toBe(path.join(instanceRoot, "secrets", "master.key")); + }); +}); diff --git a/packages/shared/src/home-paths.ts b/packages/shared/src/home-paths.ts new file mode 100644 index 00000000..fa4f1c44 --- /dev/null +++ b/packages/shared/src/home-paths.ts @@ -0,0 +1,92 @@ +import os from "node:os"; +import path from "node:path"; + +export const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; +export const PAPERCLIP_CONFIG_BASENAME = "config.json"; +export const PAPERCLIP_ENV_FILENAME = ".env"; + +const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/; + +export function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +export function resolvePaperclipHomeDir(homeOverride?: string): string { + const raw = homeOverride?.trim() || process.env.PAPERCLIP_HOME?.trim(); + if (raw) return path.resolve(expandHomePrefix(raw)); + return path.resolve(os.homedir(), ".paperclip"); +} + +export function resolvePaperclipInstanceId(instanceIdOverride?: string): string { + const raw = instanceIdOverride?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID; + if (!PATH_SEGMENT_RE.test(raw)) { + throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); + } + return raw; +} + +export function resolvePaperclipInstanceRoot(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipHomeDir(input.homeDir), "instances", resolvePaperclipInstanceId(input.instanceId)); +} + +export function resolvePaperclipInstanceConfigPath(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), PAPERCLIP_CONFIG_BASENAME); +} + +export function resolvePaperclipConfigPathForInstance(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return resolvePaperclipInstanceConfigPath(input); +} + +export function resolvePaperclipEnvPathForConfig(configPath: string): string { + return path.resolve(path.dirname(configPath), PAPERCLIP_ENV_FILENAME); +} + +export function resolveDefaultEmbeddedPostgresDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "db"); +} + +export function resolveDefaultLogsDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "logs"); +} + +export function resolveDefaultSecretsKeyFilePath(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "secrets", "master.key"); +} + +export function resolveDefaultStorageDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "data", "storage"); +} + +export function resolveDefaultBackupDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "data", "backups"); +} + +export function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2239bbf4..a8b7c486 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -581,10 +581,13 @@ export type { PluginManagedAgentDeclaration, PluginManagedProjectDeclaration, PluginManagedRoutineDeclaration, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, PluginLocalFolderDeclaration, PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, PluginManagedResourceKind, PluginManagedResourceRef, PluginUiSlotDeclaration, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 39ad1993..758f22e3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -373,10 +373,13 @@ export type { PluginManagedAgentDeclaration, PluginManagedProjectDeclaration, PluginManagedRoutineDeclaration, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, PluginLocalFolderDeclaration, PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, PluginManagedResourceKind, PluginManagedResourceRef, PluginUiSlotDeclaration, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index d5231aad..6f962912 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -27,6 +27,7 @@ import type { IssueSurfaceVisibility, } from "../constants.js"; import type { Agent } from "./agent.js"; +import type { CompanySkill } from "./company-skill.js"; import type { Project } from "./project.js"; import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js"; @@ -164,6 +165,7 @@ export interface PluginManagedAgentDeclaration { instructions?: { entryFile?: string; content?: string; + files?: Record; assetPath?: string; }; } @@ -208,7 +210,33 @@ export interface PluginManagedProjectDeclaration { settings?: Record; } -export type PluginManagedResourceKind = "agent" | "project" | "routine"; +export interface PluginManagedSkillFileDeclaration { + /** Relative path inside the skill folder, for example `references/guide.md`. */ + path: string; + /** File contents written when the skill is installed or reset. */ + content: string; +} + +/** + * Declares a company skill that a plugin can install into each company's + * skills library and later resolve by stable key. + */ +export interface PluginManagedSkillDeclaration { + /** Stable identifier for this managed skill, unique within the plugin. */ + skillKey: string; + /** Suggested visible skill name. */ + displayName: string; + /** Suggested skill slug. Defaults to `skillKey`. */ + slug?: string; + /** Suggested skill description. */ + description?: string | null; + /** Full `SKILL.md` contents. Defaults to generated markdown from display metadata. */ + markdown?: string; + /** Additional files installed with the skill. */ + files?: PluginManagedSkillFileDeclaration[]; +} + +export type PluginManagedResourceKind = "agent" | "project" | "routine" | "skill"; export interface PluginManagedResourceRef { pluginKey?: string; @@ -258,6 +286,10 @@ export interface PluginManagedAgentResolution { agent: Agent | null; status: "missing" | "resolved" | "created" | "relinked" | "reset"; approvalId?: string | null; + defaultDrift?: { + entryFile: string; + changedFiles: string[]; + } | null; } export interface PluginManagedProjectResolution { @@ -281,6 +313,19 @@ export interface PluginManagedRoutineResolution { missingRefs?: PluginManagedResourceRef[]; } +export interface PluginManagedSkillResolution { + pluginKey: string; + resourceKind: "skill"; + resourceKey: string; + companyId: string; + skillId: string | null; + skill: CompanySkill | null; + status: "missing" | "resolved" | "created" | "relinked" | "reset"; + defaultDrift?: { + changedFiles: string[]; + } | null; +} + /** * Declares a UI extension slot the plugin fills with a React component. * @@ -496,6 +541,8 @@ export interface PaperclipPluginManifestV1 { projects?: PluginManagedProjectDeclaration[]; /** Suggested company-scoped routines this plugin can provision and resolve by stable key. */ routines?: PluginManagedRoutineDeclaration[]; + /** Suggested company skills this plugin can install and resolve by stable key. */ + skills?: PluginManagedSkillDeclaration[]; /** Trusted local folders this plugin can configure and access by stable key. */ localFolders?: PluginLocalFolderDeclaration[]; /** diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 14b30989..ae0f289b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -394,6 +394,8 @@ export { pluginLauncherRenderDeclarationSchema, pluginLauncherDeclarationSchema, pluginDatabaseDeclarationSchema, + pluginManagedSkillFileDeclarationSchema, + pluginManagedSkillDeclarationSchema, pluginApiRouteDeclarationSchema, pluginManifestV1Schema, installPluginSchema, @@ -413,6 +415,8 @@ export { type PluginLauncherRenderDeclarationInput, type PluginLauncherDeclarationInput, type PluginDatabaseDeclarationInput, + type PluginManagedSkillFileDeclarationInput, + type PluginManagedSkillDeclarationInput, type PluginApiRouteDeclarationInput, type PluginManifestV1Input, type InstallPlugin, diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 7de0c316..ccea0d6a 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PLUGIN_CAPABILITIES } from "../constants.js"; -import { pluginManagedRoutineDeclarationSchema, pluginUiSlotDeclarationSchema } from "./plugin.js"; +import { pluginManagedRoutineDeclarationSchema, pluginManifestV1Schema, pluginUiSlotDeclarationSchema } from "./plugin.js"; describe("plugin capability constants", () => { it("exposes each capability once", () => { @@ -30,6 +30,41 @@ describe("plugin managed routine validators", () => { }); }); +describe("plugin managed skill validators", () => { + const baseManifest = { + id: "paperclip.test-managed-skills", + apiVersion: 1, + version: "0.1.0", + displayName: "Managed Skills", + description: "Managed skills test plugin.", + author: "Paperclip", + categories: ["automation"], + entrypoints: { worker: "./dist/worker.js" }, + } as const; + + it("requires skills.managed when managed skills are declared", () => { + const parsed = pluginManifestV1Schema.safeParse({ + ...baseManifest, + capabilities: [], + skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }], + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.some((issue) => issue.message.includes("skills.managed"))).toBe(true); + }); + + it("accepts managed skills with the skills.managed capability", () => { + const parsed = pluginManifestV1Schema.parse({ + ...baseManifest, + capabilities: ["skills.managed"], + skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }], + }); + + expect(parsed.skills?.[0]?.skillKey).toBe("wiki-maintainer"); + }); +}); + describe("plugin UI slot validators", () => { it("accepts route-scoped sidebar slots with a routePath", () => { const parsed = pluginUiSlotDeclarationSchema.parse({ diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 336620a0..8d4016cd 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -151,6 +151,7 @@ export const pluginManagedAgentDeclarationSchema = z.object({ instructions: z.object({ entryFile: z.string().min(1).max(200).optional(), content: z.string().max(200_000).optional(), + files: z.record(z.string().max(200_000)).optional(), assetPath: z.string().min(1).max(500).optional(), }).optional(), }); @@ -172,7 +173,7 @@ export type PluginManagedProjectDeclarationInput = z.infer; +export const pluginManagedSkillFileDeclarationSchema = z.object({ + path: pluginLocalFolderRelativePathSchema.refine( + (value) => value.toLowerCase() !== "skill.md", + { message: "managed skill files cannot replace SKILL.md; use markdown for the main skill file" }, + ), + content: z.string().max(200_000), +}); + +export type PluginManagedSkillFileDeclarationInput = z.infer; + +export const pluginManagedSkillDeclarationSchema = z.object({ + skillKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "skillKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }), + displayName: z.string().min(1).max(100), + slug: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "slug must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }).optional(), + description: z.string().max(2000).nullable().optional(), + markdown: z.string().max(200_000).optional(), + files: z.array(pluginManagedSkillFileDeclarationSchema).max(50).optional(), +}).superRefine((value, ctx) => { + const paths = (value.files ?? []).map((file) => file.path); + const duplicates = paths.filter((path, index) => paths.indexOf(path) !== index); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate managed skill file paths: ${[...new Set(duplicates)].join(", ")}`, + path: ["files"], + }); + } +}); + +export type PluginManagedSkillDeclarationInput = z.infer; + /** * Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin * fills with a React component. Includes `superRefine` checks for slot-specific @@ -589,6 +625,7 @@ export const pluginManifestV1Schema = z.object({ agents: z.array(pluginManagedAgentDeclarationSchema).optional(), projects: z.array(pluginManagedProjectDeclarationSchema).optional(), routines: z.array(pluginManagedRoutineDeclarationSchema).optional(), + skills: z.array(pluginManagedSkillDeclarationSchema).optional(), localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ @@ -678,6 +715,16 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.skills && manifest.skills.length > 0) { + if (!manifest.capabilities.includes("skills.managed")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'skills.managed' is required when managed skills are declared", + path: ["capabilities"], + }); + } + } + if (manifest.localFolders && manifest.localFolders.length > 0) { if (!manifest.capabilities.includes("local.folders")) { ctx.addIssue({ @@ -871,6 +918,18 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.skills) { + const skillKeys = manifest.skills.map((skill) => skill.skillKey); + const duplicates = skillKeys.filter((key, i) => skillKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate managed skill keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["skills"], + }); + } + } + // UI slot ids must be unique within the plugin (namespaced at runtime) if (manifest.ui) { if (manifest.ui.slots) { diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index 7748bfb3..f2560bcd 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -11,6 +11,8 @@ import { createDb, documentRevisions, documents, + environmentLeases, + environments, heartbeatRunEvents, heartbeatRuns, issueComments, @@ -122,6 +124,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = await new Promise((resolve) => setTimeout(resolve, 50)); } await new Promise((resolve) => setTimeout(resolve, 50)); + await db.delete(environmentLeases); await db.delete(activityLog); await db.delete(companySkills); await db.delete(issueComments); @@ -137,6 +140,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); await db.delete(agents); + await db.delete(companySkills); + await db.delete(environments); await db.delete(companies); }); @@ -511,6 +516,16 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = finishFirstRun(); + const firstRunSucceeded = await waitForCondition(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, firstWake!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "succeeded"; + }); + expect(firstRunSucceeded).toBe(true); + const secondRunSucceeded = await waitForCondition(async () => { const run = await db .select({ status: heartbeatRuns.status }) diff --git a/server/src/__tests__/plugin-local-folders.test.ts b/server/src/__tests__/plugin-local-folders.test.ts index 89e00eff..073220c4 100644 --- a/server/src/__tests__/plugin-local-folders.test.ts +++ b/server/src/__tests__/plugin-local-folders.test.ts @@ -10,6 +10,7 @@ import { preparePluginLocalFolder, readPluginLocalFolderText, resolvePluginLocalFolderPath, + deletePluginLocalFolderFile, writePluginLocalFolderTextAtomic, } from "../services/plugin-local-folders.js"; @@ -218,6 +219,16 @@ describe("plugin local folders", () => { expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]); }); + it("returns the real folder key after deleting a file", async () => { + const root = await makeRoot(); + await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8"); + + const status = await deletePluginLocalFolderFile(root, "stale.md", "content-root"); + + expect(status.folderKey).toBe("content-root"); + await expect(fs.stat(path.join(root, "stale.md"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("lists nested local folder entries without following symlink escapes", async () => { const root = await makeRoot(); const outside = await makeRoot(); diff --git a/server/src/__tests__/plugin-managed-skills.test.ts b/server/src/__tests__/plugin-managed-skills.test.ts new file mode 100644 index 00000000..deb82643 --- /dev/null +++ b/server/src/__tests__/plugin-managed-skills.test.ts @@ -0,0 +1,281 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + companies, + companySkills, + createDb, + pluginManagedResources, + plugins, +} from "@paperclipai/db"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { buildHostServices } from "../services/plugin-host-services.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: async () => {}, + subscribe: () => {}, + }; + }, + } as any; +} + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +function manifest(): PaperclipPluginManifestV1 { + return { + id: "paperclip.managed-skills-test", + apiVersion: 1, + version: "0.1.0", + displayName: "Managed Skills Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["skills.managed"], + entrypoints: { worker: "./dist/worker.js" }, + skills: [{ + skillKey: "wiki-maintainer", + displayName: "Wiki Maintainer Skill", + description: "Use LLM Wiki tools to maintain company knowledge.", + files: [{ + path: "references/wiki-style.md", + content: "# Wiki style\n\nKeep pages cited and terse.\n", + }], + }], + }; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres plugin-managed skill tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("plugin-managed skills", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-skills-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(pluginManagedResources); + await db.delete(companySkills); + await db.delete(plugins); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompanyAndPlugin(pluginManifest = manifest()) { + const companyId = randomUUID(); + const pluginId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: issuePrefix(companyId), + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: pluginManifest.id, + packageName: "@paperclipai/plugin-managed-skills-test", + version: pluginManifest.version, + apiVersion: pluginManifest.apiVersion, + categories: pluginManifest.categories, + manifestJson: pluginManifest, + status: "ready", + installOrder: 1, + }); + const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, { + manifest: pluginManifest, + }); + return { companyId, pluginId, pluginManifest, services }; + } + + it("installs and resolves managed company skills by stable resource key", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + + const created = await services.skills.managedReconcile({ + companyId, + skillKey: "wiki-maintainer", + }); + + expect(created.status).toBe("created"); + expect(created.skill).toMatchObject({ + name: "Wiki Maintainer Skill", + key: "plugin/paperclip-managed-skills-test/wiki-maintainer", + sourceType: "catalog", + fileInventory: expect.arrayContaining([ + expect.objectContaining({ path: "SKILL.md", kind: "skill" }), + expect.objectContaining({ path: "references/wiki-style.md", kind: "reference" }), + ]), + }); + + const resolved = await services.skills.managedGet({ + companyId, + skillKey: "wiki-maintainer", + }); + expect(resolved.status).toBe("resolved"); + expect(resolved.skillId).toBe(created.skillId); + + const [binding] = await db.select().from(pluginManagedResources); + expect(binding).toMatchObject({ + companyId, + resourceKind: "skill", + resourceKey: "wiki-maintainer", + resourceId: created.skillId, + }); + }); + + it("preserves operator edits during reconcile and restores manifest defaults on reset", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(created.skillId).toBeTruthy(); + + await db + .update(companySkills) + .set({ + name: "Custom Wiki Skill", + markdown: "# Custom instructions\n", + updatedAt: new Date(), + }) + .where(eq(companySkills.id, created.skillId!)); + + const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(reconciled.status).toBe("resolved"); + expect(reconciled.skill).toMatchObject({ + name: "Custom Wiki Skill", + markdown: "# Custom instructions\n", + }); + + const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" }); + expect(reset.status).toBe("reset"); + expect(reset.skill).toMatchObject({ + name: "Wiki Maintainer Skill", + description: "Use LLM Wiki tools to maintain company knowledge.", + }); + expect(reset.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/wiki-maintainer\""); + }); + + it("does not rewrite managed skill bindings when defaults are unchanged", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(created.skillId).toBeTruthy(); + + const [binding] = await db.select().from(pluginManagedResources); + const oldUpdatedAt = new Date("2026-01-01T00:00:00.000Z"); + await db + .update(pluginManagedResources) + .set({ updatedAt: oldUpdatedAt }) + .where(eq(pluginManagedResources.id, binding.id)); + + const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + const [bindingAfter] = await db.select().from(pluginManagedResources); + + expect(reconciled.status).toBe("resolved"); + expect(bindingAfter.updatedAt.toISOString()).toBe(oldUpdatedAt.toISOString()); + }); + + it("relinks an existing canonical skill without overwriting operator edits", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(created.skillId).toBeTruthy(); + + await db.delete(pluginManagedResources).where(eq(pluginManagedResources.resourceId, created.skillId!)); + await db + .update(companySkills) + .set({ + name: "Existing Customized Wiki Skill", + markdown: "# Existing customized instructions\n", + updatedAt: new Date(), + }) + .where(eq(companySkills.id, created.skillId!)); + + const relinked = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + + expect(relinked.status).toBe("relinked"); + expect(relinked.skillId).toBe(created.skillId); + expect(relinked.skill).toMatchObject({ + name: "Existing Customized Wiki Skill", + markdown: "# Existing customized instructions\n", + }); + expect(relinked.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] }); + + const [binding] = await db.select().from(pluginManagedResources); + expect(binding).toMatchObject({ + companyId, + resourceKind: "skill", + resourceKey: "wiki-maintainer", + resourceId: created.skillId, + }); + }); + + it("reports drift when installed skill files differ from plugin defaults", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(created.defaultDrift).toBeNull(); + + await db + .update(companySkills) + .set({ + markdown: "# Custom instructions\n", + updatedAt: new Date(), + }) + .where(eq(companySkills.id, created.skillId!)); + + const drifted = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(drifted.status).toBe("resolved"); + expect(drifted.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] }); + + const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" }); + expect(reset.defaultDrift).toBeNull(); + }); + + it("adds the canonical managed key to manifest-provided markdown skills", async () => { + const pluginManifest = manifest(); + pluginManifest.skills = [ + ...(pluginManifest.skills ?? []), + { + skillKey: "markdown-skill", + displayName: "Markdown Skill", + markdown: [ + "---", + "name: markdown-skill", + "description: Markdown source without a key.", + "---", + "", + "# Markdown Skill", + "", + "Follow the managed markdown.", + ].join("\n"), + }, + ]; + const { companyId, services } = await seedCompanyAndPlugin(pluginManifest); + + const created = await services.skills.managedReconcile({ companyId, skillKey: "markdown-skill" }); + + expect(created.status).toBe("created"); + expect(created.skill).toMatchObject({ + key: "plugin/paperclip-managed-skills-test/markdown-skill", + name: "markdown-skill", + }); + expect(created.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/markdown-skill\""); + }); +}); diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts new file mode 100644 index 00000000..b3efa7e1 --- /dev/null +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; + +describe("plugin SDK test harness", () => { + it("requires skills.managed capability before resetting a missing declaration", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-missing-managed-skill-capability", + apiVersion: 1, + version: "0.1.0", + displayName: "Missing Managed Skill Capability", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + skills: [{ + skillKey: "wiki-maintainer", + displayName: "Wiki Maintainer", + }], + }; + const harness = createTestHarness({ manifest }); + + await expect(harness.ctx.skills.managed.reset("unknown-skill", "company-1")).rejects.toThrow( + "missing required capability 'skills.managed'", + ); + }); +}); diff --git a/server/src/home-paths.ts b/server/src/home-paths.ts index f1174bbb..cf274a2a 100644 --- a/server/src/home-paths.ts +++ b/server/src/home-paths.ts @@ -1,57 +1,50 @@ -import os from "node:os"; import path from "node:path"; - -const DEFAULT_INSTANCE_ID = "default"; -const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/; const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/; const FRIENDLY_PATH_SEGMENT_RE = /[^a-zA-Z0-9._-]+/g; +import { + expandHomePrefix, + resolveDefaultBackupDir as resolveSharedDefaultBackupDir, + resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir, + resolveDefaultLogsDir as resolveSharedDefaultLogsDir, + resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath, + resolveDefaultStorageDir as resolveSharedDefaultStorageDir, + resolveHomeAwarePath, + resolvePaperclipConfigPathForInstance, + resolvePaperclipHomeDir, + resolvePaperclipInstanceId, + resolvePaperclipInstanceRoot, +} from "@paperclipai/shared/home-paths"; -function expandHomePrefix(value: string): string { - if (value === "~") return os.homedir(); - if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); - return value; -} - -export function resolvePaperclipHomeDir(): string { - const envHome = process.env.PAPERCLIP_HOME?.trim(); - if (envHome) return path.resolve(expandHomePrefix(envHome)); - return path.resolve(os.homedir(), ".paperclip"); -} - -export function resolvePaperclipInstanceId(): string { - const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; - if (!INSTANCE_ID_RE.test(raw)) { - throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); - } - return raw; -} - -export function resolvePaperclipInstanceRoot(): string { - return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId()); -} +export { + expandHomePrefix, + resolveHomeAwarePath, + resolvePaperclipHomeDir, + resolvePaperclipInstanceId, + resolvePaperclipInstanceRoot, +}; export function resolveDefaultConfigPath(): string { - return path.resolve(resolvePaperclipInstanceRoot(), "config.json"); + return resolvePaperclipConfigPathForInstance(); } export function resolveDefaultEmbeddedPostgresDir(): string { - return path.resolve(resolvePaperclipInstanceRoot(), "db"); + return resolveSharedDefaultEmbeddedPostgresDir(); } export function resolveDefaultLogsDir(): string { - return path.resolve(resolvePaperclipInstanceRoot(), "logs"); + return resolveSharedDefaultLogsDir(); } export function resolveDefaultSecretsKeyFilePath(): string { - return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "master.key"); + return resolveSharedDefaultSecretsKeyFilePath(); } export function resolveDefaultStorageDir(): string { - return path.resolve(resolvePaperclipInstanceRoot(), "data", "storage"); + return resolveSharedDefaultStorageDir(); } export function resolveDefaultBackupDir(): string { - return path.resolve(resolvePaperclipInstanceRoot(), "data", "backups"); + return resolveSharedDefaultBackupDir(); } export function resolveDefaultAgentWorkspaceDir(agentId: string): string { @@ -89,7 +82,3 @@ export function resolveManagedProjectWorkspaceDir(input: { sanitizeFriendlyPathSegment(input.repoName, "_default"), ); } - -export function resolveHomeAwarePath(value: string): string { - return path.resolve(expandHomePrefix(value)); -} diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index e97ab8d0..3d4c8947 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -44,10 +44,12 @@ import { pluginStateStore } from "./plugin-state-store.js"; import { pluginDatabaseService } from "./plugin-database.js"; import { pluginManagedAgentService } from "./plugin-managed-agents.js"; import { pluginManagedRoutineService } from "./plugin-managed-routines.js"; +import { pluginManagedSkillService } from "./plugin-managed-skills.js"; import { assertConfiguredLocalFolder, assertWritableConfiguredLocalFolder, getStoredLocalFolders, + deletePluginLocalFolderFile, inspectPluginLocalFolder, listPluginLocalFolderEntries, preparePluginLocalFolder, @@ -509,6 +511,11 @@ export function buildHostServices( manifest: options.manifest, pluginWorkerManager: options.pluginWorkerManager, }); + const managedSkills = pluginManagedSkillService(db, { + pluginId, + pluginKey, + manifest: options.manifest, + }); const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, }); @@ -882,12 +889,17 @@ export function buildHostServices( }); const status = await inspectStoredLocalFolder(companyId, params.folderKey); assertWritableConfiguredLocalFolder(status); - if (status.access !== "readWrite" || !status.writable) { - throw new Error("Local folder is not configured for writes"); - } await writePluginLocalFolderTextAtomic(status.realPath!, params.relativePath, params.contents); return inspectStoredLocalFolder(companyId, params.folderKey); }, + + async deleteFile(params) { + const companyId = ensureCompanyId(params.companyId); + const status = await inspectStoredLocalFolder(companyId, params.folderKey); + assertWritableConfiguredLocalFolder(status); + await deletePluginLocalFolderFile(status.realPath!, params.relativePath, params.folderKey); + return inspectStoredLocalFolder(companyId, params.folderKey); + }, }, state: { @@ -1224,6 +1236,24 @@ export function buildHostServices( }, }, + skills: { + async managedGet(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedSkills.get(params.skillKey, companyId); + }, + async managedReconcile(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedSkills.reconcile(params.skillKey, companyId); + }, + async managedReset(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedSkills.reset(params.skillKey, companyId); + }, + }, + issues: { async list(params) { const companyId = ensureCompanyId(params.companyId); diff --git a/server/src/services/plugin-local-folders.ts b/server/src/services/plugin-local-folders.ts index 8aa590d7..049b6e66 100644 --- a/server/src/services/plugin-local-folders.ts +++ b/server/src/services/plugin-local-folders.ts @@ -538,6 +538,55 @@ export async function writePluginLocalFolderTextAtomic( }); } +export async function deletePluginLocalFolderFile( + rootPath: string, + relativePath: string, + folderKey: string, +) { + const rootRealPath = await fs.realpath(rootPath); + let resolved: Awaited>; + try { + resolved = await resolvePluginLocalFolderPath(rootRealPath, relativePath, { + mustExist: true, + allowMissingLeaf: true, + }); + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; + if (code !== "ENOENT") throw error; + return inspectPluginLocalFolder({ + folderKey, + storedConfig: { + path: rootPath, + access: "readWrite", + }, + }); + } + + if (resolved.exists) { + const stat = await fs.lstat(resolved.absolutePath); + if (stat.isDirectory()) { + throw badRequest("Local folder delete target must be a file"); + } + await fs.rm(resolved.absolutePath, { force: true }); + if (process.platform !== "win32") { + const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r"); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close(); + } + } + } + + return inspectPluginLocalFolder({ + folderKey, + storedConfig: { + path: rootPath, + access: "readWrite", + }, + }); +} + export function defaultLocalFolderBasePath(pluginKey: string, companyId: string) { return path.join(os.homedir(), ".paperclip", "plugin-data", companyId, pluginKey); } diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index 84a55c3a..ea95b59c 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -126,6 +126,33 @@ function applyInstructionTemplateVariables( return next; } +function declaredInstructionFiles( + declaration: PluginManagedAgentDeclaration, + variables: Record, +) { + const instructionDeclaration = declaration.instructions; + if (!instructionDeclaration?.content && !instructionDeclaration?.files) return null; + + const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md"; + const files = { ...(instructionDeclaration.files ?? {}) }; + if (instructionDeclaration.content !== undefined) { + files[entryFile] = instructionDeclaration.content; + } + if (files[entryFile] === undefined) { + files[entryFile] = ""; + } + + return { + entryFile, + files: Object.fromEntries( + Object.entries(files).map(([filePath, content]) => [ + filePath, + applyInstructionTemplateVariables(content, variables), + ]), + ), + }; +} + function rowIsManagedAgent( row: typeof agents.$inferSelect, pluginKey: string, @@ -299,19 +326,18 @@ export function pluginManagedAgentService( companyId: string, agent: Agent, declaration: PluginManagedAgentDeclaration, - options: { replaceExisting: boolean }, + materializeOptions: { replaceExisting: boolean }, ): Promise { - const instructionDeclaration = declaration.instructions; - if (!instructionDeclaration?.content) return agent; - - const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md"; const variables = await optionsForInstructionVariables(companyId); + const declared = declaredInstructionFiles(declaration, variables); + if (!declared) return agent; + const materialized = await instructions.materializeManagedBundle( agent, - { [entryFile]: applyInstructionTemplateVariables(instructionDeclaration.content, variables) }, + declared.files, { - entryFile, - replaceExisting: options.replaceExisting, + entryFile: declared.entryFile, + replaceExisting: materializeOptions.replaceExisting, clearLegacyPromptTemplate: true, }, ); @@ -325,6 +351,33 @@ export function pluginManagedAgentService( return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig }; } + async function managedInstructionDefaultDrift( + companyId: string, + agent: Agent | null, + declaration: PluginManagedAgentDeclaration, + ): Promise { + if (!agent) return null; + const variables = await optionsForInstructionVariables(companyId); + const declared = declaredInstructionFiles(declaration, variables); + if (!declared) return null; + + let exported: Awaited>; + try { + exported = await instructions.exportFiles(agent); + } catch { + return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] }; + } + + const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]); + const changedFiles = [...paths] + .filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null)) + .sort((left, right) => left.localeCompare(right)); + if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) { + changedFiles.unshift(declared.entryFile); + } + return changedFiles.length > 0 ? { entryFile: declared.entryFile, changedFiles } : null; + } + async function optionsForInstructionVariables(companyId: string) { return options.instructionTemplateVariables ? options.instructionTemplateVariables(companyId) : {}; } @@ -333,13 +386,13 @@ export function pluginManagedAgentService( return options.pluginKey; } - function resolution( + async function resolution( companyId: string, declaration: PluginManagedAgentDeclaration, agent: Agent | null, status: PluginManagedAgentResolution["status"], approvalId?: string | null, - ): PluginManagedAgentResolution { + ): Promise { return { pluginKey: options.pluginKey, resourceKind: "agent", @@ -349,6 +402,7 @@ export function pluginManagedAgentService( agent, status, approvalId: approvalId ?? null, + defaultDrift: await managedInstructionDefaultDrift(companyId, agent, declaration), }; } diff --git a/server/src/services/plugin-managed-skills.ts b/server/src/services/plugin-managed-skills.ts new file mode 100644 index 00000000..0d6ca9c3 --- /dev/null +++ b/server/src/services/plugin-managed-skills.ts @@ -0,0 +1,359 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + pluginManagedResources, +} from "@paperclipai/db"; +import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import type { + CompanySkill, + PaperclipPluginManifestV1, + PluginManagedSkillDeclaration, + PluginManagedSkillResolution, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { logActivity } from "./activity-log.js"; +import { companySkillService } from "./company-skills.js"; + +const MANAGED_SKILL_RESOURCE_KIND = "skill"; + +interface PluginManagedSkillServiceOptions { + pluginId: string; + pluginKey: string; + manifest?: PaperclipPluginManifestV1 | null; +} + +function pluginKeySlug(pluginKey: string) { + return normalizeAgentUrlKey(pluginKey) ?? "plugin"; +} + +function canonicalSkillKey(pluginKey: string, skillKey: string) { + return `plugin/${pluginKeySlug(pluginKey)}/${skillKey}`; +} + +function yamlString(value: string) { + return JSON.stringify(value); +} + +function buildDefaultMarkdown( + pluginKey: string, + declaration: PluginManagedSkillDeclaration, +) { + const description = declaration.description?.trim() || `${declaration.displayName} plugin skill.`; + return [ + "---", + `name: ${yamlString(declaration.displayName)}`, + `description: ${yamlString(description)}`, + `key: ${yamlString(canonicalSkillKey(pluginKey, declaration.skillKey))}`, + "---", + "", + `# ${declaration.displayName}`, + "", + description, + "", + ].join("\n"); +} + +function withManagedSkillKey(markdown: string, canonicalKey: string) { + const keyLine = `key: ${yamlString(canonicalKey)}`; + const normalized = markdown.replace(/\r\n/g, "\n"); + const frontmatter = /^---\n([\s\S]*?)\n---(\n?)/.exec(normalized); + if (!frontmatter) { + return [ + "---", + keyLine, + "---", + "", + normalized, + ].join("\n"); + } + + const currentBody = frontmatter[1] ?? ""; + const nextBody = /^key\s*:/m.test(currentBody) + ? currentBody.replace(/^key\s*:.*$/m, keyLine) + : [currentBody, keyLine].filter(Boolean).join("\n"); + return `---\n${nextBody}\n---${frontmatter[2] ?? ""}${normalized.slice(frontmatter[0].length)}`; +} + +function buildPackageFiles( + pluginKey: string, + declaration: PluginManagedSkillDeclaration, +) { + const root = declaration.skillKey; + const canonicalKey = canonicalSkillKey(pluginKey, declaration.skillKey); + const files: Record = { + [`${root}/SKILL.md`]: declaration.markdown?.trim() + ? withManagedSkillKey(declaration.markdown, canonicalKey) + : buildDefaultMarkdown(pluginKey, declaration), + }; + for (const file of declaration.files ?? []) { + files[`${root}/${file.path}`] = file.content; + } + return files; +} + +function buildDeclaredSkillFiles( + pluginKey: string, + declaration: PluginManagedSkillDeclaration, +) { + const packageFiles = buildPackageFiles(pluginKey, declaration); + const root = declaration.skillKey; + const prefix = `${root}/`; + const files: Record = {}; + for (const [filePath, content] of Object.entries(packageFiles)) { + files[filePath.startsWith(prefix) ? filePath.slice(prefix.length) : filePath] = content; + } + return files; +} + +function buildSkillDefaults( + pluginKey: string, + declaration: PluginManagedSkillDeclaration, +) { + return { + skillKey: declaration.skillKey, + displayName: declaration.displayName, + slug: declaration.slug ?? declaration.skillKey, + description: declaration.description ?? null, + canonicalKey: canonicalSkillKey(pluginKey, declaration.skillKey), + files: [ + "SKILL.md", + ...(declaration.files ?? []).map((file) => file.path), + ], + }; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function resolution( + pluginKey: string, + companyId: string, + declaration: PluginManagedSkillDeclaration, + skill: CompanySkill | null, + status: PluginManagedSkillResolution["status"], + defaultDrift: PluginManagedSkillResolution["defaultDrift"] = null, +): PluginManagedSkillResolution { + return { + pluginKey, + resourceKind: "skill", + resourceKey: declaration.skillKey, + companyId, + skillId: skill?.id ?? null, + skill, + status, + defaultDrift, + }; +} + +export function pluginManagedSkillService( + db: Db, + options: PluginManagedSkillServiceOptions, +) { + const skills = companySkillService(db); + + function declarationFor(skillKey: string) { + const declaration = options.manifest?.skills?.find((skill) => skill.skillKey === skillKey); + if (!declaration) { + throw notFound(`Managed skill declaration not found: ${skillKey}`); + } + return declaration; + } + + async function getBinding(companyId: string, skillKey: string) { + return db + .select() + .from(pluginManagedResources) + .where(and( + eq(pluginManagedResources.companyId, companyId), + eq(pluginManagedResources.pluginId, options.pluginId), + eq(pluginManagedResources.resourceKind, MANAGED_SKILL_RESOURCE_KIND), + eq(pluginManagedResources.resourceKey, skillKey), + )) + .then((rows) => rows[0] ?? null); + } + + async function upsertBinding( + companyId: string, + declaration: PluginManagedSkillDeclaration, + skillId: string, + ) { + const defaultsJson = buildSkillDefaults(options.pluginKey, declaration); + const existing = await getBinding(companyId, declaration.skillKey); + if (existing) { + if ( + existing.resourceId === skillId && + stableJson(existing.defaultsJson) === stableJson(defaultsJson) + ) { + return existing; + } + return db + .update(pluginManagedResources) + .set({ + resourceId: skillId, + defaultsJson, + updatedAt: new Date(), + }) + .where(eq(pluginManagedResources.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + return db + .insert(pluginManagedResources) + .values({ + companyId, + pluginId: options.pluginId, + pluginKey: options.pluginKey, + resourceKind: MANAGED_SKILL_RESOURCE_KIND, + resourceKey: declaration.skillKey, + resourceId: skillId, + defaultsJson, + }) + .returning() + .then((rows) => rows[0]); + } + + async function getSkill(companyId: string, skillId: string) { + return skills.getById(companyId, skillId); + } + + async function managedSkillDefaultDrift( + companyId: string, + skill: CompanySkill | null, + declaration: PluginManagedSkillDeclaration, + ): Promise { + if (!skill) return null; + const declaredFiles = buildDeclaredSkillFiles(options.pluginKey, declaration); + const currentFiles: Record = {}; + const paths = new Set([ + ...Object.keys(declaredFiles), + ...skill.fileInventory.map((entry) => entry.path), + ]); + + for (const filePath of paths) { + if (filePath === "SKILL.md") { + currentFiles[filePath] = skill.markdown; + continue; + } + try { + currentFiles[filePath] = (await skills.readFile(companyId, skill.id, filePath))?.content ?? null; + } catch { + currentFiles[filePath] = null; + } + } + + const changedFiles = [...paths] + .filter((filePath) => (currentFiles[filePath] ?? null) !== (declaredFiles[filePath] ?? null)) + .sort((left, right) => left.localeCompare(right)); + return changedFiles.length > 0 ? { changedFiles } : null; + } + + async function resolvedSkill( + companyId: string, + declaration: PluginManagedSkillDeclaration, + skill: CompanySkill | null, + status: PluginManagedSkillResolution["status"], + ) { + return resolution( + options.pluginKey, + companyId, + declaration, + skill, + status, + await managedSkillDefaultDrift(companyId, skill, declaration), + ); + } + + async function importDeclaredSkill( + companyId: string, + declaration: PluginManagedSkillDeclaration, + mode: "reconcile" | "reset", + ) { + const beforeByKey = mode === "reconcile" + ? await skills.getByKey(companyId, canonicalSkillKey(options.pluginKey, declaration.skillKey)) + : null; + if (beforeByKey) { + await upsertBinding(companyId, declaration, beforeByKey.id); + return { skill: beforeByKey, status: "relinked" as const }; + } + const results = await skills.importPackageFiles( + companyId, + buildPackageFiles(options.pluginKey, declaration), + { onConflict: "replace" }, + ); + const imported = results.find((result) => + result.skill.key === canonicalSkillKey(options.pluginKey, declaration.skillKey) + || result.originalSlug === (declaration.slug ?? declaration.skillKey) + || result.originalSlug === declaration.skillKey + )?.skill ?? results[0]?.skill ?? null; + if (!imported) { + throw notFound(`Managed skill was not imported: ${declaration.skillKey}`); + } + await upsertBinding(companyId, declaration, imported.id); + const status: PluginManagedSkillResolution["status"] = mode === "reset" ? "reset" : "created"; + return { skill: imported, status }; + } + + async function get(skillKey: string, companyId: string) { + const declaration = declarationFor(skillKey); + const binding = await getBinding(companyId, skillKey); + if (!binding) return resolvedSkill(companyId, declaration, null, "missing"); + const skill = await getSkill(companyId, binding.resourceId); + return resolvedSkill(companyId, declaration, skill, skill ? "resolved" : "missing"); + } + + async function reconcile(skillKey: string, companyId: string) { + const declaration = declarationFor(skillKey); + const current = await get(skillKey, companyId); + if (current.skill) { + await upsertBinding(companyId, declaration, current.skill.id); + return current; + } + const imported = await importDeclaredSkill(companyId, declaration, "reconcile"); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_skill.reconciled", + entityType: "company_skill", + entityId: imported.skill.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.skillKey, + status: imported.status, + }, + }); + return resolvedSkill(companyId, declaration, imported.skill, imported.status); + } + + async function reset(skillKey: string, companyId: string) { + const declaration = declarationFor(skillKey); + const imported = await importDeclaredSkill(companyId, declaration, "reset"); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_skill.reset", + entityType: "company_skill", + entityId: imported.skill.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.skillKey, + }, + }); + return resolvedSkill(companyId, declaration, imported.skill, "reset"); + } + + return { + get, + reconcile, + reset, + }; +}