From 38c185fb8b50f909d29c0b62783731a3b326d021 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 22 May 2026 08:12:52 -0500 Subject: [PATCH 1/4] [codex] Add agent permissions and controls plan (#6386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies by keeping task ownership, approvals, and operator control inside one control plane. > - Agent permissions and plugin-hosted company settings sit on the boundary between autonomy and governance. > - V1 needs scoped task assignment rules, plugin extension points, and clearer company access surfaces without weakening company boundaries. > - The branch builds the core authorization service, plugin SDK/host APIs, and UI simplifications needed to support those controls. > - Paperclip EE plugin surfaces were intentionally moved out of this core PR per review direction, so this PR now carries only the public core/plugin infrastructure work. > - The latest updates preserve the PAP-9937 branch changes that belong in this PR, remove the `design/` artifacts, and exclude the experimental `plugin-briefs` package. > - Greptile feedback was applied through the authorization/audit paths and the final cleanup commit was re-reviewed at 5/5 with no unresolved Greptile threads. > - The benefit is safer assignment control with extension hooks for richer permission products while preserving simple defaults for normal operators. ## What Changed - Added scoped task-assignment authorization decisions and routed issue/agent assignment mutations through the authorization service. - Added plugin SDK and host APIs for company settings slots, authorization policy/grant management, assignment previews, and bridge invocation scope propagation. - Simplified core company access UI and moved advanced controls behind plugin-provided settings surfaces. - Added retry-now affordances for blocked issue next-step notices. - Added protected-assignment enforcement for persisted agent/project/issue policies, including explicit-grant fallback behavior. - Added incremental principal-access compatibility backfill for active agent memberships and role-default human permission grants. - Added the Markdown code block wrap action fix from the latest branch changes. - Removed `design/` artifacts from the PR and removed `packages/plugins/plugin-briefs` from the final diff. - Addressed Greptile feedback for plugin actor sanitization, legacy membership handling, audit pagination, unknown grant-scope metadata, and startup test mocks. ## Verification - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54 tests passed. - `pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62 tests passed. - `pnpm exec vitest run server/src/__tests__/authorization-service.test.ts server/src/__tests__/plugin-access-authorization-host-services.test.ts server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files passed, 28 tests passed. - `pnpm --filter @paperclipai/server typecheck` -> passed. - `git diff --check` -> passed. - `node ./scripts/check-docker-deps-stage.mjs` -> passed. - `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed with no lockfile update. - `pnpm exec vitest run ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed. - `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0. - GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`. - Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0 comments/annotations added, 0 unresolved review threads. - Confirmed the PR diff contains no `design/`, `packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or `.github/workflows` changes. ## Risks - Medium: task assignment authorization paths are behaviorally stricter for protected/private policy data, so existing plugin-authored policies may block assignment until explicit grants or approval flows are configured. - Medium: plugin-host authorization APIs expand the surface area available to trusted plugins and need careful review for company scoping. - Low: startup now performs a principal-access compatibility backfill, but the migration and runtime backfill use conflict-tolerant inserts. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell, git, and GitHub CLI access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/PRODUCT.md | 1 + doc/SPEC-implementation.md | 55 +- doc/SPEC.md | 2 + doc/plugins/PLUGIN_SPEC.md | 15 +- .../codex-local/src/server/codex-home.test.ts | 57 ++ .../codex-local/src/server/codex-home.ts | 30 +- ...fill_environment_manage_human_defaults.sql | 29 + ...ackfill_principal_access_compatibility.sql | 75 ++ packages/db/src/migrations/meta/_journal.json | 14 + .../src/constants.ts | 2 + .../src/manifest.ts | 7 + .../src/ui/index.tsx | 28 + packages/plugins/sdk/README.md | 13 +- .../plugins/sdk/src/host-client-factory.ts | 191 +++- packages/plugins/sdk/src/index.ts | 26 + packages/plugins/sdk/src/protocol.ts | 156 ++++ packages/plugins/sdk/src/testing.ts | 197 ++++- packages/plugins/sdk/src/types.ts | 181 ++++ packages/plugins/sdk/src/ui/index.ts | 1 + packages/plugins/sdk/src/ui/types.ts | 12 + packages/plugins/sdk/src/worker-rpc-host.ts | 130 ++- .../sdk/tests/host-client-factory.test.ts | 166 ++++ .../plugins/sdk/tests/worker-rpc-host.test.ts | 159 +++- packages/shared/src/constants.ts | 25 + packages/shared/src/index.ts | 2 + packages/shared/src/types/agent.ts | 2 +- packages/shared/src/types/plugin.ts | 5 +- packages/shared/src/validators/plugin.test.ts | 57 ++ packages/shared/src/validators/plugin.ts | 23 +- .../access-routes-permissions-upgrade.test.ts | 167 ++++ server/src/__tests__/access-service.test.ts | 287 +++++- .../agent-adapter-validation-routes.test.ts | 6 + .../agent-cross-tenant-authz-routes.test.ts | 12 + .../agent-instructions-routes.test.ts | 6 + .../__tests__/agent-live-run-routes.test.ts | 11 +- .../agent-permissions-routes.test.ts | 28 + .../src/__tests__/agent-skills-routes.test.ts | 6 + .../agent-test-environment-routes.test.ts | 6 + server/src/__tests__/app-hmr-port.test.ts | 14 +- .../__tests__/authorization-service.test.ts | 547 ++++++++++++ server/src/__tests__/better-auth.test.ts | 43 +- .../src/__tests__/company-portability.test.ts | 1 + .../plugin-worker-invocation-scope.cjs | 100 +++ .../src/__tests__/invite-join-grants.test.ts | 11 + ...ue-agent-mutation-ownership-routes.test.ts | 53 +- ...e-assigned-backlog-contract-routes.test.ts | 6 + .../issue-comment-reopen-routes.test.ts | 11 + .../issue-execution-policy-routes.test.ts | 12 + .../issue-thread-interaction-routes.test.ts | 6 + ...issue-update-comment-wakeup-routes.test.ts | 12 + ...ermissions-upgrade-boundary-routes.test.ts | 348 ++++++++ ...access-authorization-host-services.test.ts | 322 +++++++ .../src/__tests__/plugin-routes-authz.test.ts | 22 + .../src/__tests__/plugin-sdk-testing.test.ts | 25 + .../__tests__/plugin-worker-manager.test.ts | 89 ++ .../server-startup-feedback-export.test.ts | 4 + server/src/app.ts | 9 +- server/src/auth/better-auth.ts | 14 +- server/src/board-claim.ts | 12 + server/src/index.ts | 5 + server/src/routes/agents.ts | 86 +- server/src/routes/companies.ts | 9 +- server/src/routes/issues.ts | 136 ++- server/src/routes/plugins.ts | 12 +- server/src/services/access.ts | 65 +- server/src/services/agent-permissions.ts | 2 + server/src/services/agents.ts | 2 +- server/src/services/authorization.ts | 823 ++++++++++++++++++ server/src/services/company-member-roles.ts | 2 + server/src/services/company-portability.ts | 9 +- server/src/services/index.ts | 13 + .../services/plugin-capability-validator.ts | 1 + server/src/services/plugin-host-services.ts | 559 +++++++++++- server/src/services/plugin-worker-manager.ts | 122 ++- .../principal-access-compatibility.ts | 141 +++ ui/src/App.tsx | 7 +- .../CompanySettingsSidebar.test.tsx | 62 +- ui/src/components/CompanySettingsSidebar.tsx | 25 +- ui/src/components/IssueBlockedNotice.test.tsx | 104 ++- ui/src/components/IssueBlockedNotice.tsx | 87 +- ui/src/components/IssueChatThread.tsx | 7 + .../MarkdownBody.interaction.test.tsx | 95 ++ ui/src/components/MarkdownBody.tsx | 65 +- .../access/CompanySettingsNav.test.tsx | 14 +- .../components/access/CompanySettingsNav.tsx | 6 +- .../transcript/useLiveRunTranscripts.ts | 6 +- ui/src/context/LiveUpdatesProvider.tsx | 6 +- ui/src/index.css | 31 +- ui/src/lib/websocket-url.test.ts | 51 ++ ui/src/lib/websocket-url.ts | 20 + ui/src/pages/AgentDetail.tsx | 10 +- ui/src/pages/CompanyAccess.test.tsx | 116 ++- ui/src/pages/CompanyAccess.tsx | 185 ++-- ui/src/pages/CompanyInvites.test.tsx | 3 +- ui/src/pages/CompanyInvites.tsx | 8 +- .../pages/CompanySettingsPluginPage.test.tsx | 140 +++ ui/src/pages/CompanySettingsPluginPage.tsx | 88 ++ ui/src/pages/InviteLanding.test.tsx | 10 +- ui/src/pages/InviteLanding.tsx | 6 +- ui/src/pages/InviteUxLab.tsx | 14 +- ui/src/pages/IssueDetail.tsx | 5 + ui/src/plugins/bridge-init.ts | 130 ++- 102 files changed, 6744 insertions(+), 395 deletions(-) create mode 100644 packages/adapters/codex-local/src/server/codex-home.test.ts create mode 100644 packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql create mode 100644 packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql create mode 100644 packages/plugins/sdk/tests/host-client-factory.test.ts create mode 100644 server/src/__tests__/access-routes-permissions-upgrade.test.ts create mode 100644 server/src/__tests__/authorization-service.test.ts create mode 100644 server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs create mode 100644 server/src/__tests__/permissions-upgrade-boundary-routes.test.ts create mode 100644 server/src/__tests__/plugin-access-authorization-host-services.test.ts create mode 100644 server/src/services/authorization.ts create mode 100644 server/src/services/principal-access-compatibility.ts create mode 100644 ui/src/components/MarkdownBody.interaction.test.tsx create mode 100644 ui/src/lib/websocket-url.test.ts create mode 100644 ui/src/lib/websocket-url.ts create mode 100644 ui/src/pages/CompanySettingsPluginPage.test.tsx create mode 100644 ui/src/pages/CompanySettingsPluginPage.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index f955e67792a..7a3d1f7b401 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -118,6 +118,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**, - Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable. - Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review. - Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane. +- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped. - Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath. - Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 2753512bd51..e1810f755f1 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -34,7 +34,7 @@ These decisions close open questions from `SPEC.md` for V1. | Company model | Company is first-order; all business entities are company-scoped | | Board | Single human board operator per deployment | | Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | -| Visibility | Full visibility to board and all agents in same company | +| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) | @@ -487,6 +487,59 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and | Report cost | yes | yes | | Set company budget | yes | no | | Set subordinate budget | yes | yes (manager subtree only) | +| Set work-object visibility (issue/project) | no | no (pro gate) | + +## 9.4 Permission Terminology and Default Visibility Rule + +Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface. + +The approved term set is: + +- **Agent profile visibility**: identity-level facts needed for delegation and governance (name, role, capabilities, reporting lines). +- **Agent config visibility**: adapter/runtime config metadata and secret-access policy. +- **Assignment/invocation permission**: who may modify or execute a task. +- **Work-object visibility**: who can read/write issues, comments, projects, and attachments. +- **Tool/secret policy**: what tools and secret-backed credentials an agent can use and what appears in logs. +- **Escalation authority**: where refusal/blocked decisions route (manager, then board). + +## 9.5 Core V1 Rule: what “private” means + +- A **private marker** on an agent profile (where represented) does **not** make company-visible work private. +- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default. +- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls. + +## 9.6 V1 vs Pro/Enterprise Controls (recommended target split) + +| Permission area | Free / V1 default | Pro / Enterprise | +|---|---|---| +| Company boundary | Hard boundary only (`company_id`) | Multi-company policy overlays (`membership`, `project`, and `task` scopes) | +| Simple roles | Board + agent roles with existing approval/budget gates | Additional role aliases + scoped approver roles | +| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces | +| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement | +| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes | +| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels | +| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation | +| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows | + +## 9.7 Recommended first-slice implementation order + +1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates. +2. Treat profile privacy as external-facing signal only; do not use it to hide company-visible work objects. +3. Enforce assignment/invocation coupling (`assignee`/`agent` checks, checkout semantics, invocation checks). +4. Standardize read-path redaction for secrets and secret references, including logs and activity. +5. Standardize escalation paths (`blocked` and refusal) so non-board agents hand off by manager/board with immutable audit. + +## 9.8 Scoped Task Assignment Grants + +`tasks:assign` remains the broad assignment permission. Existing unscoped grants preserve compatibility and allow the principal to assign any visible company task within normal company-boundary checks. + +`tasks:assign_scope` is the constrained assignment permission. Its `principal_permission_grants.scope` JSON must include at least one recognized constraint: + +- Project scope: `projectId`, `projectIds`, or `allow: ["project:"]`. +- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:"]`. +- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:"]`. + +When multiple constraint families are present, assignment must satisfy all of them. Denials return `403` with a generic scope explanation and do not disclose details about hidden or unrelated resources. ## 10. API Contract (REST) diff --git a/doc/SPEC.md b/doc/SPEC.md index 6a7039ca67f..95b632d492f 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -141,6 +141,8 @@ Hierarchical reporting structure. CEO at top, reports cascade down. **Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control. +Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place. + Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what. ### Cross-Team Work diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index c8080e56b9c..d1bd103486c 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -364,13 +364,16 @@ export interface PaperclipPluginManifestV1 { | "contextMenuItem" | "commentAnnotation" | "commentContextMenuItem" - | "settingsPage"; + | "settingsPage" + | "companySettingsPage"; id: string; displayName: string; /** Which export name in the UI bundle provides this component */ exportName: string; /** For detailTab: which entity types this tab appears on */ entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">; + /** For page and companySettingsPage: single route segment */ + routePath?: string; }>; }; } @@ -1206,6 +1209,8 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards. +For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations. + ## 20. Local Tooling Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations. @@ -1455,6 +1460,14 @@ Each plugin may expose a company-context main page: This page is where board users do most day-to-day work. +## 24.4 Company Settings Plugin Page + +Each ready plugin may expose a company settings page: + +- `/:companyPrefix/company/settings/:routePath` + +The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages. + ## 25. Uninstall And Data Lifecycle When a plugin is uninstalled, the host must handle plugin-owned data explicitly. diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts new file mode 100644 index 00000000000..864837510b3 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { prepareManagedCodexHome } from "./codex-home.js"; + +describe("codex managed home", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("treats a concurrently-created expected auth symlink as success", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-")); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const managedCodexHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "codex-home", + ); + const sharedAuth = path.join(sharedCodexHome, "auth.json"); + const managedAuth = path.join(managedCodexHome, "auth.json"); + + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8"); + + const originalSymlink = fs.symlink.bind(fs); + vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => { + await originalSymlink(source, target, type); + const error = new Error("file already exists") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + }); + + try { + await expect( + prepareManagedCodexHome( + { + CODEX_HOME: sharedCodexHome, + PAPERCLIP_HOME: paperclipHome, + PAPERCLIP_INSTANCE_ID: "default", + }, + async () => {}, + "company-1", + ), + ).resolves.toBe(managedCodexHome); + + expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 0cb737bb28f..ee87e6cbf5e 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise { await fs.mkdir(path.dirname(target), { recursive: true }); } +async function isExpectedSymlink(target: string, source: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) return false; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return false; + + return path.resolve(path.dirname(target), linkedPath) === path.resolve(source); +} + +async function createExpectedSymlink(target: string, source: string): Promise { + try { + await fs.symlink(source, target); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" && await isExpectedSymlink(target, source)) return; + throw error; + } +} + async function ensureSymlink(target: string, source: string): Promise { const existing = await fs.lstat(target).catch(() => null); if (!existing) { await ensureParentDir(target); - await fs.symlink(source, target); + await createExpectedSymlink(target, source); return; } @@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise { return; } - const linkedPath = await fs.readlink(target).catch(() => null); - if (!linkedPath) return; - - const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); - if (resolvedLinkedPath === source) return; + if (await isExpectedSymlink(target, source)) return; await fs.unlink(target); - await fs.symlink(source, target); + await createExpectedSymlink(target, source); } async function ensureCopiedFile(target: string, source: string): Promise { diff --git a/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql new file mode 100644 index 00000000000..4d05e171bfc --- /dev/null +++ b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql @@ -0,0 +1,29 @@ +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + "company_id", + 'user', + "principal_id", + 'environments:manage', + NULL, + NULL, + now(), + now() +FROM "company_memberships" +WHERE "principal_type" = 'user' + AND "status" = 'active' + AND "membership_role" IN ('owner', 'admin') +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" +) DO NOTHING; diff --git a/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql new file mode 100644 index 00000000000..6e78c7e5083 --- /dev/null +++ b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql @@ -0,0 +1,75 @@ +INSERT INTO "company_memberships" ( + "company_id", + "principal_type", + "principal_id", + "status", + "membership_role", + "created_at", + "updated_at" +) +SELECT + "company_id", + 'agent', + "id", + 'active', + 'member', + now(), + now() +FROM "agents" +WHERE "status" NOT IN ('pending_approval', 'terminated') +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id" +) DO NOTHING; + +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + memberships."company_id", + 'user', + memberships."principal_id", + role_defaults."permission_key", + NULL, + NULL, + now(), + now() +FROM "company_memberships" memberships +JOIN ( + VALUES + ('owner', 'agents:create'), + ('owner', 'environments:manage'), + ('owner', 'users:invite'), + ('owner', 'users:manage_permissions'), + ('owner', 'tasks:assign'), + ('owner', 'joins:approve'), + ('admin', 'agents:create'), + ('admin', 'environments:manage'), + ('admin', 'users:invite'), + ('admin', 'tasks:assign'), + ('admin', 'joins:approve'), + ('operator', 'tasks:assign') +) AS role_defaults("membership_role", "permission_key") + ON role_defaults."membership_role" = CASE + WHEN memberships."membership_role" = 'owner' THEN 'owner' + WHEN memberships."membership_role" = 'admin' THEN 'admin' + WHEN memberships."membership_role" = 'viewer' THEN 'viewer' + WHEN memberships."membership_role" = 'member' THEN 'operator' + ELSE 'operator' + END +WHERE memberships."principal_type" = 'user' + AND memberships."status" = 'active' +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" +) DO NOTHING; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index a47bae7c2d1..7aaa96d3da3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -610,6 +610,20 @@ "when": 1778976000000, "tag": "0086_routine_env_runtime_contract", "breakpoints": true + }, + { + "idx": 87, + "version": "7", + "when": 1779360000000, + "tag": "0087_backfill_environment_manage_human_defaults", + "breakpoints": true + }, + { + "idx": 88, + "version": "7", + "when": 1779446400000, + "tag": "0088_backfill_principal_access_compatibility", + "breakpoints": true } ] } diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts index 9c18f610b37..8a2e44c00aa 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts @@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink"; export const SLOT_IDS = { page: "kitchen-sink-page", settingsPage: "kitchen-sink-settings-page", + companySettingsPage: "kitchen-sink-company-settings-page", dashboardWidget: "kitchen-sink-dashboard-widget", sidebar: "kitchen-sink-sidebar-link", sidebarPanel: "kitchen-sink-sidebar-panel", @@ -23,6 +24,7 @@ export const SLOT_IDS = { export const EXPORT_NAMES = { page: "KitchenSinkPage", settingsPage: "KitchenSinkSettingsPage", + companySettingsPage: "KitchenSinkCompanySettingsPage", dashboardWidget: "KitchenSinkDashboardWidget", sidebar: "KitchenSinkSidebarLink", sidebarPanel: "KitchenSinkSidebarPanel", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts index bcff32c2eed..5fc99281868 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts @@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = { displayName: "Kitchen Sink Settings", exportName: EXPORT_NAMES.settingsPage, }, + { + type: "companySettingsPage", + id: SLOT_IDS.companySettingsPage, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.companySettingsPage, + routePath: "kitchen-sink", + }, { type: "dashboardWidget", id: SLOT_IDS.dashboardWidget, diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx index ea3cb491e31..95ce4c14c1c 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -10,6 +10,7 @@ import { usePluginToast, type PluginCommentAnnotationProps, type PluginCommentContextMenuItemProps, + type PluginCompanySettingsPageProps, type PluginDetailTabProps, type PluginPageProps, type PluginProjectSidebarItemProps, @@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) { ); } +export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) { + const hostNavigation = useHostNavigation(); + const overview = usePluginOverview(context.companyId); + const href = hostNavigation.resolveHref("/company/settings/kitchen-sink"); + + return ( +
+
+
+
+ Mounted inside company settings +
+ This fixture proves a ready plugin can add a settings sidebar item and render with company context. +
+ +
+
+
+
+ ); +} + export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) { const hostNavigation = useHostNavigation(); const overview = usePluginOverview(context.companyId); diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index c1f73d98ba2..b9a02d5c149 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url); | `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. | | `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. | -**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest. +**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest. **Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details. @@ -134,7 +134,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, **Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events. -**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. +**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope. ## Scheduled (recurring) jobs @@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `activity.read` | | | `costs.read` | | | `issues.orchestration.read` | +| | `access.members.read` | +| | `access.invites.read` | +| | `authorization.grants.read` | +| | `authorization.policies.read` | +| | `authorization.audit.read` | | | `database.namespace.read` | | | `issues.create` | | | `issues.update` | @@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `local.folders` | | **Agent** | `agent.tools.register` | | | `agents.invoke` | +| | `access.members.write` | +| | `access.invites.write` | +| | `authorization.grants.write` | +| | `authorization.policies.write` | | | `agent.sessions.create` | | | `agent.sessions.list` | | | `agent.sessions.send` | diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 8b85b0b7e1f..3264afbccb9 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -49,7 +49,7 @@ */ import type { PluginCapability } from "@paperclipai/shared"; -import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js"; +import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js"; import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js"; // --------------------------------------------------------------------------- @@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error { } } +/** + * Thrown when a worker→host call asks for company-scoped data outside the + * company authorized for the current top-level plugin invocation. + */ +export class InvocationScopeDeniedError extends Error { + override readonly name = "InvocationScopeDeniedError"; + readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED; + + constructor(pluginId: string, method: string, message: string) { + super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`); + } +} + // --------------------------------------------------------------------------- // Host service interfaces // --------------------------------------------------------------------------- @@ -257,6 +270,28 @@ export interface HostServices { create(params: WorkerToHostMethods["goals.create"][0]): Promise; update(params: WorkerToHostMethods["goals.update"][0]): Promise; }; + + /** Provides `access.members.*` and `access.invites.*`. */ + access: { + listMembers(params: WorkerToHostMethods["access.members.list"][0]): Promise; + getMember(params: WorkerToHostMethods["access.members.get"][0]): Promise; + updateMember(params: WorkerToHostMethods["access.members.update"][0]): Promise; + listInvites(params: WorkerToHostMethods["access.invites.list"][0]): Promise; + createInvite(params: WorkerToHostMethods["access.invites.create"][0]): Promise; + revokeInvite(params: WorkerToHostMethods["access.invites.revoke"][0]): Promise; + }; + + /** Provides authorization grant, policy, preview, and audit helpers. */ + authorization: { + listGrants(params: WorkerToHostMethods["authorization.grants.list"][0]): Promise; + setGrants(params: WorkerToHostMethods["authorization.grants.set"][0]): Promise; + policySummary(params: WorkerToHostMethods["authorization.policies.summary"][0]): Promise; + getPolicy(params: WorkerToHostMethods["authorization.policies.get"][0]): Promise; + updatePolicy(params: WorkerToHostMethods["authorization.policies.update"][0]): Promise; + previewAssignment(params: WorkerToHostMethods["authorization.policies.previewAssignment"][0]): Promise; + explainAssignment(params: WorkerToHostMethods["authorization.policies.explainAssignment"][0]): Promise; + searchAudit(params: WorkerToHostMethods["authorization.audit.search"][0]): Promise; + }; } // --------------------------------------------------------------------------- @@ -292,6 +327,7 @@ export interface HostClientFactoryOptions { */ type HostHandler = ( params: WorkerToHostMethods[M][0], + context?: WorkerHostCallContext, ) => Promise; /** @@ -431,6 +467,24 @@ const METHOD_CAPABILITY_MAP: Record(options.capabilities); + type CompanyScopeRequest = + | { kind: "none" } + | { kind: "single"; companyId: string } + | { kind: "all" }; + + const noCompanyScope: CompanyScopeRequest = { kind: "none" }; + + function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function requestedCompanyScope( + method: WorkerToHostMethodName, + params: unknown, + ): CompanyScopeRequest { + if (method === "companies.list") return { kind: "all" }; + if (!isRecord(params)) return noCompanyScope; + + const companyId = readNonEmptyString(params.companyId); + if (companyId) return { kind: "single", companyId }; + + if (params.scopeKind === "company") { + const scopeId = readNonEmptyString(params.scopeId); + return scopeId ? { kind: "single", companyId: scopeId } : { kind: "all" }; + } + + if (method === "events.subscribe" && isRecord(params.filter)) { + const filterCompanyId = readNonEmptyString(params.filter.companyId); + if (filterCompanyId) return { kind: "single", companyId: filterCompanyId }; + } + + return noCompanyScope; + } + + function requireInvocationCompanyScope( + method: WorkerToHostMethodName, + params: unknown, + context?: WorkerHostCallContext, + ): void { + const requested = requestedCompanyScope(method, params); + if (requested.kind === "none") return; + + if (context?.invalidInvocationScope) { + throw new InvocationScopeDeniedError( + pluginId, + method, + "the worker referenced a missing, expired, or unknown invocation scope", + ); + } + + const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); + if (!allowedCompanyId) return; + + if (requested.kind === "all") { + if (method === "companies.list") return; + throw new InvocationScopeDeniedError( + pluginId, + method, + `the current invocation is scoped to company "${allowedCompanyId}"`, + ); + } + + if (requested.companyId !== allowedCompanyId) { + throw new InvocationScopeDeniedError( + pluginId, + method, + `requested company "${requested.companyId}" but the current invocation is scoped to company "${allowedCompanyId}"`, + ); + } + } + /** * Assert that the plugin has the required capability for a method. * Throws `CapabilityDeniedError` if the capability is missing. @@ -485,9 +614,10 @@ export function createHostClientHandlers( method: M, handler: HostHandler, ): HostHandler { - return async (params: WorkerToHostMethods[M][0]) => { + return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => { requireCapability(method); - return handler(params); + requireInvocationCompanyScope(method, params, context); + return handler(params, context); }; } @@ -591,8 +721,13 @@ export function createHostClientHandlers( }), // Companies - "companies.list": gated("companies.list", async (params) => { - return services.companies.list(params); + "companies.list": gated("companies.list", async (params, context) => { + const rows = await services.companies.list(params); + const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); + if (!allowedCompanyId) return rows; + return rows.filter((company) => + isRecord(company) && company.id === allowedCompanyId, + ) as WorkerToHostMethods["companies.list"][1]; }), "companies.get": gated("companies.get", async (params) => { return services.companies.get(params); @@ -772,6 +907,52 @@ export function createHostClientHandlers( "goals.update": gated("goals.update", async (params) => { return services.goals.update(params); }), + + // Access + "access.members.list": gated("access.members.list", async (params) => { + return services.access.listMembers(params); + }), + "access.members.get": gated("access.members.get", async (params) => { + return services.access.getMember(params); + }), + "access.members.update": gated("access.members.update", async (params) => { + return services.access.updateMember(params); + }), + "access.invites.list": gated("access.invites.list", async (params) => { + return services.access.listInvites(params); + }), + "access.invites.create": gated("access.invites.create", async (params) => { + return services.access.createInvite(params); + }), + "access.invites.revoke": gated("access.invites.revoke", async (params) => { + return services.access.revokeInvite(params); + }), + + // Authorization + "authorization.grants.list": gated("authorization.grants.list", async (params) => { + return services.authorization.listGrants(params); + }), + "authorization.grants.set": gated("authorization.grants.set", async (params) => { + return services.authorization.setGrants(params); + }), + "authorization.policies.summary": gated("authorization.policies.summary", async (params) => { + return services.authorization.policySummary(params); + }), + "authorization.policies.get": gated("authorization.policies.get", async (params) => { + return services.authorization.getPolicy(params); + }), + "authorization.policies.update": gated("authorization.policies.update", async (params) => { + return services.authorization.updatePolicy(params); + }), + "authorization.policies.previewAssignment": gated("authorization.policies.previewAssignment", async (params) => { + return services.authorization.previewAssignment(params); + }), + "authorization.policies.explainAssignment": gated("authorization.policies.explainAssignment", async (params) => { + return services.authorization.explainAssignment(params); + }), + "authorization.audit.search": gated("authorization.audit.search", async (params) => { + return services.authorization.searchAudit(params); + }), }; } diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 92db6d696cf..2ca0fa9a7c3 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -58,6 +58,7 @@ export { createHostClientHandlers, getRequiredCapability, CapabilityDeniedError, + InvocationScopeDeniedError, } from "./host-client-factory.js"; // JSON-RPC protocol helpers and constants @@ -137,6 +138,9 @@ export type { JsonRpcMessage, JsonRpcErrorCode, PluginRpcErrorCode, + PluginInvocationScope, + PluginInvocationContext, + WorkerHostCallContext, InitializeParams, InitializeResult, ConfigChangedParams, @@ -218,6 +222,17 @@ export type { PluginIssueSubtree, PluginIssueSummariesClient, PluginAgentsClient, + PluginAccessClient, + PluginAccessMembersClient, + PluginAccessInvitesClient, + PluginAccessMember, + PluginAccessInvite, + PluginAuthorizationClient, + PluginAuthorizationPolicySummary, + PluginAuthorizationPolicyRecord, + PluginAssignmentPreviewInput, + PluginAuthorizationDecisionResult, + PluginAuthorizationAuditEntry, PluginAgentSessionsClient, AgentSession, AgentSessionEvent, @@ -253,7 +268,12 @@ export type { IssueDocumentSummary, Agent, Goal, + PermissionKey, + PrincipalPermissionGrant, + PrincipalType, PluginDatabaseClient, + HumanCompanyMembershipRole, + MembershipStatus, } from "./types.js"; // Manifest and constant types re-exported from @paperclipai/shared @@ -353,6 +373,7 @@ export { PLUGIN_CAPABILITIES, PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_STATE_SCOPE_KINDS, PLUGIN_JOB_STATUSES, PLUGIN_JOB_RUN_STATUSES, @@ -360,4 +381,9 @@ export { PLUGIN_WEBHOOK_DELIVERY_STATUSES, PLUGIN_EVENT_TYPES, PLUGIN_BRIDGE_ERROR_CODES, + PERMISSION_KEYS, + HUMAN_COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, + MEMBERSHIP_STATUSES, + PRINCIPAL_TYPES, } from "@paperclipai/shared"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 24bc871b369..48075b2f6b9 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -39,6 +39,7 @@ import type { Agent, Goal, PluginLocalFolderDeclaration, + PrincipalPermissionGrant, } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; @@ -57,6 +58,13 @@ import type { ToolResult, PluginLocalFolderListing, PluginLocalFolderStatus, + PluginAccessInvite, + PluginAccessMember, + PluginAssignmentPreviewInput, + PluginAuthorizationAuditEntry, + PluginAuthorizationDecisionResult, + PluginAuthorizationPolicyRecord, + PluginAuthorizationPolicySummary, } from "./types.js"; import type { PluginHealthDiagnostics, @@ -96,6 +104,14 @@ export interface JsonRpcRequest< readonly method: TMethod; /** Structured parameters for the method call. */ readonly params: TParams; + /** + * Host-issued metadata for the top-level plugin invocation that is currently + * executing. The worker treats this as opaque and echoes only the id on + * worker→host calls made from the same async execution context. + */ + readonly paperclipInvocation?: PluginInvocationContext; + /** Opaque top-level invocation id echoed by worker→host requests. */ + readonly paperclipInvocationId?: string; } /** @@ -156,6 +172,13 @@ export interface JsonRpcNotification< readonly method: TMethod; /** Structured parameters for the notification. */ readonly params: TParams; + /** + * Host-issued metadata for host→worker push notifications such as events. + * Worker→host notifications echo only `paperclipInvocationId`. + */ + readonly paperclipInvocation?: PluginInvocationContext; + /** Opaque top-level invocation id echoed by worker→host notifications. */ + readonly paperclipInvocationId?: string; } /** @@ -217,6 +240,36 @@ export const PLUGIN_RPC_ERROR_CODES = { export type PluginRpcErrorCode = (typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES]; +// --------------------------------------------------------------------------- +// Invocation scope metadata +// --------------------------------------------------------------------------- + +/** + * Company scope attached by the host to one top-level plugin invocation. + * Absence of this metadata means the invocation is instance/global scoped. + */ +export interface PluginInvocationScope { + companyId: string; +} + +/** + * Opaque invocation metadata generated by the host. Workers must not derive or + * mutate this. They only echo the id on nested worker→host RPC calls. + */ +export interface PluginInvocationContext { + id: string; + scope: PluginInvocationScope; +} + +/** + * Context provided to host-side worker→host handlers after the worker echoes a + * host-issued invocation id. + */ +export interface WorkerHostCallContext { + invocationScope?: PluginInvocationScope | null; + invalidInvocationScope?: boolean; +} + // --------------------------------------------------------------------------- // Host → Worker Method Signatures (§13 Host-Worker Protocol) // --------------------------------------------------------------------------- @@ -302,6 +355,8 @@ export interface RunJobParams { export interface GetDataParams { /** Plugin-defined data key (e.g. `"sync-health"`). */ key: string; + /** Host-authorized active company scope, when this bridge call is company-scoped. */ + companyId?: string | null; /** Context and query parameters from the UI. */ params: Record; /** Optional launcher/container metadata from the host render environment. */ @@ -316,6 +371,8 @@ export interface GetDataParams { export interface PerformActionParams { /** Plugin-defined action key (e.g. `"resync"`). */ key: string; + /** Host-authorized active company scope, when this bridge call is company-scoped. */ + companyId?: string | null; /** Action parameters from the UI. */ params: Record; /** Optional launcher/container metadata from the host render environment. */ @@ -1128,6 +1185,105 @@ export interface WorkerToHostMethods { }, result: Goal, ]; + + // Access + "access.members.list": [ + params: { companyId: string; includeArchived?: boolean }, + result: PluginAccessMember[], + ]; + "access.members.get": [ + params: { memberId: string; companyId: string }, + result: PluginAccessMember | null, + ]; + "access.members.update": [ + params: { + memberId: string; + companyId: string; + patch: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + }; + }, + result: PluginAccessMember, + ]; + "access.invites.list": [ + params: { + companyId: string; + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; + }, + result: { invites: PluginAccessInvite[]; nextOffset: number | null }, + ]; + "access.invites.create": [ + params: { + companyId: string; + allowedJoinTypes?: "human" | "agent" | "both"; + humanRole?: string | null; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }, + result: PluginAccessInvite & { token: string }, + ]; + "access.invites.revoke": [ + params: { inviteId: string; companyId: string }, + result: PluginAccessInvite, + ]; + + // Authorization + "authorization.grants.list": [ + params: { companyId: string; principalType?: string; principalId?: string }, + result: PrincipalPermissionGrant[], + ]; + "authorization.grants.set": [ + params: { + companyId: string; + principalType: string; + principalId: string; + grants: Array<{ permissionKey: string; scope?: Record | null }>; + grantedByUserId?: string | null; + }, + result: PrincipalPermissionGrant[], + ]; + "authorization.policies.summary": [ + params: { companyId: string }, + result: PluginAuthorizationPolicySummary, + ]; + "authorization.policies.get": [ + params: { companyId: string; resourceType: "company" | "agent" | "project" | "issue"; resourceId: string }, + result: PluginAuthorizationPolicyRecord | null, + ]; + "authorization.policies.update": [ + params: { + companyId: string; + resourceType: "company" | "agent" | "project" | "issue"; + resourceId: string; + policy: Record | null; + }, + result: PluginAuthorizationPolicyRecord, + ]; + "authorization.policies.previewAssignment": [ + params: PluginAssignmentPreviewInput, + result: PluginAuthorizationDecisionResult, + ]; + "authorization.policies.explainAssignment": [ + params: PluginAssignmentPreviewInput, + result: PluginAuthorizationDecisionResult, + ]; + "authorization.audit.search": [ + params: { + companyId: string; + action?: string; + actorType?: string; + actorId?: string; + entityType?: string; + entityId?: string; + decision?: string; + limit?: number; + offset?: number; + }, + result: PluginAuthorizationAuditEntry[], + ]; } /** Union of all worker→host method names. */ diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index de34e8ae112..299af7c6866 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -38,6 +38,10 @@ import type { AgentSessionEvent, PluginLocalFolderEntry, PluginLocalFolderStatus, + PluginAccessMember, + PrincipalPermissionGrant, + PermissionKey, + PrincipalType, } from "./types.js"; import type { PluginEnvironmentValidateConfigParams, @@ -73,7 +77,7 @@ export interface TestHarnessLogEntry { export interface TestHarness { /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ ctx: PluginContext; - /** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */ + /** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */ seed(input: { companies?: Company[]; projects?: Project[]; @@ -83,6 +87,8 @@ export interface TestHarness { goals?: Goal[]; projectWorkspaces?: PluginWorkspace[]; executionWorkspaces?: PluginExecutionWorkspaceMetadata[]; + accessMembers?: PluginAccessMember[]; + principalGrants?: PrincipalPermissionGrant[]; }): void; setConfig(config: Record): void; /** Dispatch a host or plugin event to registered handlers. */ @@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const issueDocuments = new Map(); const agents = new Map(); const goals = new Map(); + const accessMembers = new Map(); + const principalGrants = new Map(); + + function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) { + return `${companyId}:${principalType}:${principalId}`; + } + function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) { + return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? []; + } + function setPrincipalGrants( + companyId: string, + principalType: PrincipalType, + principalId: string, + grants: Array<{ permissionKey: PermissionKey; scope?: Record | null }>, + ) { + const stamped = grants.map((grant) => ({ + principalType, + principalId, + permissionKey: grant.permissionKey, + scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null, + })) as PrincipalPermissionGrant[]; + principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped); + const member = [...accessMembers.values()].find( + (entry) => + entry.companyId === companyId + && entry.principalType === principalType + && entry.principalId === principalId, + ); + if (member) { + accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() }); + } + return stamped; + } const projectWorkspaces = new Map(); const executionWorkspaces = new Map(); const localFolderStatuses = new Map(); @@ -1983,6 +2022,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return updated; }, }, + access: { + members: { + async list(input) { + requireCapability(manifest, capabilitySet, "access.members.read"); + const cid = requireCompanyId(input.companyId); + const includeArchived = input.includeArchived === true; + return [...accessMembers.values()] + .filter((member) => member.companyId === cid) + .filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"])) + .map((member) => ({ + ...member, + grants: getPrincipalGrants(cid, member.principalType, member.principalId), + })); + }, + async get(memberId, companyId) { + requireCapability(manifest, capabilitySet, "access.members.read"); + const cid = requireCompanyId(companyId); + const member = accessMembers.get(memberId); + if (!member || member.companyId !== cid) return null; + return { + ...member, + grants: getPrincipalGrants(cid, member.principalType, member.principalId), + }; + }, + async update(memberId, patch, companyId) { + requireCapability(manifest, capabilitySet, "access.members.write"); + const cid = requireCompanyId(companyId); + const member = accessMembers.get(memberId); + if (!member || member.companyId !== cid) { + throw new Error(`Membership not found: ${memberId}`); + } + const updated: PluginAccessMember = { + ...member, + membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole, + status: patch.status === undefined ? member.status : patch.status, + updatedAt: new Date().toISOString(), + }; + accessMembers.set(memberId, updated); + return { + ...updated, + grants: getPrincipalGrants(cid, updated.principalType, updated.principalId), + }; + }, + }, + invites: { + async list(input) { + requireCapability(manifest, capabilitySet, "access.invites.read"); + requireCompanyId(input.companyId); + return { invites: [], nextOffset: null }; + }, + async create(input) { + requireCapability(manifest, capabilitySet, "access.invites.write"); + requireCompanyId(input.companyId); + throw new Error("Invite creation is not implemented in the plugin test harness"); + }, + async revoke(inviteId, companyId) { + requireCapability(manifest, capabilitySet, "access.invites.write"); + requireCompanyId(companyId); + throw new Error(`Invite not found: ${inviteId}`); + }, + }, + }, + authorization: { + grants: { + async list(input) { + requireCapability(manifest, capabilitySet, "authorization.grants.read"); + const cid = requireCompanyId(input.companyId); + if (input.principalType && input.principalId) { + return getPrincipalGrants(cid, input.principalType, input.principalId); + } + const out: PrincipalPermissionGrant[] = []; + for (const [key, grants] of principalGrants.entries()) { + if (!key.startsWith(`${cid}:`)) continue; + for (const grant of grants) { + if (input.principalType && grant.principalType !== input.principalType) continue; + if (input.principalId && grant.principalId !== input.principalId) continue; + out.push(grant); + } + } + return out; + }, + async set(input) { + requireCapability(manifest, capabilitySet, "authorization.grants.write"); + const cid = requireCompanyId(input.companyId); + return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants); + }, + }, + policies: { + async summary(companyId) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + const cid = requireCompanyId(companyId); + const members = [...accessMembers.values()].filter((member) => member.companyId === cid); + let grantCount = 0; + for (const [key, grants] of principalGrants.entries()) { + if (key.startsWith(`${cid}:`)) grantCount += grants.length; + } + return { + companyId: cid, + permissionsMode: "simple", + memberCount: members.length, + activeMemberCount: members.filter((member) => member.status === "active").length, + grantCount, + advancedPolicyAvailable: false, + }; + }, + async get(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + requireCompanyId(input.companyId); + return null; + }, + async update(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.write"); + const cid = requireCompanyId(input.companyId); + return { + companyId: cid, + resourceType: input.resourceType, + resourceId: input.resourceId, + policy: input.policy, + updatedAt: new Date().toISOString(), + }; + }, + async previewAssignment(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + requireCompanyId(input.companyId); + return { + allowed: true, + action: "issue.assign", + explanation: "Allowed by simple company-wide defaults in the plugin test harness.", + reason: "simple_mode", + }; + }, + async explainAssignment(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + requireCompanyId(input.companyId); + return { + allowed: true, + action: "issue.assign", + explanation: "Allowed by simple company-wide defaults in the plugin test harness.", + reason: "simple_mode", + }; + }, + }, + audit: { + async search(input) { + requireCapability(manifest, capabilitySet, "authorization.audit.read"); + requireCompanyId(input.companyId); + return []; + }, + }, + }, data: { register(key, handler) { dataHandlers.set(key, handler); @@ -2065,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { projectWorkspaces.set(row.projectId, list); } for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row); + for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row); + for (const row of input.principalGrants ?? []) { + const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? []; + list.push(row); + principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list); + } }, setConfig(config) { currentConfig = { ...config }; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index bcbb7f4e71d..456f4a9e7c0 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -39,6 +39,12 @@ import type { RoutineRun, Agent, Goal, + HumanCompanyMembershipRole, + InviteJoinType, + MembershipStatus, + PermissionKey, + PrincipalPermissionGrant, + PrincipalType, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -120,6 +126,12 @@ export type { IssueSurfaceVisibility, Agent, Goal, + HumanCompanyMembershipRole, + InviteJoinType, + MembershipStatus, + PermissionKey, + PrincipalPermissionGrant, + PrincipalType, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -1576,6 +1588,169 @@ export interface PluginGoalsClient { ): Promise; } +// --------------------------------------------------------------------------- +// Access and Authorization +// --------------------------------------------------------------------------- + +export interface PluginAccessMember { + id: string; + companyId: string; + principalType: PrincipalType; + principalId: string; + status: MembershipStatus; + membershipRole: string | null; + grants: PrincipalPermissionGrant[]; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface PluginAccessInvite { + id: string; + companyId: string | null; + inviteType: string; + allowedJoinTypes: InviteJoinType; + defaultsPayload: Record | null; + expiresAt: Date | string; + invitedByUserId: string | null; + revokedAt: Date | string | null; + acceptedAt: Date | string | null; + createdAt: Date | string; + updatedAt: Date | string; + state: "active" | "revoked" | "accepted" | "expired"; +} + +export interface PluginAccessMembersClient { + list(input: { companyId: string; includeArchived?: boolean }): Promise; + get(memberId: string, companyId: string): Promise; + update( + memberId: string, + patch: { + membershipRole?: HumanCompanyMembershipRole | null; + status?: Extract; + }, + companyId: string, + ): Promise; +} + +export interface PluginAccessInvitesClient { + list(input: { + companyId: string; + state?: PluginAccessInvite["state"]; + limit?: number; + offset?: number; + }): Promise<{ invites: PluginAccessInvite[]; nextOffset: number | null }>; + create(input: { + companyId: string; + allowedJoinTypes?: InviteJoinType; + humanRole?: HumanCompanyMembershipRole | null; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }): Promise; + revoke(inviteId: string, companyId: string): Promise; +} + +export interface PluginAccessClient { + /** Read and update company memberships. Requires `access.members.*`. */ + members: PluginAccessMembersClient; + /** Read, create, and revoke company invites. Requires `access.invites.*`. */ + invites: PluginAccessInvitesClient; +} + +export interface PluginAuthorizationPolicySummary { + companyId: string; + permissionsMode: "simple"; + memberCount: number; + activeMemberCount: number; + grantCount: number; + advancedPolicyAvailable: false; +} + +export interface PluginAuthorizationPolicyRecord { + resourceType: "company" | "agent" | "project" | "issue"; + resourceId: string; + companyId: string; + policy: Record | null; + updatedAt: Date | string | null; +} + +export interface PluginAssignmentPreviewInput { + companyId: string; + actor: + | { type: "board"; userId?: string | null; companyIds?: string[]; isInstanceAdmin?: boolean } + | { type: "agent"; agentId: string; companyId: string }; + target: { + issueId?: string | null; + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + status?: string | null; + }; +} + +export interface PluginAuthorizationDecisionResult { + allowed: boolean; + action: string; + explanation: string; + reason: string; + grant?: { + principalType: PrincipalType; + principalId: string; + permissionKey: PermissionKey; + scope: Record | null; + }; +} + +export interface PluginAuthorizationAuditEntry { + id: string; + companyId: string; + actorType: string; + actorId: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + createdAt: Date | string; +} + +export interface PluginAuthorizationClient { + grants: { + list(input: { companyId: string; principalType?: PrincipalType; principalId?: string }): Promise; + set(input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + grants: Array<{ permissionKey: PermissionKey; scope?: Record | null }>; + grantedByUserId?: string | null; + }): Promise; + }; + policies: { + summary(companyId: string): Promise; + get(input: { companyId: string; resourceType: PluginAuthorizationPolicyRecord["resourceType"]; resourceId: string }): Promise; + update(input: { + companyId: string; + resourceType: PluginAuthorizationPolicyRecord["resourceType"]; + resourceId: string; + policy: Record | null; + }): Promise; + previewAssignment(input: PluginAssignmentPreviewInput): Promise; + explainAssignment(input: PluginAssignmentPreviewInput): Promise; + }; + audit: { + search(input: { + companyId: string; + action?: string; + actorType?: string; + actorId?: string; + entityType?: string; + entityId?: string; + decision?: string; + limit?: number; + offset?: number; + }): Promise; + }; +} + // --------------------------------------------------------------------------- // Streaming (worker → UI push channel) // --------------------------------------------------------------------------- @@ -1716,6 +1891,12 @@ export interface PluginContext { /** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */ goals: PluginGoalsClient; + /** Read and manage access memberships and invites. Requires `access.*` capabilities. */ + access: PluginAccessClient; + + /** Read and manage authorization grants, policy summaries, previews, and audit entries. Requires `authorization.*` capabilities. */ + authorization: PluginAuthorizationClient; + /** Register getData handlers for the plugin's UI components. */ data: PluginDataClient; diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index 8f90af70a3a..e2ce8f63aec 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -146,6 +146,7 @@ export type { // Slot component prop interfaces export type { PluginPageProps, + PluginCompanySettingsPageProps, PluginWidgetProps, PluginDetailTabProps, PluginSidebarProps, diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index b8216836157..8923803e4e5 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -229,6 +229,18 @@ export interface PluginPageProps { context: PluginHostContext; } +/** + * Props passed to a plugin company settings page component. + * + * A company settings page is mounted at + * `/:companyPrefix/company/settings/:routePath` and always receives the active + * company id and prefix when available. + */ +export interface PluginCompanySettingsPageProps { + /** The current host context, including company id and prefix. */ + context: PluginHostContext; +} + /** * Props passed to a plugin dashboard widget component. * diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 9bfae39e675..a50b6d8f14c 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -35,6 +35,7 @@ */ import fs from "node:fs"; +import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { fileURLToPath } from "node:url"; @@ -66,6 +67,7 @@ import type { } from "./types.js"; import type { JsonRpcId, + JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, InitializeParams, @@ -85,6 +87,7 @@ import type { PluginEnvironmentResumeLeaseParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentProbeParams, + PluginInvocationContext, WorkerToHostMethodName, WorkerToHostMethods, } from "./protocol.js"; @@ -279,6 +282,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost let manifest: PaperclipPluginManifestV1 | null = null; let currentConfig: Record = {}; let databaseNamespace: string | null = null; + const invocationContextStorage = new AsyncLocalStorage(); // Plugin handler registrations (populated during setup()) const eventHandlers: EventRegistration[] = []; @@ -365,7 +369,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }); try { - const request = createRequest(method, params, id); + const activeInvocation = invocationContextStorage.getStore(); + const request = { + ...createRequest(method, params, id), + ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}), + }; sendMessage(request); } catch (err) { settle(reject, err instanceof Error ? err : new Error(String(err))); @@ -378,7 +386,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost */ function notifyHost(method: string, params: unknown): void { try { - sendMessage(createNotification(method, params)); + const activeInvocation = invocationContextStorage.getStore(); + sendMessage({ + ...createNotification(method, params), + ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}), + }); } catch { // Swallow — the host may have closed stdin } @@ -1086,6 +1098,85 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + access: { + members: { + async list(input) { + return callHost("access.members.list", { + companyId: input.companyId, + includeArchived: input.includeArchived, + }); + }, + + async get(memberId: string, companyId: string) { + return callHost("access.members.get", { memberId, companyId }); + }, + + async update(memberId: string, patch, companyId: string) { + return callHost("access.members.update", { memberId, patch, companyId }); + }, + }, + + invites: { + async list(input) { + return callHost("access.invites.list", { + companyId: input.companyId, + state: input.state, + limit: input.limit, + offset: input.offset, + }); + }, + + async create(input) { + return callHost("access.invites.create", { + companyId: input.companyId, + allowedJoinTypes: input.allowedJoinTypes, + humanRole: input.humanRole, + defaultsPayload: input.defaultsPayload, + agentMessage: input.agentMessage, + }); + }, + + async revoke(inviteId: string, companyId: string) { + return callHost("access.invites.revoke", { inviteId, companyId }); + }, + }, + }, + + authorization: { + grants: { + async list(input) { + return callHost("authorization.grants.list", input); + }, + async set(input) { + return callHost("authorization.grants.set", input); + }, + }, + + policies: { + async summary(companyId: string) { + return callHost("authorization.policies.summary", { companyId }); + }, + async get(input) { + return callHost("authorization.policies.get", input); + }, + async update(input) { + return callHost("authorization.policies.update", input); + }, + async previewAssignment(input) { + return callHost("authorization.policies.previewAssignment", input); + }, + async explainAssignment(input) { + return callHost("authorization.policies.explainAssignment", input); + }, + }, + + audit: { + async search(input) { + return callHost("authorization.audit.search", input); + }, + }, + }, + data: { register(key: string, handler: (params: Record) => Promise): void { dataHandlers.set(key, handler); @@ -1175,7 +1266,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost const { id, method, params } = request; try { - const result = await dispatchMethod(method, params); + const invoke = () => dispatchMethod(method, params); + const result = request.paperclipInvocation + ? await invocationContextStorage.run(request.paperclipInvocation, invoke) + : await invoke(); sendMessage(createSuccessResponse(id, result ?? null)); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -1413,11 +1507,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (!handler) { throw new Error(`No data handler registered for key "${params.key}"`); } - return handler( - params.renderEnvironment === undefined - ? params.params - : { ...params.params, renderEnvironment: params.renderEnvironment }, - ); + return handler({ + ...params.params, + ...(params.companyId === undefined ? {} : { companyId: params.companyId }), + ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), + }); } async function handlePerformAction(params: PerformActionParams): Promise { @@ -1425,11 +1519,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (!handler) { throw new Error(`No action handler registered for key "${params.key}"`); } - return handler( - params.renderEnvironment === undefined - ? params.params - : { ...params.params, renderEnvironment: params.renderEnvironment }, - ); + return handler({ + ...params.params, + ...(params.companyId === undefined ? {} : { companyId: params.companyId }), + ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), + }); } async function handleExecuteTool(params: ExecuteToolParams): Promise { @@ -1597,14 +1691,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }); } else if (isJsonRpcNotification(message)) { // Dispatch host→worker push notifications - const notif = message as { method: string; params?: unknown }; + const notif = message as JsonRpcNotification & { method: string; params?: unknown }; + const runNotification = (fn: () => void | Promise) => { + if (notif.paperclipInvocation) { + return invocationContextStorage.run(notif.paperclipInvocation, fn); + } + return fn(); + }; if (notif.method === "agents.sessions.event" && notif.params) { const event = notif.params as AgentSessionEvent; const cb = sessionEventCallbacks.get(event.sessionId); if (cb) cb(event); } else if (notif.method === "onEvent" && notif.params) { // Plugin event bus notifications — dispatch to registered event handlers - handleOnEvent(notif.params as OnEventParams).catch((err) => { + Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => { notifyHost("log", { level: "error", message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts new file mode 100644 index 00000000000..8cded9c8a15 --- /dev/null +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { HostServices } from "../src/host-client-factory.js"; +import { + CapabilityDeniedError, + createHostClientHandlers, + InvocationScopeDeniedError, +} from "../src/host-client-factory.js"; + +describe("createHostClientHandlers invocation company scope", () => { + it("rejects company-scoped host calls outside the current invocation company", async () => { + const projectsList = vi.fn(async () => []); + const services = { + projects: { + list: projectsList, + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["projects.read"], + services, + }); + + await expect( + handlers["projects.list"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(projectsList).not.toHaveBeenCalled(); + }); + + it("filters companies.list to the current invocation company", async () => { + const services = { + companies: { + list: vi.fn(async () => [ + { id: "company-a", name: "Company A" }, + { id: "company-b", name: "Company B" }, + ]), + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["companies.read"], + services, + }); + + await expect( + handlers["companies.list"]( + {}, + { invocationScope: { companyId: "company-a" } }, + ), + ).resolves.toEqual([{ id: "company-a", name: "Company A" }]); + }); + + it("rejects company-scope store access for a different company", async () => { + const stateGet = vi.fn(async () => null); + const services = { + state: { + get: stateGet, + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["plugin.state.read"], + services, + }); + + await expect( + handlers["state.get"]( + { scopeKind: "company", scopeId: "company-b", stateKey: "settings" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(stateGet).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "access.members.list", + "access.members.read", + { companyId: "company-a" }, + (services: HostServices) => vi.mocked(services.access.listMembers), + ], + [ + "access.members.update", + "access.members.write", + { companyId: "company-a", memberId: "member-a", patch: { status: "active" } }, + (services: HostServices) => vi.mocked(services.access.updateMember), + ], + [ + "authorization.grants.set", + "authorization.grants.write", + { companyId: "company-a", principalType: "agent", principalId: "agent-a", grants: [] }, + (services: HostServices) => vi.mocked(services.authorization.setGrants), + ], + [ + "authorization.policies.update", + "authorization.policies.write", + { companyId: "company-a", resourceType: "agent", resourceId: "agent-a", policy: null }, + (services: HostServices) => vi.mocked(services.authorization.updatePolicy), + ], + [ + "authorization.audit.search", + "authorization.audit.read", + { companyId: "company-a" }, + (services: HostServices) => vi.mocked(services.authorization.searchAudit), + ], + ] as const)( + "rejects %s when the plugin lacks %s", + async (method, capability, params, getDelegate) => { + const services = { + access: { + listMembers: vi.fn(async () => []), + updateMember: vi.fn(async () => ({ id: "member-a" })), + }, + authorization: { + setGrants: vi.fn(async () => []), + updatePolicy: vi.fn(async () => ({ policy: null })), + searchAudit: vi.fn(async () => []), + }, + } as unknown as HostServices; + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: [], + services, + }); + + await expect( + (handlers as Record Promise>)[method](params), + ).rejects.toMatchObject({ + name: "CapabilityDeniedError", + message: expect.stringContaining(capability), + }); + await expect( + (handlers as Record Promise>)[method](params), + ).rejects.toBeInstanceOf(CapabilityDeniedError); + expect(getDelegate(services)).not.toHaveBeenCalled(); + }, + ); + + it("checks invocation company scope before exposing authorization data", async () => { + const searchAudit = vi.fn(async () => []); + const services = { + authorization: { + searchAudit, + }, + } as unknown as HostServices; + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["authorization.audit.read"], + services, + }); + + await expect( + handlers["authorization.audit.search"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(searchAudit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index f8e0a38e5dc..b7f15781b5f 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -1,11 +1,26 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { createInterface } from "node:readline"; +import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { isWorkerEntrypoint } from "../src/worker-rpc-host.js"; +import { definePlugin } from "../src/define-plugin.js"; +import { + createRequest, + createErrorResponse, + createSuccessResponse, + isJsonRpcRequest, + isJsonRpcResponse, + parseMessage, + PLUGIN_RPC_ERROR_CODES, + serializeMessage, + type JsonRpcResponse, + type PluginInvocationContext, +} from "../src/protocol.js"; +import { isWorkerEntrypoint, startWorkerRpcHost } from "../src/worker-rpc-host.js"; describe("isWorkerEntrypoint", () => { const tempRoots: string[] = []; @@ -55,3 +70,145 @@ describe("isWorkerEntrypoint", () => { ).toBe(false); }); }); + +describe("worker invocation scope propagation", () => { + it("keeps overlapping company scopes local to each getData invocation", async () => { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + const nestedInvocationIds: string[] = []; + const invocationCompanies = new Map([ + ["invocation-a", "company-a"], + ["invocation-b", "company-b"], + ]); + let releaseCompanyA: (() => void) | null = null; + let nextRequestId = 1; + + const plugin = definePlugin({ + async setup(ctx) { + ctx.data.register("probe", async (params) => { + if (params.label === "a") { + await new Promise((resolve) => { + releaseCompanyA = resolve; + }); + } + const company = await ctx.companies.get(String(params.requestedCompanyId)); + return { label: params.label, company }; + }); + }, + }); + + const worker = startWorkerRpcHost({ + plugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown, invocation?: PluginInvocationContext) { + const id = `host-${nextRequestId++}`; + const request = { + ...createRequest(method, params, id), + ...(invocation ? { paperclipInvocation: invocation } : {}), + }; + const result = new Promise((resolve, reject) => { + pending.set(id, (response) => { + if ("error" in response && response.error) { + reject(new Error(response.error.message)); + return; + } + resolve((response as { result?: unknown }).result); + }); + }); + hostToWorker.write(serializeMessage(request)); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (isJsonRpcResponse(message)) { + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + return; + } + + if (!isJsonRpcRequest(message)) return; + if (message.method !== "companies.get") return; + + const invocationId = (message as { paperclipInvocationId?: string }).paperclipInvocationId ?? ""; + const requestedCompanyId = (message.params as { companyId?: string }).companyId; + const allowedCompanyId = invocationCompanies.get(invocationId); + nestedInvocationIds.push(invocationId); + if (requestedCompanyId !== allowedCompanyId) { + hostToWorker.write(serializeMessage(createErrorResponse( + message.id, + PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + `requested company "${requestedCompanyId}" but invocation "${invocationId}" is scoped to "${allowedCompanyId}"`, + ))); + return; + } + + hostToWorker.write(serializeMessage(createSuccessResponse(message.id, { + id: requestedCompanyId, + }))); + + if (invocationId === "invocation-b") { + releaseCompanyA?.(); + } + }); + + try { + await callWorker("initialize", { + manifest: { + id: "paperclip.scope-test", + apiVersion: 1, + version: "1.0.0", + displayName: "Scope test", + description: "Scope test", + author: "Paperclip", + categories: ["automation"], + capabilities: ["companies.read"], + entrypoints: { worker: "dist/worker.js" }, + }, + config: {}, + instanceInfo: { instanceId: "test", hostVersion: "0.0.0" }, + apiVersion: 1, + }); + + const companyARequest = callWorker( + "getData", + { + key: "probe", + companyId: "company-a", + params: { label: "a", requestedCompanyId: "company-b" }, + }, + { id: "invocation-a", scope: { companyId: "company-a" } }, + ); + const companyAExpectation = expect(companyARequest).rejects.toThrow( + /requested company "company-b"/, + ); + const companyBRequest = callWorker( + "getData", + { + key: "probe", + companyId: "company-b", + params: { label: "b", requestedCompanyId: "company-b" }, + }, + { id: "invocation-b", scope: { companyId: "company-b" } }, + ); + + await expect(companyBRequest).resolves.toEqual({ + label: "b", + company: { id: "company-b" }, + }); + await companyAExpectation; + + expect(nestedInvocationIds).toEqual(["invocation-b", "invocation-a"]); + } finally { + worker.stop(); + hostReadline.close(); + hostToWorker.destroy(); + workerToHost.destroy(); + } + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9c295eae956..b0c92acb006 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -739,6 +739,11 @@ export const PLUGIN_CAPABILITIES = [ "activity.read", "costs.read", "issues.orchestration.read", + "access.members.read", + "access.invites.read", + "authorization.grants.read", + "authorization.policies.read", + "authorization.audit.read", "database.namespace.read", // Data Write "issues.create", @@ -756,6 +761,10 @@ export const PLUGIN_CAPABILITIES = [ "agents.resume", "agents.invoke", "agents.managed", + "access.members.write", + "access.invites.write", + "authorization.grants.write", + "authorization.policies.write", "agent.sessions.create", "agent.sessions.list", "agent.sessions.send", @@ -857,6 +866,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "commentAnnotation", "commentContextMenuItem", "settingsPage", + "companySettingsPage", ] as const; export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number]; @@ -887,6 +897,21 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [ export type PluginReservedCompanyRouteSegment = (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number]; +/** + * Reserved route segments under `/:companyPrefix/company/settings/...` that + * plugin company settings pages may not claim. + */ +export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [ + "general", + "environments", + "access", + "members", + "invites", + "secrets", +] as const; +export type PluginReservedCompanySettingsRouteSegment = + (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number]; + /** * Launcher placement zones describe where a plugin-owned launcher can appear * in the host UI. These are intentionally aligned with current slot surfaces diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a851acf0aca..9afa40f46a3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -111,6 +111,7 @@ export { PLUGIN_CAPABILITIES, PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_BOUNDS, @@ -226,6 +227,7 @@ export { type PluginCapability, type PluginUiSlotType, type PluginUiSlotEntityType, + type PluginReservedCompanySettingsRouteSegment, type PluginLauncherPlacementZone, type PluginLauncherAction, type PluginLauncherBounds, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 18aea077210..14c227dd6ce 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -58,7 +58,7 @@ export interface AgentInstructionsBundle { export interface AgentAccessState { canAssignTasks: boolean; - taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none"; + taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none"; membership: CompanyMembership | null; grants: PrincipalPermissionGrant[]; } diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 6f9629122cc..f9330a48005 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration { */ entityTypes?: PluginUiSlotEntityType[]; /** - * Optional company-scoped route segment for page and routeSidebar slots. + * Optional company-scoped route segment for page, routeSidebar, and + * companySettingsPage slots. * Example: `kitchensink` becomes `/:companyPrefix/kitchensink`. + * For companySettingsPage, `permissions` becomes + * `/:companyPrefix/company/settings/permissions`. */ routePath?: string; /** diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 1984aa6b209..210c8845096 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -8,6 +8,37 @@ describe("plugin capability constants", () => { }); }); +describe("plugin manifest validators", () => { + it("accepts existing-style plugins that do not request access or authorization capabilities", () => { + const parsed = pluginManifestV1Schema.parse({ + id: "paperclip.compat-dashboard", + apiVersion: 1, + version: "0.1.0", + displayName: "Compat Dashboard", + description: "Dashboard-only plugin without access or authorization host APIs.", + author: "Paperclip", + categories: ["ui"], + capabilities: ["ui.dashboardWidget.register"], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui.js", + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "compat-dashboard", + displayName: "Compat Dashboard", + exportName: "CompatDashboard", + }, + ], + }, + }); + + expect(parsed.capabilities).toEqual(["ui.dashboardWidget.register"]); + }); +}); + describe("plugin managed routine validators", () => { it("accepts core issue surface visibility values in routine templates", () => { const parsed = pluginManagedRoutineDeclarationSchema.parse({ @@ -128,4 +159,30 @@ describe("plugin UI slot validators", () => { expect(parsed.entityTypes).toEqual(["execution_workspace"]); }); + + it("accepts company settings page slots with a non-core settings route", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "companySettingsPage", + id: "permissions-settings", + displayName: "Permissions", + exportName: "PermissionsSettingsPage", + routePath: "permissions", + }); + + expect(parsed.routePath).toBe("permissions"); + }); + + it("prevents company settings page slots from shadowing core settings routes", () => { + const parsed = pluginUiSlotDeclarationSchema.safeParse({ + type: "companySettingsPage", + id: "access-settings", + displayName: "Access", + exportName: "AccessSettingsPage", + routePath: "access", + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); + }); }); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index cbc900cf36e..0a857b44d82 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -6,6 +6,7 @@ import { PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_BOUNDS, @@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["entityTypes"], }); } - if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") { + if (value.routePath && value.type !== "page" && value.type !== "routeSidebar" && value.type !== "companySettingsPage") { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "routePath is only supported for page and routeSidebar slots", + message: "routePath is only supported for page, routeSidebar, and companySettingsPage slots", path: ["routePath"], }); } @@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["routePath"], }); } + if (value.type === "companySettingsPage" && !value.routePath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "companySettingsPage slots require routePath", + path: ["routePath"], + }); + } if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["routePath"], }); } + if ( + value.type === "companySettingsPage" + && value.routePath + && PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number]) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `company settings routePath "${value.routePath}" is reserved by the host`, + path: ["routePath"], + }); + } }); export type PluginUiSlotDeclarationInput = z.infer; diff --git a/server/src/__tests__/access-routes-permissions-upgrade.test.ts b/server/src/__tests__/access-routes-permissions-upgrade.test.ts new file mode 100644 index 00000000000..9db328d4519 --- /dev/null +++ b/server/src/__tests__/access-routes-permissions-upgrade.test.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + companies, + companyMemberships, + createDb, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +vi.hoisted(() => { + process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home"; + process.env.PAPERCLIP_INSTANCE_ID = "vitest"; + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +type Db = ReturnType; + +async function createApp(db: Db, companyId: string, userId: string) { + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + const { accessRoutes } = await import("../routes/access.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId, + source: "local_implicit", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", accessRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + })); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" }); + }); + return app; +} + +async function createCompanyWithOwner(db: Db) { + const company = await db + .insert(companies) + .values({ + name: `Access Routes ${randomUUID()}`, + issuePrefix: `AR${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + const owner = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `owner-${randomUUID()}`, + status: "active", + membershipRole: "owner", + }) + .returning() + .then((rows) => rows[0]!); + return { company, owner }; +} + +describeEmbeddedPostgres("access routes permissions upgrade compatibility", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-routes-permissions-upgrade-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects owner self-lockout through the member route after the permissions upgrade", async () => { + const { company, owner } = await createCompanyWithOwner(db); + + const res = await request(await createApp(db, company.id, owner.principalId)) + .patch(`/api/companies/${company.id}/members/${owner.id}`) + .send({ membershipRole: "admin" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("You cannot remove yourself"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.membershipRole).toBe("owner"); + }); + + it("keeps custom grants when the role-only member route changes a member role", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const member = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + const customScope = { projectIds: ["project-1"] }; + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: member.principalId, + permissionKey: "tasks:assign_scope", + scope: customScope, + grantedByUserId: owner.principalId, + }); + + const res = await request(await createApp(db, company.id, owner.principalId)) + .patch(`/api/companies/${company.id}/members/${member.id}`) + .send({ membershipRole: "operator" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.membershipRole).toBe("operator"); + + const grants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, member.principalId), + ), + ); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + permissionKey: "tasks:assign_scope", + scope: customScope, + grantedByUserId: owner.principalId, + }); + }); +}); diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts index f9ac3e64c9b..a0daac807aa 100644 --- a/server/src/__tests__/access-service.test.ts +++ b/server/src/__tests__/access-service.test.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + agents, companies, companyMemberships, createDb, @@ -14,6 +15,8 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { accessService } from "../services/access.js"; +import { grantsForHumanRole } from "../services/company-member-roles.js"; +import { backfillPrincipalAccessCompatibility } from "../services/principal-access-compatibility.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -56,6 +59,7 @@ describeEmbeddedPostgres("access service", () => { await db.delete(issues); await db.delete(principalPermissionGrants); await db.delete(instanceUserRoles); + await db.delete(agents); await db.delete(companyMemberships); await db.delete(companies); }); @@ -221,4 +225,285 @@ describeEmbeddedPostgres("access service", () => { access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }), ).rejects.toThrow("Instance admins cannot be removed from company access"); }); + + it("allows owner and admin role-default grants to manage environments", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + const roles = ["admin", "operator", "viewer"] as const; + const members = await db + .insert(companyMemberships) + .values( + roles.map((role) => ({ + companyId: company.id, + principalType: "user" as const, + principalId: `${role}-${randomUUID()}`, + status: "active" as const, + membershipRole: role, + })), + ) + .returning(); + + await access.setPrincipalGrants( + company.id, + "user", + owner.principalId, + grantsForHumanRole("owner"), + owner.principalId, + ); + for (const member of members) { + await access.setPrincipalGrants( + company.id, + "user", + member.principalId, + grantsForHumanRole(member.membershipRole as "admin" | "operator" | "viewer"), + owner.principalId, + ); + } + + const admin = members.find((member) => member.membershipRole === "admin")!; + const operator = members.find((member) => member.membershipRole === "operator")!; + const viewer = members.find((member) => member.membershipRole === "viewer")!; + + await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true); + await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true); + await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false); + await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false); + }); + + it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const scopedEnvironmentGrant = { environmentId: "env-1" }; + const humanRows = await db + .insert(companyMemberships) + .values([ + { + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }, + { + companyId: company.id, + principalType: "user", + principalId: `operator-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }, + { + companyId: company.id, + principalType: "user", + principalId: `viewer-${randomUUID()}`, + status: "active", + membershipRole: "viewer", + }, + { + companyId: company.id, + principalType: "user", + principalId: `legacy-${randomUUID()}`, + status: "active", + membershipRole: null, + }, + ]) + .returning(); + const admin = humanRows[0]!; + const operator = humanRows[1]!; + const viewer = humanRows[2]!; + const legacyMember = humanRows[3]!; + + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: owner.principalId, + permissionKey: "environments:manage", + scope: scopedEnvironmentGrant, + grantedByUserId: "custom-author", + }); + + const first = await backfillPrincipalAccessCompatibility(db); + const second = await backfillPrincipalAccessCompatibility(db); + + expect(first.humanGrantsInserted).toBeGreaterThan(0); + expect(second.humanGrantsInserted).toBe(0); + await expect(accessService(db).canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, operator.principalId, "tasks:assign")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, legacyMember.principalId, "tasks:assign")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, viewer.principalId, "tasks:assign")).resolves.toBe(false); + + const ownerEnvironmentGrants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalId, owner.principalId), + eq(principalPermissionGrants.permissionKey, "environments:manage"), + ), + ); + expect(ownerEnvironmentGrants).toHaveLength(1); + expect(ownerEnvironmentGrants[0]?.scope).toEqual(scopedEnvironmentGrant); + expect(ownerEnvironmentGrants[0]?.grantedByUserId).toBe("custom-author"); + }); + + it("backfills non-terminal agents as active company members without reviving pending or terminated agents", async () => { + const { company } = await createCompanyWithOwner(db); + const agentRows = await db + .insert(agents) + .values([ + { + companyId: company.id, + name: `Idle ${randomUUID()}`, + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Running ${randomUUID()}`, + role: "engineer", + status: "running", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Pending ${randomUUID()}`, + role: "engineer", + status: "pending_approval", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Terminated ${randomUUID()}`, + role: "engineer", + status: "terminated", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + ]) + .returning(); + const idleAgent = agentRows[0]!; + const runningAgent = agentRows[1]!; + const pendingAgent = agentRows[2]!; + const terminatedAgent = agentRows[3]!; + + const first = await backfillPrincipalAccessCompatibility(db); + const second = await backfillPrincipalAccessCompatibility(db); + + expect(first.agentMembershipsInserted).toBe(2); + expect(second.agentMembershipsInserted).toBe(0); + const memberships = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.principalType, "agent")); + expect(memberships.map((membership) => membership.principalId).sort()).toEqual([ + idleAgent.id, + runningAgent.id, + ].sort()); + expect(memberships.every((membership) => membership.status === "active")).toBe(true); + expect(memberships.every((membership) => membership.membershipRole === "member")).toBe(true); + expect(memberships.some((membership) => membership.principalId === pendingAgent.id)).toBe(false); + expect(memberships.some((membership) => membership.principalId === terminatedAgent.id)).toBe(false); + }); + + it("copies active user memberships with role-default grants for safe company imports", async () => { + const source = await createCompanyWithOwner(db); + const target = await createCompanyWithOwner(db); + const admin = await db + .insert(companyMemberships) + .values({ + companyId: source.company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + + const access = accessService(db); + await access.copyActiveUserMemberships(source.company.id, target.company.id); + + const copiedOwnerGrants = await access.listPrincipalGrants( + target.company.id, + "user", + source.owner.principalId, + ); + const copiedAdminGrants = await access.listPrincipalGrants( + target.company.id, + "user", + admin.principalId, + ); + expect(copiedOwnerGrants.map((grant) => grant.permissionKey)).toEqual( + grantsForHumanRole("owner").map((grant) => grant.permissionKey).sort(), + ); + expect(copiedAdminGrants.map((grant) => grant.permissionKey)).toEqual( + grantsForHumanRole("admin").map((grant) => grant.permissionKey).sort(), + ); + }); + + it("preserves explicit scoped environment grants when backfilling owner and admin defaults", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const scopedGrant = { environmentId: "env-1" }; + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: owner.principalId, + permissionKey: "environments:manage", + scope: scopedGrant, + grantedByUserId: "custom-grant-author", + }); + + await db.execute(sql.raw(` + INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" + ) + SELECT + "company_id", + 'user', + "principal_id", + 'environments:manage', + NULL, + NULL, + now(), + now() + FROM "company_memberships" + WHERE "principal_type" = 'user' + AND "status" = 'active' + AND "membership_role" IN ('owner', 'admin') + ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" + ) DO NOTHING + `)); + + const grants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalId, owner.principalId), + eq(principalPermissionGrants.permissionKey, "environments:manage"), + ), + ); + expect(grants).toHaveLength(1); + expect(grants[0]?.scope).toEqual(scopedGrant); + expect(grants[0]?.grantedByUserId).toBe("custom-grant-author"); + }); }); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index b33fd21d017..198efd9cc2b 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), ensureMembership: vi.fn(), setPrincipalPermission: vi.fn(), @@ -192,6 +193,11 @@ describe("agent routes adapter validation", () => { mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]); mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockAccessService.hasPermission.mockResolvedValue(true); mockAccessService.ensureMembership.mockResolvedValue(undefined); mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index ac5c9313aee..1df94d90352 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -60,6 +60,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(), ensureMembership: vi.fn(), @@ -293,6 +294,17 @@ function resetMockDefaults() { revokedAt: new Date("2026-04-11T00:05:00.000Z"), })); mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser); + mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => { + const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit" + ? true + : currentAccessCanUser; + return { + allowed, + action: input.action, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`, + }; + }); mockAccessService.hasPermission.mockImplementation(async () => false); mockAccessService.getMembership.mockImplementation(async () => null); mockAccessService.listPrincipalGrants.mockImplementation(async () => []); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index c29fca24374..f35ef610605 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -21,6 +21,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), })); @@ -175,6 +176,11 @@ describe("agent instructions bundle routes", () => { vi.clearAllMocks(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type })); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockAgentService.getById.mockResolvedValue(makeAgent()); mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeAgent(), diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 0eeebe524f1..d0549059a30 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -51,7 +51,16 @@ function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => ({}), - accessService: () => ({}), + accessService: () => ({ + canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), + hasPermission: vi.fn(async () => true), + }), approvalService: () => ({}), companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), budgetService: () => ({}), diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 218d653f8bc..28a49a69e21 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -51,6 +51,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(), ensureMembership: vi.fn(), @@ -302,6 +303,7 @@ describe.sequential("agent permission routes", () => { mockAgentService.getChainOfCommand.mockReset(); mockAgentService.resolveByReference.mockReset(); mockAccessService.canUser.mockReset(); + mockAccessService.decide.mockReset(); mockAccessService.hasPermission.mockReset(); mockAccessService.getMembership.mockReset(); mockAccessService.ensureMembership.mockReset(); @@ -342,6 +344,14 @@ describe.sequential("agent permission routes", () => { mockAgentService.update.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => { + const allowed = Boolean(await mockAccessService.canUser()); + return { + allowed, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant" : `Missing test grant for ${input.action ?? "action"}`, + }; + }); mockAccessService.hasPermission.mockResolvedValue(false); mockAccessService.getMembership.mockResolvedValue({ id: "membership-1", @@ -1342,6 +1352,24 @@ describe.sequential("agent permission routes", () => { expect(res.body.access.taskAssignSource).toBe("explicit_grant"); }, 15_000); + it("reports simple-mode task assignment as enabled for active company agent members", async () => { + mockAccessService.listPrincipalGrants.mockResolvedValue([]); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`)); + + expect(res.status).toBe(200); + expect(res.body.access.canAssignTasks).toBe(true); + expect(res.body.access.taskAssignSource).toBe("simple_default"); + }, 15_000); + it("keeps task assignment enabled when agent creation privilege is enabled", async () => { mockAgentService.updatePermissions.mockResolvedValue({ ...baseAgent, diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 84537a9b107..1001f69409f 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -11,6 +11,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(), listPrincipalGrants: vi.fn(), @@ -315,6 +316,11 @@ describe.sequential("agent skill routes", () => { ); mockLogActivity.mockResolvedValue(undefined); mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockAccessService.hasPermission.mockResolvedValue(true); mockAccessService.getMembership.mockResolvedValue(null); mockAccessService.listPrincipalGrants.mockResolvedValue([]); diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 3063fb70fa2..40730c051b1 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(async () => null), listPrincipalGrants: vi.fn(async () => []), @@ -120,6 +121,11 @@ describe("agent test-environment route", () => { beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockEnvironmentService.getById.mockResolvedValue({ id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", diff --git a/server/src/__tests__/app-hmr-port.test.ts b/server/src/__tests__/app-hmr-port.test.ts index 2f25d3ab8f3..f6e6ac1cf6d 100644 --- a/server/src/__tests__/app-hmr-port.test.ts +++ b/server/src/__tests__/app-hmr-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveViteHmrPort } from "../app.ts"; +import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts"; describe("resolveViteHmrPort", () => { it("uses serverPort + 10000 when the result stays in range", () => { @@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => { expect(resolveViteHmrPort(9_000)).toBe(19_000); }); }); + +describe("resolveViteHmrHost", () => { + it("omits wildcard bind hosts so Vite uses the browser hostname", () => { + expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined(); + expect(resolveViteHmrHost("::")).toBeUndefined(); + }); + + it("keeps concrete bind hosts", () => { + expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1"); + expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev"); + }); +}); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts new file mode 100644 index 00000000000..933c460e23f --- /dev/null +++ b/server/src/__tests__/authorization-service.test.ts @@ -0,0 +1,547 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + companyMemberships, + createDb, + instanceUserRoles, + principalPermissionGrants, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { authorizationService } from "../services/authorization.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createCompany(db: ReturnType, label: string) { + return db + .insert(companies) + .values({ + name: `Authorization ${label} ${randomUUID()}`, + issuePrefix: `AZ${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createAgent( + db: ReturnType, + companyId: string, + input: { role?: string; reportsTo?: string | null; permissions?: Record } = {}, +) { + return db + .insert(agents) + .values({ + companyId, + name: `Agent ${randomUUID()}`, + role: input.role ?? "engineer", + reportsTo: input.reportsTo ?? null, + permissions: input.permissions ?? {}, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createProject(db: ReturnType, companyId: string, label: string) { + return db + .insert(projects) + .values({ + companyId, + name: `Project ${label} ${randomUUID()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function grantAgentPermission( + db: ReturnType, + companyId: string, + agentId: string, + permissionKey: "tasks:assign" | "tasks:assign_scope", + scope: Record | null = null, +) { + await db.insert(companyMemberships).values({ + companyId, + principalType: "agent", + principalId: agentId, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId, + principalType: "agent", + principalId: agentId, + permissionKey, + scope, + grantedByUserId: null, + }); +} + +describeEmbeddedPostgres("authorization service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(instanceUserRoles); + await db.delete(agents); + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("allows active user role grants and explains the grant source", async () => { + const company = await createCompany(db, "UserGrant"); + const userId = `user-${randomUUID()}`; + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "operator", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + permissionKey: "tasks:assign", + grantedByUserId: "owner", + }); + + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "user", + principalId: userId, + action: "tasks:assign", + permissionKey: "tasks:assign", + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + grant: { + principalType: "user", + principalId: userId, + permissionKey: "tasks:assign", + }, + }); + expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign"); + }); + + it("allows agent grants for agent configuration decisions", async () => { + const company = await createCompany(db, "AgentGrant"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "agents:create", + grantedByUserId: null, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + }); + + expect(decision.allowed).toBe(true); + expect(decision.grant?.permissionKey).toBe("agents:create"); + }); + + it("denies cross-company agent decisions before grant evaluation", async () => { + const sourceCompany = await createCompany(db, "Source"); + const targetCompany = await createCompany(db, "Target"); + const actorAgent = await createAgent(db, sourceCompany.id); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_jwt" }, + action: "tasks:assign", + resource: { type: "company", companyId: targetCompany.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_company_boundary", + }); + expect(decision.explanation).toContain("Agent key cannot access another company"); + }); + + it("allows simple-mode task assignment between same-company agents without explicit grants", async () => { + const company = await createCompany(db, "AssignmentDefault"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_simple_company_member", + }); + expect(decision.explanation).toContain("simple mode"); + }); + + it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => { + const company = await createCompany(db, "ProtectedAssignment"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { + role: "engineer", + permissions: { + authorizationPolicy: { + assignmentPolicy: { + mode: "protected", + protectedAgentRequiresApproval: true, + }, + protectedAgent: { + requiresApproval: true, + approvalReason: "Production deployment authority", + }, + managedBy: "permissions-extension", + }, + }, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(decision.explanation).toContain("requires approval"); + }); + + it("requires an explicit grant before assigning to a private target agent", async () => { + const company = await createCompany(db, "PrivateAssignment"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { + role: "engineer", + permissions: { + authorizationPolicy: { + agentVisibility: { + mode: "private", + hiddenFromDefaultDirectory: true, + }, + assignmentPolicy: { + mode: "company_default", + protectedAgentRequiresApproval: false, + }, + protectedAgent: { + requiresApproval: false, + }, + managedBy: "permissions-extension", + }, + }, + }); + + const denied = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + assigneeAgentId: targetAgent.id, + }); + + const allowed = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(denied.explanation).toContain("private"); + expect(allowed).toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + grant: { permissionKey: "tasks:assign_scope" }, + }); + }); + + it("allows simple-mode task assignment for active same-company board operators without explicit grants", async () => { + const company = await createCompany(db, "BoardAssignmentDefault"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "operator", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "board", userId, source: "session" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_simple_company_member", + }); + }); + + it("denies legacy board assignment context for viewers", async () => { + const company = await createCompany(db, "BoardViewerAssignment"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "viewer", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "board", userId, companyIds: [company.id], source: "session" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_missing_grant", + }); + }); + + it("denies simple-mode assignment to a target agent from another company", async () => { + const sourceCompany = await createCompany(db, "AssignmentSource"); + const targetCompany = await createCompany(db, "AssignmentTarget"); + const actorAgent = await createAgent(db, sourceCompany.id, { role: "engineer" }); + const targetAgent = await createAgent(db, targetCompany.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: sourceCompany.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: sourceCompany.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_company_boundary", + }); + }); + + it("preserves legacy CEO agent creator authority", async () => { + const company = await createCompany(db, "Legacy"); + const actorAgent = await createAgent(db, company.id, { role: "ceo" }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" }, + action: "agents:create", + resource: { type: "company", companyId: company.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_legacy_agent_creator", + }); + }); + + it("allows scoped assignment inside a granted project and denies other projects", async () => { + const company = await createCompany(db, "ProjectScope"); + const project = await createProject(db, company.id, "Allowed"); + const otherProject = await createProject(db, company.id, "Denied"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + projectIds: [project.id], + }); + + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { projectId: project.id, assigneeAgentId: targetAgent.id }, + }); + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id }, + }); + + expect(allowed).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign_scope" }, + }); + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + expect(denied.explanation).toContain("does not cover the requested scope"); + }); + + it("treats unknown grant scope metadata as unconstrained", async () => { + const company = await createCompany(db, "UnknownScopeMetadata"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + note: "CEO-approved", + }); + + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign_scope" }, + }); + }); + + it("allows scoped assignment to agents inside a managed subtree only", async () => { + const company = await createCompany(db, "SubtreeScope"); + const actorAgent = await createAgent(db, company.id); + const managerAgent = await createAgent(db, company.id); + const childAgent = await createAgent(db, company.id, { reportsTo: managerAgent.id }); + const grandchildAgent = await createAgent(db, company.id, { reportsTo: childAgent.id }); + const outsideAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + managedSubtreeAgentIds: [managerAgent.id], + }); + + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: grandchildAgent.id }, + }); + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: outsideAgent.id }, + }); + + expect(allowed.allowed).toBe(true); + expect(allowed.grant?.permissionKey).toBe("tasks:assign_scope"); + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + }); + + it("allows scoped assignment to an explicit target-agent allowlist only", async () => { + const company = await createCompany(db, "AllowlistScope"); + const actorAgent = await createAgent(db, company.id); + const allowedTarget = await createAgent(db, company.id); + const deniedTarget = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + assigneeAgentIds: [allowedTarget.id], + }); + + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: allowedTarget.id }, + }); + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: deniedTarget.id }, + }); + + expect(allowed.allowed).toBe(true); + expect(denied.allowed).toBe(false); + }); + + it("preserves unscoped tasks:assign compatibility for assignment decisions", async () => { + const company = await createCompany(db, "BroadAssign"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign"); + + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign", + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign" }, + }); + }); +}); diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts index dd672d67e44..2e2821d7ec9 100644 --- a/server/src/__tests__/better-auth.test.ts +++ b/server/src/__tests__/better-auth.test.ts @@ -5,13 +5,17 @@ import { buildBetterAuthAdvancedOptions, deriveAuthCookiePrefix, deriveAuthTrustedOrigins, + shouldDisableSecureAuthCookies, } from "../auth/better-auth.js"; const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID; +const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL; afterEach(() => { if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID; + if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.PAPERCLIP_PUBLIC_URL; + else process.env.PAPERCLIP_PUBLIC_URL = ORIGINAL_PUBLIC_URL; }); describe("Better Auth cookie scoping", () => { @@ -28,8 +32,8 @@ describe("Better Auth cookie scoping", () => { expect(advanced).toEqual({ cookiePrefix: "paperclip-sat-worktree", }); - expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe( - "paperclip-sat-worktree.session_token", + expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch( + /paperclip-sat-worktree\.session_token$/, ); }); @@ -42,6 +46,41 @@ describe("Better Auth cookie scoping", () => { }); }); + it("disables secure cookies when no canonical public auth URL is configured", () => { + delete process.env.PAPERCLIP_PUBLIC_URL; + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + } as Parameters[0])).toBe(true); + }); + + it("derives secure cookie behavior from the configured public auth URL", () => { + delete process.env.PAPERCLIP_PUBLIC_URL; + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "http://paperclip-dev:46259", + } as Parameters[0])).toBe(true); + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://paperclip.example.test", + } as Parameters[0])).toBe(false); + }); + + it("lets PAPERCLIP_PUBLIC_URL override the auth base URL for cookie security", () => { + process.env.PAPERCLIP_PUBLIC_URL = "http://paperclip-dev:46259"; + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://paperclip.example.test", + } as Parameters[0])).toBe(true); + }); + it("adds hostname port variants for authenticated mode on non-default ports", () => { const trustedOrigins = deriveAuthTrustedOrigins({ deploymentMode: "authenticated", diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index df1d8e7fda0..c048052a106 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -20,6 +20,7 @@ const agentSvc = { const accessSvc = { ensureMembership: vi.fn(), + ensureRoleDefaultGrants: vi.fn(), listActiveUserMemberships: vi.fn(), copyActiveUserMemberships: vi.fn(), setPrincipalPermission: vi.fn(), diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs new file mode 100644 index 00000000000..6e54689fc3e --- /dev/null +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -0,0 +1,100 @@ +const readline = require("node:readline"); + +let nextRequestId = 1; +const pendingNested = new Map(); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function sendNestedHostRequest(originalRequest, invocationId) { + const nestedId = `nested-${nextRequestId++}`; + const params = originalRequest.params?.params ?? {}; + const mode = params.mode; + const requestedCompanyId = params.requestedCompanyId; + const nestedRequest = { + jsonrpc: "2.0", + id: nestedId, + method: "companies.get", + params: { + companyId: requestedCompanyId, + }, + }; + + if (mode === "echo") { + nestedRequest.paperclipInvocationId = invocationId; + } else if (mode === "unknown") { + nestedRequest.paperclipInvocationId = "unknown-invocation"; + } + + pendingNested.set(nestedId, originalRequest.id); + send(nestedRequest); +} + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +rl.on("line", (line) => { + if (!line.trim()) return; + const message = JSON.parse(line); + + if (message.id && pendingNested.has(message.id)) { + const originalId = pendingNested.get(message.id); + pendingNested.delete(message.id); + if (message.error) { + send({ + jsonrpc: "2.0", + id: originalId, + error: message.error, + }); + return; + } + + send({ + jsonrpc: "2.0", + id: originalId, + result: message.result, + }); + return; + } + + const method = message && typeof message.method === "string" ? message.method : null; + + if (method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + ok: true, + supportedMethods: ["getData"], + }, + }); + return; + } + + if (method === "getData") { + sendNestedHostRequest(message, message.paperclipInvocation?.id); + return; + } + + if (method === "shutdown") { + send({ + jsonrpc: "2.0", + id: message.id, + result: {}, + }); + setImmediate(() => process.exit(0)); + return; + } + + send({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Unhandled method: ${method}`, + }, + }); +}); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index fae007e059d..6834f763e2c 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -68,6 +68,7 @@ describe("human invite roles", () => { it("maps owner to the full management grant set", () => { expect(grantsForHumanRole("owner")).toEqual([ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, @@ -75,6 +76,16 @@ describe("human invite roles", () => { ]); }); + it("maps admin to management grants including environment management", () => { + expect(grantsForHumanRole("admin")).toEqual([ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]); + }); + it("defaults legacy or missing roles to operator", () => { expect(normalizeHumanRole("member")).toBe("operator"); expect(resolveHumanInviteRole(null)).toBe("operator"); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 6413ba21625..7503bdf6379 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -30,6 +30,7 @@ const mockIssueService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), })); @@ -275,6 +276,13 @@ describe("agent issue mutation checkout ownership", () => { registerRouteMocks(); vi.clearAllMocks(); mockAccessService.canUser.mockReset(); + mockAccessService.decide.mockReset(); + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ + allowed: input.action === "tasks:assign", + action: input.action, + reason: input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant", + explanation: input.action === "tasks:assign" ? "Allowed by test assignment default." : "Missing permission.", + })); mockAccessService.hasPermission.mockReset(); mockAgentService.getById.mockReset(); mockAgentService.list.mockReset(); @@ -682,12 +690,12 @@ describe("agent issue mutation checkout ownership", () => { }); it("allows agents with the active-checkout management grant to mutate active checkouts", async () => { - mockAccessService.hasPermission.mockImplementation(async ( - _companyId: string, - _principalType: string, - principalId: string, - permissionKey: string, - ) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts"); + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ + allowed: input.action === "tasks:manage_active_checkouts", + action: input.action, + reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant", + explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.", + })); const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" }); @@ -828,4 +836,37 @@ describe("agent issue mutation checkout ownership", () => { }), ); }); + + it("uses the authorization decision path for assignment changes", async () => { + const decide = vi.fn(async () => ({ + allowed: false, + action: "tasks:assign", + reason: "deny_policy_restricted", + explanation: "Target agent requires approval before task assignment.", + })); + (mockAccessService as any).decide = decide; + mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); + mockAgentService.resolveByReference.mockResolvedValue({ + ambiguous: false, + agent: makeAgent(peerAgentId), + }); + + const app = await createApp(ownerActor()); + const res = await request(app) + .patch(`/api/issues/${issueId}`) + .send({ assigneeAgentId: peerAgentId }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("requires approval"); + expect(decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "tasks:assign", + resource: expect.objectContaining({ + type: "issue", + companyId, + issueId, + assigneeAgentId: peerAgentId, + }), + })); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts index 2dc70626322..bd196a60a03 100644 --- a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts +++ b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts @@ -22,6 +22,12 @@ const mockIssueService = vi.hoisted(() => ({ vi.mock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index ecb3db9bf8f..ad4ee770498 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -16,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), })); @@ -229,6 +230,7 @@ describe.sequential("issue comment reopen routes", () => { mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); mockAccessService.canUser.mockReset(); + mockAccessService.decide.mockReset(); mockAccessService.hasPermission.mockReset(); mockHeartbeatService.wakeup.mockReset(); mockHeartbeatService.reportRunActivity.mockReset(); @@ -307,6 +309,15 @@ describe.sequential("issue comment reopen routes", () => { mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => { + const allowed = input.action !== "tasks:manage_active_checkouts"; + return { + allowed, + action: input.action, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant." : "Missing active checkout override.", + }; + }); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockResolvedValue(null); mockAgentService.list.mockResolvedValue([ diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 1b0db770a19..0b7392d2e2e 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -26,6 +26,7 @@ const mockHeartbeatService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(async () => false), + decide: vi.fn(), hasPermission: vi.fn(async () => false), })); @@ -160,6 +161,17 @@ describe("issue execution policy routes", () => { parentBlockerAdded: false, }); mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => { + const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit" + ? true + : Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission()); + return { + allowed, + action: input.action, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`, + }; + }); mockAccessService.hasPermission.mockResolvedValue(false); }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 818c5d7d9ac..785dbf182cf 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -43,6 +43,12 @@ function registerModuleMocks() { }), accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 167037fbafd..42539b218bc 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -33,6 +33,12 @@ vi.mock("../services/index.js", () => ({ }), accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ @@ -95,6 +101,12 @@ function registerModuleMocks() { }), accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ diff --git a/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts new file mode 100644 index 00000000000..2d53659115a --- /dev/null +++ b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts @@ -0,0 +1,348 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + assets, + companies, + companyMemberships, + createDb, + documents, + heartbeatRuns, + issueAttachments, + issueComments, + issueDocuments, + issues, + issueWorkProducts, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +vi.hoisted(() => { + process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home"; + process.env.PAPERCLIP_INSTANCE_ID = "vitest"; + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; +}); + +vi.mock("../services/issue-assignment-wakeup.js", () => ({ + queueIssueAssignmentWakeup: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +type Db = ReturnType; + +function agentActor(companyId: string, agentId: string): Express.Request["actor"] { + return { + type: "agent", + agentId, + companyId, + runId: null, + source: "agent_jwt", + }; +} + +async function createApp(db: Db, actor: Express.Request["actor"]) { + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + const [{ activityRoutes }, { issueRoutes }] = await Promise.all([ + import("../routes/activity.js"), + import("../routes/issues.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use("/api", activityRoutes(db)); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" }); + }); + return app; +} + +async function seedCompany(db: Db, label: string) { + return db + .insert(companies) + .values({ + name: `Permissions Boundary ${label}`, + issuePrefix: `PB${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function seedAgent( + db: Db, + companyId: string, + input: { role?: string; permissions?: Record; status?: "active" | "idle" } = {}, +) { + return db + .insert(agents) + .values({ + companyId, + name: `Agent ${randomUUID()}`, + role: input.role ?? "engineer", + status: input.status ?? "active", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: input.permissions ?? {}, + }) + .returning() + .then((rows) => rows[0]!); +} + +describeEmbeddedPostgres("permissions upgrade visibility and route boundaries", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-permissions-boundary-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueAttachments); + await db.delete(assets); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issueWorkProducts); + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("keeps V1 private agent visibility from becoming issue, comment, document, attachment, activity, or work product privacy", async () => { + const company = await seedCompany(db, "Visibility"); + const readerAgent = await seedAgent(db, company.id); + const privateTargetAgent = await seedAgent(db, company.id, { + permissions: { + authorizationPolicy: { + agentVisibility: { + mode: "private", + hiddenFromDefaultDirectory: true, + }, + assignmentPolicy: { mode: "protected" }, + protectedAgent: { requiresApproval: false }, + managedBy: "permissions-extension", + }, + }, + }); + const issue = await db + .insert(issues) + .values({ + companyId: company.id, + identifier: `${company.issuePrefix}-1`, + title: "Visible work for a private target agent", + status: "todo", + priority: "medium", + assigneeAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + const comment = await db + .insert(issueComments) + .values({ + companyId: company.id, + issueId: issue.id, + authorAgentId: privateTargetAgent.id, + body: "Private target agent status is still company-visible.", + }) + .returning() + .then((rows) => rows[0]!); + const doc = await db + .insert(documents) + .values({ + companyId: company.id, + title: "Plan", + latestBody: "Shared plan body", + createdByAgentId: privateTargetAgent.id, + updatedByAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(issueDocuments).values({ + companyId: company.id, + issueId: issue.id, + documentId: doc.id, + key: "plan", + }); + const asset = await db + .insert(assets) + .values({ + companyId: company.id, + provider: "local_disk", + objectKey: `attachments/${randomUUID()}.txt`, + contentType: "text/plain", + byteSize: 12, + sha256: "abc123", + originalFilename: "note.txt", + createdByAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(issueAttachments).values({ + companyId: company.id, + issueId: issue.id, + issueCommentId: comment.id, + assetId: asset.id, + }); + await db.insert(issueWorkProducts).values({ + companyId: company.id, + issueId: issue.id, + type: "url", + provider: "test", + title: "Preview", + url: "https://example.test/preview", + status: "ready", + }); + await db.insert(activityLog).values({ + companyId: company.id, + actorType: "agent", + actorId: privateTargetAgent.id, + agentId: privateTargetAgent.id, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { source: "test" }, + }); + + const app = await createApp(db, agentActor(company.id, readerAgent.id)); + + const [issueList, comments, docs, docDetail, attachments, activity, workProducts] = await Promise.all([ + request(app).get(`/api/companies/${company.id}/issues`), + request(app).get(`/api/issues/${issue.id}/comments`), + request(app).get(`/api/issues/${issue.id}/documents`), + request(app).get(`/api/issues/${issue.id}/documents/plan`), + request(app).get(`/api/issues/${issue.id}/attachments`), + request(app).get(`/api/issues/${issue.id}/activity`), + request(app).get(`/api/issues/${issue.id}/work-products`), + ]); + + expect(issueList.status, JSON.stringify(issueList.body)).toBe(200); + expect(issueList.body.items ?? issueList.body).toEqual( + expect.arrayContaining([expect.objectContaining({ id: issue.id })]), + ); + expect(comments.status, JSON.stringify(comments.body)).toBe(200); + expect(comments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: comment.id })])); + expect(docs.status, JSON.stringify(docs.body)).toBe(200); + expect(docs.body).toEqual(expect.arrayContaining([expect.objectContaining({ key: "plan" })])); + expect(docDetail.status, JSON.stringify(docDetail.body)).toBe(200); + expect(docDetail.body.body ?? docDetail.body.latestBody).toContain("Shared plan body"); + expect(attachments.status, JSON.stringify(attachments.body)).toBe(200); + expect(attachments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })])); + expect(activity.status, JSON.stringify(activity.body)).toBe(200); + expect(activity.body).toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.updated" })])); + expect(workProducts.status, JSON.stringify(workProducts.body)).toBe(200); + expect(workProducts.body).toEqual(expect.arrayContaining([expect.objectContaining({ title: "Preview" })])); + }); + + it("denies cross-company issue reads before private-agent grant evaluation can matter", async () => { + const sourceCompany = await seedCompany(db, "Source"); + const targetCompany = await seedCompany(db, "Target"); + const sourceAgent = await seedAgent(db, sourceCompany.id); + const privateTargetAgent = await seedAgent(db, targetCompany.id, { + permissions: { + authorizationPolicy: { + agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true }, + assignmentPolicy: { mode: "company_default" }, + protectedAgent: { requiresApproval: false }, + }, + }, + }); + const issue = await db + .insert(issues) + .values({ + companyId: targetCompany.id, + title: "Other company work", + status: "todo", + priority: "medium", + assigneeAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + + const res = await request(await createApp(db, agentActor(sourceCompany.id, sourceAgent.id))) + .get(`/api/issues/${issue.id}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Agent key cannot access another company"); + }); + + it("allows same-company route assignment after upgrade but keeps private target assignment grant constrained", async () => { + const company = await seedCompany(db, "Assignment"); + const actorAgent = await seedAgent(db, company.id); + const openTargetAgent = await seedAgent(db, company.id); + const privateTargetAgent = await seedAgent(db, company.id, { + permissions: { + authorizationPolicy: { + agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true }, + assignmentPolicy: { mode: "company_default" }, + protectedAgent: { requiresApproval: false }, + managedBy: "permissions-extension", + }, + }, + }); + const app = await createApp(db, agentActor(company.id, actorAgent.id)); + + const openAssignment = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Assignable after upgrade", assigneeAgentId: openTargetAgent.id }); + expect(openAssignment.status, JSON.stringify(openAssignment.body)).toBe(201); + + const deniedPrivateAssignment = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Private target needs scope", assigneeAgentId: privateTargetAgent.id }); + expect(deniedPrivateAssignment.status).toBe(403); + expect(deniedPrivateAssignment.body.error).toContain("private"); + + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentIds: [privateTargetAgent.id] }, + grantedByUserId: null, + }); + + const allowedPrivateAssignment = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Private target has explicit scope", assigneeAgentId: privateTargetAgent.id }); + expect(allowedPrivateAssignment.status, JSON.stringify(allowedPrivateAssignment.body)).toBe(201); + + const otherPrivateTargetAgent = await seedAgent(db, company.id, { + permissions: privateTargetAgent.permissions as Record, + }); + const deniedOutsideScope = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Different private target stays denied", assigneeAgentId: otherPrivateTargetAgent.id }); + expect(deniedOutsideScope.status).toBe(403); + expect(deniedOutsideScope.body.error).toContain("private"); + }); +}); diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts new file mode 100644 index 00000000000..87737816a34 --- /dev/null +++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts @@ -0,0 +1,322 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + companies, + companyMemberships, + createDb, + invites, + principalPermissionGrants, +} from "@paperclipai/db"; +import { buildHostServices } from "../services/plugin-host-services.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const pluginId = "plugin-record-id"; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: vi.fn(), + subscribe: vi.fn(), + clear: vi.fn(), + }; + }, + } as any; +} + +async function createCompany(db: ReturnType, prefix: string) { + return db + .insert(companies) + .values({ + name: `${prefix} ${randomUUID()}`, + issuePrefix: `${prefix}${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +describeEmbeddedPostgres("plugin access and authorization host services", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-access-authz-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(invites); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects grant writes for principals outside the requested company", async () => { + const targetCompany = await createCompany(db, "PAX"); + const otherCompany = await createCompany(db, "PAY"); + const otherAgent = await db + .insert(agents) + .values({ + companyId: otherCompany.id, + name: "Other agent", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }) + .returning() + .then((rows) => rows[0]!); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + + await expect( + services.authorization.setGrants({ + companyId: targetCompany.id, + principalType: "agent", + principalId: otherAgent.id, + grants: [{ permissionKey: "tasks:assign" }], + }), + ).rejects.toThrow("Agent not found"); + + const rows = await db.select().from(principalPermissionGrants); + expect(rows).toEqual([]); + services.dispose(); + }); + + it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => { + const company = await createCompany(db, "PAZ"); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + + const created = await services.access.createInvite({ + companyId: company.id, + allowedJoinTypes: "human", + defaultsPayload: { + human: { role: "operator", apiKey: "secret-value" }, + secret: "top-secret", + }, + }); + + expect(created.token).toMatch(/^pcp_invite_/); + expect("tokenHash" in created).toBe(false); + expect(created.defaultsPayload).toMatchObject({ + human: { role: "operator", apiKey: "***REDACTED***" }, + secret: "***REDACTED***", + }); + + const listed = await services.access.listInvites({ companyId: company.id }); + expect(listed.invites).toHaveLength(1); + expect("token" in listed.invites[0]!).toBe(false); + expect("tokenHash" in listed.invites[0]!).toBe(false); + services.dispose(); + }); + + it("filters authorization audit entries by allow or deny decision details", async () => { + const company = await createCompany(db, "PAU"); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + await db.insert(activityLog).values([ + { + companyId: company.id, + actorType: "agent", + actorId: "agent-1", + action: "authorization.assignment_preview", + entityType: "issue", + entityId: "issue-1", + details: { decision: "allow", secret: "do-not-leak" }, + createdAt: new Date("2026-01-02T00:00:00Z"), + }, + { + companyId: company.id, + actorType: "agent", + actorId: "agent-1", + action: "authorization.assignment_preview", + entityType: "issue", + entityId: "issue-2", + details: { reason: "deny_scope" }, + createdAt: new Date("2026-01-03T00:00:00Z"), + }, + ]); + + const [allowed, denied] = await Promise.all([ + services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "allow", + limit: 1, + }), + services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "deny", + }), + ]); + + expect(allowed).toHaveLength(1); + expect(allowed[0]!.entityId).toBe("issue-1"); + expect(allowed[0]!.details).toMatchObject({ decision: "allow", secret: "***REDACTED***" }); + expect(denied).toHaveLength(1); + expect(denied[0]!.entityId).toBe("issue-2"); + services.dispose(); + }); + + it("uses persisted agent policy for plugin assignment preview and explanation", async () => { + const company = await createCompany(db, "PAP"); + const [actorAgent, targetAgent] = await db + .insert(agents) + .values([ + { + companyId: company.id, + name: "Actor agent", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }, + { + companyId: company.id, + name: "Protected target", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }, + ]) + .returning(); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent!.id, + status: "active", + membershipRole: "member", + }); + + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + const updatedPolicy = await services.authorization.updatePolicy({ + companyId: company.id, + resourceType: "agent", + resourceId: targetAgent!.id, + policy: { + assignmentPolicy: { + mode: "protected", + protectedAgentRequiresApproval: true, + }, + protectedAgent: { + requiresApproval: true, + approvalReason: "Needs board approval", + }, + managedBy: "permissions-extension", + }, + }); + const input = { + companyId: company.id, + actor: { + type: "agent" as const, + agentId: actorAgent!.id, + companyId: company.id, + source: "agent_key" as const, + }, + target: { assigneeAgentId: targetAgent!.id }, + }; + const [policy, preview, explanation] = await Promise.all([ + Promise.resolve(updatedPolicy), + services.authorization.previewAssignment(input), + services.authorization.explainAssignment(input), + ]); + + expect(policy.policy).toMatchObject({ + protectedAgent: { requiresApproval: true }, + }); + expect(preview).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(explanation).toMatchObject(preview); + + const injectedBoardPreview = await services.authorization.previewAssignment({ + companyId: company.id, + actor: { + type: "board", + userId: "operator", + companyIds: [company.id], + source: "local_implicit", + isInstanceAdmin: true, + } as any, + target: { assigneeAgentId: targetAgent!.id }, + }); + expect(injectedBoardPreview).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + services.dispose(); + }); + + it("sanitizes plugin authorization policy updates and records audit activity", async () => { + const company = await createCompany(db, "PAS"); + const targetAgent = await db + .insert(agents) + .values({ + companyId: company.id, + name: "Policy target", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }) + .returning() + .then((rows) => rows[0]!); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + + const updatedPolicy = await services.authorization.updatePolicy({ + companyId: company.id, + resourceType: "agent", + resourceId: targetAgent.id, + policy: { + assignmentPolicy: { mode: "protected" }, + apiKey: "sk-test-secret", + nested: { + authorization: "Bearer should-not-persist", + safeLabel: "kept", + }, + }, + }); + + expect(updatedPolicy.policy).toMatchObject({ + assignmentPolicy: { mode: "protected" }, + apiKey: "***REDACTED***", + nested: { + authorization: "***REDACTED***", + safeLabel: "kept", + }, + }); + + const rows = await db.select().from(activityLog); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + companyId: company.id, + actorType: "plugin", + actorId: pluginId, + action: "authorization.policy_updated_by_plugin", + entityType: "agent", + entityId: targetAgent.id, + }); + expect(rows[0]!.details).toMatchObject({ + hasPolicy: true, + sourcePluginId: pluginId, + sourcePluginKey: "permissions-extension", + }); + expect(JSON.stringify(rows[0]!.details)).not.toContain("sk-test-secret"); + expect(JSON.stringify(rows[0]!.details)).not.toContain("should-not-persist"); + services.dispose(); + }); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 0c19848822f..52fe1c84750 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -613,6 +613,28 @@ describe.sequential("plugin tool and bridge authz", () => { expect(call).not.toHaveBeenCalled(); }); + it("forwards authorized bridge company scope to the plugin worker", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/data/health`) + .send({ companyId: companyA, params: { view: "compact" } }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "getData", { + key: "health", + companyId: companyA, + params: { view: "compact" }, + renderEnvironment: null, + }); + }); + it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => { readyPlugin(); const call = vi.fn().mockResolvedValue({ ok: true }); diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts index 2510416179c..bb66dae702f 100644 --- a/server/src/__tests__/plugin-sdk-testing.test.ts +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -82,4 +82,29 @@ describe("plugin SDK test harness", () => { "missing required capability 'skills.managed'", ); }); + + it("requires access and authorization capabilities for permission SDK calls", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-missing-access-authz-capability", + apiVersion: 1, + version: "0.1.0", + displayName: "Missing Access Capability", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + + await expect(harness.ctx.access.members.list({ companyId: "company-1" })).rejects.toThrow( + "missing required capability 'access.members.read'", + ); + await expect(harness.ctx.authorization.grants.list({ companyId: "company-1" })).rejects.toThrow( + "missing required capability 'authorization.grants.read'", + ); + await expect(harness.ctx.authorization.audit.search({ companyId: "company-1" })).rejects.toThrow( + "missing required capability 'authorization.audit.read'", + ); + }); }); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index 4f578fda072..626ed448cba 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { + createHostClientHandlers, JsonRpcCallError, + PLUGIN_RPC_ERROR_CODES, + type HostServices, type HostToWorkerMethods, } from "@paperclipai/plugin-sdk"; import { @@ -14,6 +17,10 @@ import { const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs"); +const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join( + FIXTURES_DIR, + "plugin-worker-invocation-scope.cjs", +); const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs"); const TEST_MANIFEST: PaperclipPluginManifestV1 = { @@ -178,4 +185,86 @@ describe("plugin-worker-manager stderr failure context", () => { await handle.stop().catch(() => undefined); } }); + + it("passes echoed invocation scope to worker-to-host handlers", async () => { + const companiesGet = vi.fn(async () => ({ id: "company-1" })); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: { + "companies.get": companiesGet, + }, + }); + + try { + await handle.start(); + + await expect(handle.call("getData", { + key: "probe", + companyId: "company-1", + params: { + mode: "echo", + requestedCompanyId: "company-1", + }, + } as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" }); + + expect(companiesGet).toHaveBeenCalledWith( + { companyId: "company-1" }, + { invocationScope: { companyId: "company-1" } }, + ); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("rejects missing or unknown invocation ids while a company invocation is active", async () => { + const companiesGet = vi.fn(async () => ({ id: "company-2" })); + const hostHandlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read"], + services: { + companies: { + get: companiesGet, + }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers, + }); + + try { + await handle.start(); + + for (const mode of ["omit", "unknown"]) { + await expect(handle.call("getData", { + key: "probe", + companyId: "company-1", + params: { + mode, + requestedCompanyId: "company-2", + }, + } as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + }); + } + + expect(companiesGet).not.toHaveBeenCalled(); + } finally { + await handle.stop().catch(() => undefined); + } + }); }); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index c288b6e6322..f1023a91533 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -138,6 +138,10 @@ vi.mock("../realtime/live-events-ws.js", () => ({ })); vi.mock("../services/index.js", () => ({ + backfillPrincipalAccessCompatibility: vi.fn(async () => ({ + agentMembershipsInserted: 0, + humanGrantsInserted: 0, + })), feedbackService: feedbackServiceFactoryMock, heartbeatService: vi.fn(() => ({ reapOrphanedRuns: vi.fn(async () => undefined), diff --git a/server/src/app.ts b/server/src/app.ts index 421a72a4390..a7c9a4ed630 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -96,6 +96,12 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } +export function resolveViteHmrHost(bindHost: string): string | undefined { + const normalized = bindHost.trim().toLowerCase(); + if (normalized === "0.0.0.0" || normalized === "::") return undefined; + return bindHost; +} + export function shouldServeViteDevHtml(req: ExpressRequest): boolean { const pathname = req.path; if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; @@ -373,6 +379,7 @@ export async function createApp( const uiRoot = path.resolve(__dirname, "../../ui"); const publicUiRoot = path.resolve(uiRoot, "public"); const hmrPort = resolveViteHmrPort(opts.serverPort); + const hmrHost = resolveViteHmrHost(opts.bindHost); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ root: uiRoot, @@ -380,7 +387,7 @@ export async function createApp( server: { middlewareMode: true, hmr: { - host: opts.bindHost, + ...(hmrHost ? { host: hmrHost } : {}), port: hmrPort, clientPort: hmrPort, }, diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 3c84b9f555c..6eb2024938c 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -44,6 +44,15 @@ export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: bo }; } +export function shouldDisableSecureAuthCookies(config: Config): boolean { + const configuredPublicUrl = ( + process.env.PAPERCLIP_PUBLIC_URL?.trim() || + (config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl?.trim() : "") + ); + if (!configuredPublicUrl) return true; + return configuredPublicUrl.startsWith("http://"); +} + function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [key, raw] of Object.entries(rawHeaders)) { @@ -99,8 +108,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: "For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.", ); } - const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl; - const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false; + const disableSecureCookies = shouldDisableSecureAuthCookies(config); const authConfig = { baseURL: baseUrl, @@ -120,7 +128,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: requireEmailVerification: false, disableSignUp: config.authDisableSignUp, }, - advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }), + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }), }; if (!baseUrl) { diff --git a/server/src/board-claim.ts b/server/src/board-claim.ts index 4c87b732bfc..c4784406646 100644 --- a/server/src/board-claim.ts +++ b/server/src/board-claim.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import type { DeploymentMode } from "@paperclipai/shared"; +import { ensureHumanRoleDefaultGrants } from "./services/principal-access-compatibility.js"; const LOCAL_BOARD_USER_ID = "local-board"; const CLAIM_TTL_MS = 1000 * 60 * 60 * 24; @@ -89,6 +90,7 @@ export async function claimBoardOwnership( const status = getChallengeStatus(opts.token, opts.code); if (status !== "available") return { status }; + const claimedCompanyIds: string[] = []; await db.transaction(async (tx) => { const existingTargetAdmin = await tx .select({ id: instanceUserRoles.id }) @@ -108,6 +110,7 @@ export async function claimBoardOwnership( const allCompanies = await tx.select({ id: companies.id }).from(companies); for (const company of allCompanies) { + claimedCompanyIds.push(company.id); const existing = await tx .select({ id: companyMemberships.id, status: companyMemberships.status }) .from(companyMemberships) @@ -140,6 +143,15 @@ export async function claimBoardOwnership( } }); + for (const companyId of claimedCompanyIds) { + await ensureHumanRoleDefaultGrants(db, { + companyId, + principalId: opts.userId, + membershipRole: "owner", + grantedByUserId: opts.userId, + }); + } + if (activeChallenge && activeChallenge.token === opts.token) { activeChallenge.claimedAt = new Date(); activeChallenge.claimedByUserId = opts.userId; diff --git a/server/src/index.ts b/server/src/index.ts index c039f7584f1..bb89a46e567 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import { logger } from "./middleware/logger.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, + backfillPrincipalAccessCompatibility, heartbeatService, instanceSettingsService, reconcilePersistedRuntimeServicesOnStartup, @@ -512,6 +513,10 @@ export async function startServer(): Promise { if (config.deploymentMode === "local_trusted") { await ensureLocalTrustedBoardPrincipal(db as any); } + const accessBackfill = await backfillPrincipalAccessCompatibility(db as any); + if (accessBackfill.agentMembershipsInserted > 0 || accessBackfill.humanGrantsInserted > 0) { + logger.info(accessBackfill, "Backfilled principal access compatibility records"); + } if (config.deploymentMode === "authenticated") { const { createBetterAuthHandler, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 03f6ef58ee5..07c2dd6de33 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -501,6 +501,15 @@ export function agentRoutes( }; } + if (membership?.status === "active") { + return { + canAssignTasks: true, + taskAssignSource: "simple_default" as const, + membership, + grants, + }; + } + return { canAssignTasks: false, taskAssignSource: "none" as const, @@ -543,34 +552,32 @@ export function agentRoutes( async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; - const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); - if (!allowed) { - throw forbidden("Missing permission: agents:create"); - } - return null; + const decision = await access.decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) { + throw forbidden(decision.explanation); } - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - const actorAgent = await svc.getById(req.actor.agentId); + if (req.actor.type !== "agent") return null; + const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; if (!actorAgent || actorAgent.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); - if (!allowedByGrant && !canCreateAgents(actorAgent)) { - throw forbidden("Missing permission: can create agents"); - } return actorAgent; } async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { assertBoard(req); assertCompanyAccess(req, companyId); - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); - if (!allowed) { - throw forbidden("Missing permission: agents:create"); - } + const decision = await access.decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); } async function assertCanReadConfigurations(req: Request, companyId: string) { @@ -592,15 +599,12 @@ export function agentRoutes( async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; - return access.canUser(companyId, req.actor.userId, "agents:create"); - } - if (!req.actor.agentId) return false; - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== companyId) return false; - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); - return allowedByGrant || canCreateAgents(actorAgent); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:read", + resource: { type: "company", companyId }, + }); + return decision.allowed; } async function buildSkippedWakeupResponse( @@ -672,27 +676,13 @@ export function agentRoutes( async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") { - await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); - return; - } - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { - throw forbidden("Agent key cannot access another company"); - } - - if (actorAgent.id === targetAgent.id) return; - if (actorAgent.role === "ceo") return; - const allowedByGrant = await access.hasPermission( - targetAgent.companyId, - "agent", - actorAgent.id, - "agents:create", - ); - if (allowedByGrant || canCreateAgents(actorAgent)) return; - throw forbidden("Only CEO or agent creators can modify other agents"); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); } async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 96a004ac039..2de26afbe13 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -271,7 +271,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { throw forbidden("Instance admin required"); } const company = await svc.create(req.body); - await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active"); + const ownerPrincipalId = req.actor.userId ?? "local-board"; + await access.ensureMembership(company.id, "user", ownerPrincipalId, "owner", "active"); + await access.ensureRoleDefaultGrants( + company.id, + ownerPrincipalId, + "owner", + req.actor.userId ?? null, + ); await logActivity(db, { companyId: company.id, actorType: "user", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9b2b9da7963..3e2549b373e 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1246,29 +1246,48 @@ export function issueRoutes( return (req.actor.companyIds ?? []).includes(companyId); } - function canCreateAgentsLegacy(agent: { permissions: Record | null | undefined; role: string }) { - if (agent.role === "ceo") return true; - if (!agent.permissions || typeof agent.permissions !== "object") return false; - return Boolean((agent.permissions as Record).canCreateAgents); + type TaskAssignmentAuthorizationScope = { + issueId?: string | null; + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + }; + + async function resolveAssignmentProjectId(input: { + companyId: string; + projectId: string | null | undefined; + parentIssueId?: string | null; + }) { + if (input.projectId !== undefined) return input.projectId; + if (!input.parentIssueId) return null; + const parent = await svc.getById(input.parentIssueId); + if (!parent || parent.companyId !== input.companyId) return null; + return parent.projectId ?? null; } - async function assertCanAssignTasks(req: Request, companyId: string) { + async function assertCanAssignTasks( + req: Request, + companyId: string, + assignmentScope?: TaskAssignmentAuthorizationScope, + ) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); - if (!allowed) throw forbidden("Missing permission: tasks:assign"); - return; - } - if (req.actor.type === "agent") { - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign"); - if (allowedByGrant) return; - const actorAgent = await agentsSvc.getById(req.actor.agentId); - if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return; - throw forbidden("Missing permission: tasks:assign"); - } - throw unauthorized(); + const decision = await access.decide({ + actor: req.actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId, + issueId: assignmentScope?.issueId ?? null, + projectId: assignmentScope?.projectId ?? null, + parentIssueId: assignmentScope?.parentIssueId ?? null, + assigneeAgentId: assignmentScope?.assigneeAgentId ?? null, + assigneeUserId: assignmentScope?.assigneeUserId ?? null, + }, + scope: assignmentScope ?? null, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); } function requireAgentRunId(req: Request, res: Response) { @@ -1284,31 +1303,12 @@ export function issueRoutes( companyId: string, assigneeAgentId: string, ) { - const allowedByGrant = await access.hasPermission( - companyId, - "agent", - actorAgentId, - "tasks:manage_active_checkouts", - ); - if (allowedByGrant) return true; - - const companyAgents = await agentsSvc.list(companyId); - const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent])); - const actorAgent = agentsById.get(actorAgentId); - if (!actorAgent) return false; - if (canCreateAgentsLegacy(actorAgent)) return true; - - // Reporting-chain managers may intervene in an agent's active checkout - // without taking the task over. Peers must own the checkout/run first. - let cursor: string | null = assigneeAgentId; - for (let depth = 0; cursor && depth < 50; depth += 1) { - const assignee = agentsById.get(cursor); - if (!assignee) return false; - if (assignee.reportsTo === actorAgentId) return true; - cursor = assignee.reportsTo; - } - - return false; + const decision = await access.decide({ + actor: { type: "agent", agentId: actorAgentId, companyId }, + action: "tasks:manage_active_checkouts", + resource: { type: "issue", companyId, assigneeAgentId }, + }); + return decision.allowed; } async function assertAgentIssueMutationAllowed( @@ -3146,7 +3146,16 @@ export function issueRoutes( assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, companyId); + await assertCanAssignTasks(req, companyId, { + projectId: await resolveAssignmentProjectId({ + companyId, + projectId: req.body.projectId, + parentIssueId: req.body.parentId, + }), + parentIssueId: req.body.parentId ?? null, + assigneeAgentId: req.body.assigneeAgentId ?? null, + assigneeUserId: req.body.assigneeUserId ?? null, + }); } await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId); @@ -3242,7 +3251,12 @@ export function issueRoutes( assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, parent.companyId); + await assertCanAssignTasks(req, parent.companyId, { + projectId: req.body.projectId ?? parent.projectId ?? null, + parentIssueId: parent.id, + assigneeAgentId: req.body.assigneeAgentId ?? null, + assigneeUserId: req.body.assigneeUserId ?? null, + }); } await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId); @@ -3631,7 +3645,23 @@ export function issueRoutes( if (assigneeWillChange && !transition.workflowControlledAssignment) { if (!isAgentReturningIssueToCreator) { - await assertCanAssignTasks(req, existing.companyId); + await assertCanAssignTasks(req, existing.companyId, { + issueId: existing.id, + projectId: await resolveAssignmentProjectId({ + companyId: existing.companyId, + projectId: updateFields.projectId === undefined + ? existing.projectId + : updateFields.projectId as string | null | undefined, + parentIssueId: (updateFields.parentId === undefined + ? existing.parentId + : updateFields.parentId) as string | null | undefined, + }), + parentIssueId: (updateFields.parentId === undefined + ? existing.parentId + : updateFields.parentId) as string | null | undefined, + assigneeAgentId: nextAssigneeAgentId, + assigneeUserId: nextAssigneeUserId, + }); } } @@ -4401,6 +4431,16 @@ export function issueRoutes( return; } + if (issue.assigneeAgentId !== req.body.agentId) { + await assertCanAssignTasks(req, issue.companyId, { + issueId: issue.id, + projectId: issue.projectId ?? null, + parentIssueId: issue.parentId ?? null, + assigneeAgentId: req.body.agentId, + assigneeUserId: null, + }); + } + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 6abb7c56ec5..741fa1513fe 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -1116,7 +1116,7 @@ export function pluginRoutes( return; } - assertPluginBridgeScope(req, body.companyId); + const companyId = assertPluginBridgeScope(req, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1124,6 +1124,7 @@ export function pluginRoutes( "getData", { key: body.key, + ...(companyId ? { companyId } : {}), params: body.params ?? {}, renderEnvironment: body.renderEnvironment ?? null, }, @@ -1208,7 +1209,7 @@ export function pluginRoutes( return; } - assertPluginBridgeScope(req, body.companyId); + const companyId = assertPluginBridgeScope(req, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1216,6 +1217,7 @@ export function pluginRoutes( "performAction", { key: body.key, + ...(companyId ? { companyId } : {}), params: body.params ?? {}, renderEnvironment: body.renderEnvironment ?? null, }, @@ -1301,7 +1303,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - assertPluginBridgeScope(req, body?.companyId); + const companyId = assertPluginBridgeScope(req, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1309,6 +1311,7 @@ export function pluginRoutes( "getData", { key, + ...(companyId ? { companyId } : {}), params: body?.params ?? {}, renderEnvironment: body?.renderEnvironment ?? null, }, @@ -1390,7 +1393,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - assertPluginBridgeScope(req, body?.companyId); + const companyId = assertPluginBridgeScope(req, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1398,6 +1401,7 @@ export function pluginRoutes( "performAction", { key, + ...(companyId ? { companyId } : {}), params: body?.params ?? {}, renderEnvironment: body?.renderEnvironment ?? null, }, diff --git a/server/src/services/access.ts b/server/src/services/access.ts index faac70da407..ad58db87df4 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -9,6 +9,8 @@ import { } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import { conflict } from "../errors.js"; +import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js"; +import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { @@ -24,6 +26,8 @@ type MemberArchiveInput = { }; export function accessService(db: Db) { + const authorization = authorizationService(db); + async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; const row = await db @@ -58,21 +62,13 @@ export function accessService(db: Db) { principalId: string, permissionKey: PermissionKey, ): Promise { - const membership = await getMembership(companyId, principalType, principalId); - if (!membership || membership.status !== "active") return false; - const grant = await db - .select({ id: principalPermissionGrants.id }) - .from(principalPermissionGrants) - .where( - and( - eq(principalPermissionGrants.companyId, companyId), - eq(principalPermissionGrants.principalType, principalType), - eq(principalPermissionGrants.principalId, principalId), - eq(principalPermissionGrants.permissionKey, permissionKey), - ), - ) - .then((rows) => rows[0] ?? null); - return Boolean(grant); + return authorization.decidePrincipalGrant({ + companyId, + principalType, + principalId, + permissionKey, + action: permissionKey, + }).then((decision) => decision.allowed); } async function canUser( @@ -80,9 +76,20 @@ export function accessService(db: Db) { userId: string | null | undefined, permissionKey: PermissionKey, ): Promise { - if (!userId) return false; - if (await isInstanceAdmin(userId)) return true; - return hasPermission(companyId, "user", userId, permissionKey); + return authorization.decide({ + actor: { type: "board", userId }, + action: permissionKey, + resource: { type: "company", companyId }, + }).then((decision) => decision.allowed); + } + + async function decide(input: { + actor: AuthorizationActor; + action: Parameters[0]["action"]; + resource: AuthorizationResource; + scope?: Record | null; + }) { + return authorization.decide(input); } async function listMembers(companyId: string) { @@ -616,10 +623,30 @@ export function accessService(db: Db) { membership.membershipRole, "active", ); + await ensureHumanRoleDefaultGrants(db, { + companyId: targetCompanyId, + principalId: membership.principalId, + membershipRole: membership.membershipRole, + grantedByUserId: null, + }); } return sourceMemberships; } + async function ensureRoleDefaultGrants( + companyId: string, + principalId: string, + membershipRole: string | null | undefined, + grantedByUserId: string | null, + ) { + return ensureHumanRoleDefaultGrants(db, { + companyId, + principalId, + membershipRole, + grantedByUserId, + }); + } + async function listPrincipalGrants( companyId: string, principalType: PrincipalType, @@ -768,6 +795,7 @@ export function accessService(db: Db) { return { isInstanceAdmin, + decide, canUser, hasPermission, getMembership, @@ -776,6 +804,7 @@ export function accessService(db: Db) { listMembers, listActiveUserMemberships, copyActiveUserMemberships, + ensureRoleDefaultGrants, archiveMember, setMemberPermissions, updateMemberAndPermissions, diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index a0379c92e07..97cd13bf584 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -18,7 +18,9 @@ export function normalizeAgentPermissions( } const record = permissions as Record; + const preserved = { ...record }; return { + ...preserved, canCreateAgents: typeof record.canCreateAgents === "boolean" ? record.canCreateAgents diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 0d4762cc9d4..faf63723673 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -554,7 +554,7 @@ export function agentService(db: Db) { const updated = await db .update(agents) .set({ - permissions: normalizeAgentPermissions(permissions, existing.role), + permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role), updatedAt: new Date(), }) .where(eq(agents.id, id)) diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts new file mode 100644 index 00000000000..bb1d18b9b40 --- /dev/null +++ b/server/src/services/authorization.ts @@ -0,0 +1,823 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + companyMemberships, + instanceUserRoles, + issues, + principalPermissionGrants, + projects, +} from "@paperclipai/db"; +import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; + +export type AuthorizationActor = + { + type: "board" | "agent" | "none"; + userId?: string | null; + companyIds?: string[]; + memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>; + isInstanceAdmin?: boolean; + agentId?: string | null; + companyId?: string | null; + source?: + | "local_implicit" + | "session" + | "board_key" + | "agent_key" + | "agent_jwt" + | "cloud_tenant" + | "none"; + }; + +export type AuthorizationAction = + | PermissionKey + | "agent_config:read" + | "agent_config:update" + | "issue:mutate"; + +export type AuthorizationResource = + | { type: "company"; companyId: string } + | { type: "agent"; companyId: string; agentId?: string | null } + | { + type: "issue"; + companyId: string; + issueId?: string | null; + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + status?: string | null; + }; + +export type AuthorizationDecision = { + allowed: boolean; + action: AuthorizationAction; + explanation: string; + reason: + | "allow_local_board" + | "allow_instance_admin" + | "allow_explicit_grant" + | "allow_legacy_agent_creator" + | "allow_self" + | "allow_company_agent" + | "allow_simple_company_member" + | "allow_manager_chain" + | "deny_unauthenticated" + | "deny_company_boundary" + | "deny_missing_membership" + | "deny_missing_grant" + | "deny_policy_restricted" + | "deny_scope" + | "deny_unsupported_action"; + grant?: { + principalType: PrincipalType; + principalId: string; + permissionKey: PermissionKey; + scope: Record | null; + }; +}; + +type PrincipalGrantDecision = AuthorizationDecision & { + grant?: NonNullable; +}; + +function companyIdForResource(resource: AuthorizationResource) { + return resource.companyId; +} + +function permissionForAction(action: AuthorizationAction): PermissionKey | null { + if (action === "agent_config:read" || action === "agent_config:update") return "agents:create"; + if (action === "issue:mutate") return null; + return action; +} + +function canCreateAgentsLegacy(agent: { role: string; permissions: Record | null | undefined }) { + if (agent.role === "ceo") return true; + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean(agent.permissions.canCreateAgents); +} + +function scopeValueList(value: unknown): string[] { + if (typeof value === "string" && value.trim()) return [value.trim()]; + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); +} + +function prefixedScopeValues(grantScope: Record, prefix: string) { + return scopeValueList(grantScope.allow) + .filter((rule) => rule.startsWith(prefix)) + .map((rule) => rule.slice(prefix.length)) + .filter((value) => value.length > 0); +} + +function scopeValuesForKeys(grantScope: Record, keys: string[]) { + return keys.flatMap((key) => scopeValueList(grantScope[key])); +} + +function scopeIncludesId(ids: string[], id: string | null | undefined) { + return Boolean(id && ids.includes(id)); +} + +function isSimpleAssignableAgentStatus(status: string | null | undefined) { + return status !== "pending_approval" && status !== "terminated"; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function objectIsEmpty(value: Record) { + return Object.keys(value).length === 0; +} + +function readPolicyObject(container: unknown, key: string): Record | null { + if (!isPlainRecord(container)) return null; + const value = container[key]; + return isPlainRecord(value) ? value : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +type AssignmentPolicyEffect = + | { kind: "none" } + | { kind: "restricted"; explanation: string } + | { kind: "requires_approval"; explanation: string } + | { kind: "unknown"; explanation: string }; + +type AgentHierarchyRow = { id: string; reportsTo: string | null }; + +function evaluateAuthorizationPolicyForAssignment( + policy: Record | null | undefined, + label: string, +): AssignmentPolicyEffect { + if (!policy || objectIsEmpty(policy)) return { kind: "none" }; + + const agentVisibility = readPolicyObject(policy, "agentVisibility"); + const assignmentPolicy = readPolicyObject(policy, "assignmentPolicy"); + const protectedAgent = readPolicyObject(policy, "protectedAgent"); + const knownTopLevelKeys = new Set([ + "agentVisibility", + "assignmentPolicy", + "protectedAgent", + "managedBy", + ]); + const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key)); + const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent); + if (hasUnknownTopLevelKey || !hasKnownPolicySection) { + return { + kind: "unknown", + explanation: `${label} has authorization policy data that core cannot evaluate for task assignment.`, + }; + } + + const visibilityMode = readString(agentVisibility?.mode); + if (visibilityMode && visibilityMode !== "discoverable" && visibilityMode !== "private") { + return { + kind: "unknown", + explanation: `${label} has an unsupported agent visibility policy mode.`, + }; + } + + const assignmentMode = readString(assignmentPolicy?.mode); + if (assignmentMode && assignmentMode !== "company_default" && assignmentMode !== "protected") { + return { + kind: "unknown", + explanation: `${label} has an unsupported assignment policy mode.`, + }; + } + + const requiresApproval = + readBoolean(protectedAgent?.requiresApproval) === true || + readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true; + if (requiresApproval) { + return { + kind: "requires_approval", + explanation: `${label} requires approval before task assignment.`, + }; + } + + if ( + visibilityMode === "private" || + readBoolean(agentVisibility?.hiddenFromDefaultDirectory) === true + ) { + return { + kind: "restricted", + explanation: `${label} is private and cannot use simple company-wide task assignment.`, + }; + } + + if (assignmentMode === "protected") { + return { + kind: "restricted", + explanation: `${label} is protected and requires an explicit assignment grant.`, + }; + } + + return { kind: "none" }; +} + +function agentIsInSubtree( + agentsById: Map, + rootAgentId: string, + targetAgentId: string, +) { + if (rootAgentId === targetAgentId) return true; + + let cursor: string | null = targetAgentId; + for (let depth = 0; cursor && depth < 50; depth += 1) { + const current = agentsById.get(cursor); + if (!current) return false; + if (current.reportsTo === rootAgentId) return true; + cursor = current.reportsTo; + } + return false; +} + +async function loadCompanyAgentHierarchy(db: Db, companyId: string) { + const rows = await db + .select({ id: agents.id, reportsTo: agents.reportsTo }) + .from(agents) + .where(eq(agents.companyId, companyId)); + return new Map(rows.map((agent) => [agent.id, agent])); +} + +async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) { + return agentIsInSubtree( + await loadCompanyAgentHierarchy(db, companyId), + rootAgentId, + targetAgentId, + ); +} + +async function scopeAllows( + db: Db, + companyId: string, + grantScope: Record | null, + requestedScope: Record | null | undefined, + options: { requireStructuredScope?: boolean } = {}, +) { + if (!grantScope || Object.keys(grantScope).length === 0) return !options.requireStructuredScope; + if (!requestedScope) return false; + + const targetAssigneeAgentId = + typeof requestedScope.assigneeAgentId === "string" + ? requestedScope.assigneeAgentId + : typeof requestedScope.targetAgentId === "string" + ? requestedScope.targetAgentId + : null; + const requestedProjectId = typeof requestedScope.projectId === "string" ? requestedScope.projectId : null; + let constrained = false; + + const projectIds = [ + ...scopeValueList(grantScope.projectId), + ...scopeValueList(grantScope.projectIds), + ...prefixedScopeValues(grantScope, "project:"), + ]; + if (projectIds.length > 0) { + constrained = true; + if (!scopeIncludesId(projectIds, requestedProjectId)) return false; + } + + const targetAgentIds = [ + ...scopeValuesForKeys(grantScope, [ + "agentId", + "agentIds", + "assigneeAgentId", + "assigneeAgentIds", + "targetAgentId", + "targetAgentIds", + ]), + ...prefixedScopeValues(grantScope, "agent:"), + ]; + if (targetAgentIds.length > 0) { + constrained = true; + if (!scopeIncludesId(targetAgentIds, targetAssigneeAgentId)) return false; + } + + const subtreeRootAgentIds = [ + ...scopeValuesForKeys(grantScope, [ + "managerAgentId", + "managerAgentIds", + "managedSubtreeAgentId", + "managedSubtreeAgentIds", + "subtreeAgentId", + "subtreeAgentIds", + "subtreeRootAgentId", + "subtreeRootAgentIds", + ]), + ...prefixedScopeValues(grantScope, "subtree:"), + ]; + if (subtreeRootAgentIds.length > 0) { + constrained = true; + if (!targetAssigneeAgentId) return false; + const agentsById = await loadCompanyAgentHierarchy(db, companyId); + let matchesSubtree = false; + for (const rootAgentId of subtreeRootAgentIds) { + if (agentIsInSubtree(agentsById, rootAgentId, targetAssigneeAgentId)) { + matchesSubtree = true; + break; + } + } + if (!matchesSubtree) return false; + } + + // Unknown metadata keys do not constrain the grant. Recognized constraints + // return false above when they fail to match the requested assignment scope. + return !constrained ? true : constrained; +} + +function allow(input: Omit): AuthorizationDecision { + return { ...input, allowed: true }; +} + +function deny(input: Omit): AuthorizationDecision { + return { ...input, allowed: false }; +} + +export function authorizationService(db: Db) { + async function isInstanceAdmin(userId: string | null | undefined): Promise { + if (!userId) return false; + if ( + await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null) + ) { + return true; + } + return false; + } + + async function getActiveMembership( + companyId: string, + principalType: PrincipalType, + principalId: string, + ) { + return db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, principalType), + eq(companyMemberships.principalId, principalId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function findGrant( + companyId: string, + principalType: PrincipalType, + principalId: string, + permissionKey: PermissionKey, + ) { + return db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function decidePrincipalGrant(input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + action: AuthorizationAction; + permissionKey: PermissionKey; + scope?: Record | null; + }): Promise { + const membership = await getActiveMembership(input.companyId, input.principalType, input.principalId); + if (!membership) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: `${input.principalType} principal ${input.principalId} is not an active member of company ${input.companyId}.`, + }); + } + + const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey); + if (!grant) { + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: `Missing permission: ${input.permissionKey}.`, + }); + } + + if ( + !(await scopeAllows(db, input.companyId, grant.scope, input.scope, { + requireStructuredScope: input.permissionKey === "tasks:assign_scope", + })) + ) { + return deny({ + action: input.action, + reason: "deny_scope", + explanation: `Permission ${input.permissionKey} does not cover the requested scope.`, + grant: { + principalType: input.principalType, + principalId: input.principalId, + permissionKey: input.permissionKey, + scope: grant.scope ?? null, + }, + }); + } + + return allow({ + action: input.action, + reason: "allow_explicit_grant", + explanation: `Allowed by explicit grant ${input.permissionKey}.`, + grant: { + principalType: input.principalType, + principalId: input.principalId, + permissionKey: input.permissionKey, + scope: grant.scope ?? null, + }, + }); + } + + async function loadAgent(agentId: string) { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + role: agents.role, + status: agents.status, + reportsTo: agents.reportsTo, + permissions: agents.permissions, + }) + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); + } + + async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) { + const row = await db + .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return readPolicyObject(row?.executionWorkspacePolicy, "authorizationPolicy"); + } + + async function loadIssueAuthorizationPolicy(companyId: string, issueId: string) { + const row = await db + .select({ executionPolicy: issues.executionPolicy }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return readPolicyObject(row?.executionPolicy, "authorizationPolicy"); + } + + async function assignmentTargetIsInCompany(resource: AuthorizationResource) { + if (resource.type !== "issue") return true; + if (resource.assigneeAgentId) { + const target = await loadAgent(resource.assigneeAgentId); + return Boolean( + target && + target.companyId === resource.companyId && + isSimpleAssignableAgentStatus(target.status), + ); + } + if (resource.assigneeUserId) { + return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId)); + } + return true; + } + + async function assignmentPolicyEffect(resource: AuthorizationResource): Promise { + if (resource.type !== "issue") return { kind: "none" }; + + const checks: Array> = []; + if (resource.assigneeAgentId) { + checks.push( + loadAgent(resource.assigneeAgentId).then((agent) => + evaluateAuthorizationPolicyForAssignment( + readPolicyObject(agent?.permissions, "authorizationPolicy"), + "Target agent", + ), + ), + ); + } + if (resource.projectId) { + checks.push( + loadProjectAuthorizationPolicy(resource.companyId, resource.projectId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Target project"), + ), + ); + } + if (resource.issueId) { + checks.push( + loadIssueAuthorizationPolicy(resource.companyId, resource.issueId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Target issue"), + ), + ); + } + if (resource.parentIssueId && resource.parentIssueId !== resource.issueId) { + checks.push( + loadIssueAuthorizationPolicy(resource.companyId, resource.parentIssueId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Parent issue"), + ), + ); + } + if (checks.length === 0) return { kind: "none" }; + + const effects = await Promise.all(checks); + return ( + effects.find((effect) => effect.kind === "unknown") ?? + effects.find((effect) => effect.kind === "requires_approval") ?? + effects.find((effect) => effect.kind === "restricted") ?? + { kind: "none" } + ); + } + + async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) { + return isAgentInSubtree(db, companyId, managerAgentId, assigneeAgentId); + } + + async function decide(input: { + actor: AuthorizationActor; + action: AuthorizationAction; + resource: AuthorizationResource; + scope?: Record | null; + }): Promise { + const permissionKey = permissionForAction(input.action); + const companyId = companyIdForResource(input.resource); + + async function decideWithTaskAssignmentGrants( + principalType: PrincipalType, + principalId: string, + ): Promise { + const broadDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "tasks:assign", + scope: input.scope, + }); + if (broadDecision.allowed || broadDecision.reason === "deny_missing_membership") return broadDecision; + const scopedDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "tasks:assign_scope", + scope: input.scope, + }); + if (scopedDecision.allowed || broadDecision.reason === "deny_missing_grant") return scopedDecision; + return broadDecision; + } + + async function denyForAssignmentPolicyIfNeeded( + policyEffect: AssignmentPolicyEffect, + ): Promise { + if (policyEffect.kind === "none" || policyEffect.kind === "restricted") return null; + return deny({ + action: input.action, + reason: "deny_policy_restricted", + explanation: policyEffect.explanation, + }); + } + + function denyRestrictedAssignmentPolicy(policyEffect: AssignmentPolicyEffect): AuthorizationDecision { + return deny({ + action: input.action, + reason: "deny_policy_restricted", + explanation: + policyEffect.kind === "restricted" + ? policyEffect.explanation + : "Restrictive authorization policy blocks simple company-wide task assignment.", + }); + } + + if (input.actor.type === "none") { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Authentication required.", + }); + } + + if (input.actor.type === "board") { + let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null; + if (input.actor.source === "local_implicit") { + return allow({ + action: input.action, + reason: "allow_local_board", + explanation: "Allowed because the actor is the local implicit board.", + }); + } + if (input.actor.isInstanceAdmin || await isInstanceAdmin(input.actor.userId)) { + return allow({ + action: input.action, + reason: "allow_instance_admin", + explanation: "Allowed because the actor is an instance admin.", + }); + } + if (!input.actor.userId) { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Board user id is required.", + }); + } + if (input.action === "tasks:assign") { + if (!(await assignmentTargetIsInCompany(input.resource))) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Task assignment target agent is not active in the target company.", + }); + } + const policyEffect = await assignmentPolicyEffect(input.resource); + taskAssignmentPolicyEffect = policyEffect; + const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); + if (policyDeny) return policyDeny; + const membership = await getActiveMembership(companyId, "user", input.actor.userId); + if (policyEffect.kind === "none" && membership && membership.membershipRole !== "viewer") { + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by simple mode company-wide task assignment default.", + }); + } + } + if (!permissionKey) { + return deny({ + action: input.action, + reason: "deny_unsupported_action", + explanation: `No board permission mapping exists for ${input.action}.`, + }); + } + if (input.action === "tasks:assign") { + const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId); + if (grantDecision.allowed) return grantDecision; + const policyEffect = taskAssignmentPolicyEffect ?? await assignmentPolicyEffect(input.resource); + if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect); + return grantDecision; + } + return decidePrincipalGrant({ + companyId, + principalType: "user", + principalId: input.actor.userId, + action: input.action, + permissionKey, + scope: input.scope, + }); + } + + const actorAgentId = input.actor.agentId ?? null; + if (!actorAgentId) { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Agent authentication required.", + }); + } + if (input.actor.companyId !== companyId) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Agent key cannot access another company.", + }); + } + + const actorAgent = await loadAgent(actorAgentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Actor agent was not found in the target company.", + }); + } + + if (input.action === "tasks:assign") { + if (!isSimpleAssignableAgentStatus(actorAgent.status)) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: "Actor agent is not active for simple mode task assignment.", + }); + } + if (!(await assignmentTargetIsInCompany(input.resource))) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Task assignment target agent is not active in the target company.", + }); + } + const policyEffect = await assignmentPolicyEffect(input.resource); + const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); + if (policyDeny) return policyDeny; + if (policyEffect.kind === "restricted") { + const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId); + if (grantDecision.allowed) return grantDecision; + return denyRestrictedAssignmentPolicy(policyEffect); + } + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by simple mode company-wide task assignment default.", + }); + } + + if (input.action === "issue:mutate") { + const resource = input.resource.type === "issue" ? input.resource : null; + if (resource?.assigneeAgentId === actorAgentId) { + return allow({ + action: input.action, + reason: "allow_self", + explanation: "Allowed because the actor owns the assigned issue.", + }); + } + if (!resource?.assigneeAgentId) { + return allow({ + action: input.action, + reason: "allow_company_agent", + explanation: "Allowed because the issue has no agent assignee.", + }); + } + } + if ( + input.action === "agent_config:update" && + input.resource.type === "agent" && + input.resource.agentId === actorAgentId + ) { + return allow({ + action: input.action, + reason: "allow_self", + explanation: "Allowed because the actor is updating its own agent configuration.", + }); + } + + if (permissionKey) { + const grantDecision = await decidePrincipalGrant({ + companyId, + principalType: "agent", + principalId: actorAgentId, + action: input.action, + permissionKey, + scope: input.scope, + }); + if (grantDecision.allowed) return grantDecision; + } + + if ( + (input.action === "agents:create" || + input.action === "agent_config:read" || + input.action === "agent_config:update" || + input.action === "tasks:manage_active_checkouts") && + canCreateAgentsLegacy(actorAgent) + ) { + return allow({ + action: input.action, + reason: "allow_legacy_agent_creator", + explanation: "Allowed by legacy agent creator authority.", + }); + } + + if ( + input.action === "tasks:manage_active_checkouts" && + input.resource.type === "issue" && + input.resource.assigneeAgentId && + await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId) + ) { + return allow({ + action: input.action, + reason: "allow_manager_chain", + explanation: "Allowed because the actor manages the issue assignee in the reporting chain.", + }); + } + + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: permissionKey + ? `Missing permission: ${permissionKey}.` + : `No agent permission mapping exists for ${input.action}.`, + }); + } + + return { + decide, + decidePrincipalGrant, + }; +} diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index 6e8513ee451..593770ca42e 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -28,6 +28,7 @@ export function grantsForHumanRole( case "owner": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, @@ -36,6 +37,7 @@ export function grantsForHumanRole( case "admin": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index b1a4ede3969..1b9dfc85794 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -4118,7 +4118,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (mode === "agent_safe" && options?.sourceCompanyId) { await access.copyActiveUserMemberships(options.sourceCompanyId, created.id); } else { - await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active"); + const ownerPrincipalId = actorUserId ?? "board"; + await access.ensureMembership(created.id, "user", ownerPrincipalId, "owner", "active"); + await access.ensureRoleDefaultGrants( + created.id, + ownerPrincipalId, + "owner", + actorUserId ?? null, + ); } targetCompany = created; companyAction = "created"; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2e7c6bcdaf5..a8570d2d27f 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -44,6 +44,19 @@ export { sidebarBadgeService } from "./sidebar-badges.js"; export { sidebarPreferenceService } from "./sidebar-preferences.js"; export { inboxDismissalService } from "./inbox-dismissals.js"; export { accessService } from "./access.js"; +export { + backfillPrincipalAccessCompatibility, + ensureHumanRoleDefaultGrants, + insertMissingPrincipalGrants, + type PrincipalAccessCompatibilityBackfillStats, +} from "./principal-access-compatibility.js"; +export { authorizationService } from "./authorization.js"; +export type { + AuthorizationAction, + AuthorizationActor, + AuthorizationDecision, + AuthorizationResource, +} from "./authorization.js"; export { boardAuthService } from "./board-auth.js"; export { instanceSettingsService } from "./instance-settings.js"; export { companyPortabilityService } from "./company-portability.js"; diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index e400ab88ca9..f8b47866c35 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -149,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record = { commentAnnotation: "ui.commentAnnotation.register", commentContextMenuItem: "ui.action.register", settingsPage: "instance.settings.register", + companySettingsPage: "instance.settings.register", routeSidebar: "ui.sidebar.register", }; diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index fef165dc1a8..7c2a9156267 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1,14 +1,18 @@ import type { Db } from "@paperclipai/db"; import { + activityLog, agentTaskSessions as agentTaskSessionsTable, agents as agentsTable, budgetIncidents, costEvents, heartbeatRuns, + invites, issues as issuesTable, pluginLogs, + principalPermissionGrants, + projects as projectsTable, } from "@paperclipai/db"; -import { eq, and, like, desc, inArray, sql } from "drizzle-orm"; +import { eq, and, like, desc, inArray, sql, isNull, isNotNull, gt, lte } from "drizzle-orm"; import type { HostServices, Company, @@ -22,7 +26,7 @@ import type { PluginIssueOrchestrationSummary, PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; -import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared"; +import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; @@ -36,11 +40,8 @@ import { heartbeatService } from "./heartbeat.js"; import { budgetService } from "./budgets.js"; import { issueApprovalService } from "./issue-approvals.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import path from "node:path"; -import { activityService } from "./activity.js"; -import { costService } from "./costs.js"; -import { assetService } from "./assets.js"; import { pluginRegistryService } from "./plugin-registry.js"; import { pluginStateStore } from "./plugin-state-store.js"; import { pluginDatabaseService } from "./plugin-database.js"; @@ -71,6 +72,9 @@ import { request as httpsRequest } from "node:https"; import { isIP } from "node:net"; import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; +import { accessService } from "./access.js"; +import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { sanitizeRecord } from "../redaction.js"; // --------------------------------------------------------------------------- // SSRF protection for plugin HTTP fetch @@ -526,11 +530,10 @@ export function buildHostServices( const issues = issueService(db); const documents = documentService(db); const goals = goalService(db); - const activity = activityService(db); - const costs = costService(db); + const access = accessService(db); + const authorization = authorizationService(db); const budgets = budgetService(db); const issueApprovals = issueApprovalService(db); - const assets = assetService(db); const scopedBus = eventBus.forPlugin(pluginKey); // Track active session event subscriptions for cleanup @@ -562,6 +565,17 @@ export function buildHostServices( return rows.slice(offset, offset + limit); }; + const authorizationAuditDecisionCondition = (decisionFilter: string) => { + const conditions = [ + sql`lower(${activityLog.details}->>'decision') = ${decisionFilter}`, + decisionFilter === "allow" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 6) = 'allow_'` : undefined, + decisionFilter === "deny" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 5) = 'deny_'` : undefined, + decisionFilter === "allow" ? sql`${activityLog.details}->>'allowed' = 'true'` : undefined, + decisionFilter === "deny" ? sql`${activityLog.details}->>'allowed' = 'false'` : undefined, + ].filter((condition): condition is NonNullable => Boolean(condition)); + return sql`(${sql.join(conditions, sql` OR `)})`; + }; + /** * Plugins are instance-wide in the current runtime. Company IDs are still * required for company-scoped data access, but there is no per-company @@ -841,6 +855,202 @@ export function buildHostServices( })); }; + const INVITE_TOKEN_PREFIX = "pcp_invite_"; + const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; + const INVITE_TOKEN_SUFFIX_LENGTH = 8; + const INVITE_TOKEN_MAX_RETRIES = 5; + const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; + + const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); + + const createInviteToken = () => { + const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH); + let suffix = ""; + for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) { + suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length]; + } + return `${INVITE_TOKEN_PREFIX}${suffix}`; + }; + + const isInviteTokenHashCollisionError = (error: unknown) => { + const candidates = [ + error, + (error as { cause?: unknown } | null)?.cause ?? null, + ]; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object") continue; + const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null; + const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : ""; + const constraint = "constraint" in candidate && typeof candidate.constraint === "string" ? candidate.constraint : null; + if (code !== "23505") continue; + if (constraint === "invites_token_hash_unique_idx") return true; + if (message.includes("invites_token_hash_unique_idx")) return true; + } + return false; + }; + + const inviteState = (invite: typeof invites.$inferSelect) => { + if (invite.revokedAt) return "revoked" as const; + if (invite.acceptedAt) return "accepted" as const; + if (invite.expiresAt <= new Date()) return "expired" as const; + return "active" as const; + }; + + const redactInvite = (invite: typeof invites.$inferSelect) => { + const { tokenHash: _tokenHash, defaultsPayload, ...safeInvite } = invite; + return { + ...safeInvite, + allowedJoinTypes: safeInvite.allowedJoinTypes as InviteJoinType, + defaultsPayload: defaultsPayload && typeof defaultsPayload === "object" + ? sanitizeRecord(defaultsPayload) + : defaultsPayload ?? null, + state: inviteState(invite), + }; + }; + + const inviteStateWhereClause = (state: unknown) => { + const now = new Date(); + switch (state) { + case "active": + return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), gt(invites.expiresAt, now)); + case "accepted": + return isNotNull(invites.acceptedAt); + case "expired": + return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), lte(invites.expiresAt, now)); + case "revoked": + return isNotNull(invites.revokedAt); + default: + return undefined; + } + }; + + const mergeInviteDefaults = (defaultsPayload: Record | null | undefined, agentMessage: string | null, humanRole: string | null) => { + const defaults = defaultsPayload && typeof defaultsPayload === "object" + ? { ...defaultsPayload } + : {}; + if (humanRole) { + defaults.human = { + ...(typeof defaults.human === "object" && defaults.human !== null ? defaults.human as Record : {}), + role: humanRole, + }; + } + if (agentMessage) { + defaults.agent = { + ...(typeof defaults.agent === "object" && defaults.agent !== null ? defaults.agent as Record : {}), + message: agentMessage, + }; + } + return sanitizeRecord(defaults); + }; + + const redactGrant = (grant: typeof principalPermissionGrants.$inferSelect) => ({ + ...grant, + principalType: grant.principalType as PrincipalType, + permissionKey: grant.permissionKey as PermissionKey, + scope: grant.scope && typeof grant.scope === "object" ? sanitizeRecord(grant.scope) : grant.scope ?? null, + }); + + const loadPluginMember = async (companyId: string, memberId: string) => { + const member = await access.getMemberById(companyId, memberId); + if (!member) return null; + const grants = await access.listPrincipalGrants( + companyId, + member.principalType as PrincipalType, + member.principalId, + ); + return { + ...member, + principalType: member.principalType as PrincipalType, + status: member.status as "pending" | "active" | "suspended" | "archived", + grants: grants.map(redactGrant), + }; + }; + + const pluginAssignmentActor = (actor: { + type: "agent" | "board"; + agentId?: string | null; + companyId?: string | null; + userId?: string | null; + companyIds?: string[]; + }): AuthorizationActor => { + if (actor.type === "agent") { + return { + type: "agent", + agentId: actor.agentId ?? null, + companyId: actor.companyId ?? null, + source: "agent_key", + }; + } + return { + type: "board", + userId: actor.userId ?? null, + companyIds: Array.isArray(actor.companyIds) ? actor.companyIds : [], + source: "session", + }; + }; + + const policyPathForResource = (resourceType: "company" | "agent" | "project" | "issue") => { + switch (resourceType) { + case "agent": + return { table: "agent" as const }; + case "project": + return { table: "project" as const }; + case "issue": + return { table: "issue" as const }; + case "company": + return { table: "company" as const }; + } + }; + + const readAuthorizationPolicy = async (companyId: string, resourceType: "company" | "agent" | "project" | "issue", resourceId: string) => { + const pathInfo = policyPathForResource(resourceType); + if (pathInfo.table === "agent") { + const agent = await agents.getById(resourceId); + if (!inCompany(agent, companyId)) return null; + const permissions = agent.permissions && typeof agent.permissions === "object" ? agent.permissions as Record : {}; + return { + resourceType, + resourceId, + companyId, + policy: permissions.authorizationPolicy && typeof permissions.authorizationPolicy === "object" + ? sanitizeRecord(permissions.authorizationPolicy as Record) + : null, + updatedAt: agent.updatedAt, + }; + } + if (pathInfo.table === "project") { + const project = await projects.getById(resourceId); + if (!inCompany(project, companyId)) return null; + const policy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object" + ? (project.executionWorkspacePolicy as unknown as Record).authorizationPolicy + : null; + return { + resourceType, + resourceId, + companyId, + policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null, + updatedAt: project.updatedAt, + }; + } + if (pathInfo.table === "issue") { + const issue = await issues.getById(resourceId); + if (!inCompany(issue, companyId)) return null; + const policy = issue.executionPolicy && typeof issue.executionPolicy === "object" + ? (issue.executionPolicy as Record).authorizationPolicy + : null; + return { + resourceType, + resourceId, + companyId, + policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null, + updatedAt: issue.updatedAt, + }; + } + const company = await companies.getById(resourceId); + if (!company || company.id !== companyId) return null; + return { resourceType, resourceId, companyId, policy: null, updatedAt: company.updatedAt }; + }; + return { config: { async get() { @@ -1993,6 +2203,337 @@ export function buildHostServices( }, }, + access: { + async listMembers(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await access.listMembers(companyId); + const visibleRows = params.includeArchived ? rows : rows.filter((row) => row.status !== "archived"); + const grants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.companyId, companyId)); + const grantsByPrincipal = new Map(); + for (const grant of grants) { + const key = `${grant.principalType}:${grant.principalId}`; + const existing = grantsByPrincipal.get(key) ?? []; + existing.push(grant); + grantsByPrincipal.set(key, existing); + } + return visibleRows.map((member) => ({ + ...member, + principalType: member.principalType as PrincipalType, + status: member.status as "pending" | "active" | "suspended" | "archived", + grants: (grantsByPrincipal.get(`${member.principalType}:${member.principalId}`) ?? []).map(redactGrant), + })); + }, + async getMember(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return loadPluginMember(companyId, params.memberId); + }, + async updateMember(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const updated = await access.updateMember(companyId, params.memberId, params.patch); + if (!updated) throw new Error("Member not found"); + await logPluginActivity({ + companyId, + action: "company_member.updated_by_plugin", + entityType: "company_membership", + entityId: params.memberId, + details: { + patch: sanitizeRecord(params.patch as Record), + }, + }); + return (await loadPluginMember(companyId, params.memberId))!; + }, + async listInvites(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const limit = Math.min(Math.max(Number(params.limit ?? 20), 1), 100); + const offset = Math.max(Number(params.offset ?? 0), 0); + const stateClause = inviteStateWhereClause(params.state); + const rows = await db + .select() + .from(invites) + .where(stateClause ? and(eq(invites.companyId, companyId), stateClause) : eq(invites.companyId, companyId)) + .orderBy(desc(invites.createdAt)) + .limit(limit + 1) + .offset(offset); + const hasMore = rows.length > limit; + return { + invites: rows.slice(0, limit).map(redactInvite), + nextOffset: hasMore ? offset + limit : null, + }; + }, + async createInvite(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const normalizedAgentMessage = typeof params.agentMessage === "string" + ? params.agentMessage.trim() || null + : null; + const allowedJoinTypes = params.allowedJoinTypes ?? "both"; + const humanRole = allowedJoinTypes === "agent" ? null : params.humanRole ?? "operator"; + const insertValues = { + companyId, + inviteType: "company_join" as const, + allowedJoinTypes, + defaultsPayload: mergeInviteDefaults(params.defaultsPayload ?? null, normalizedAgentMessage, humanRole), + expiresAt: new Date(Date.now() + COMPANY_INVITE_TTL_MS), + invitedByUserId: null, + }; + let token: string | null = null; + let created: typeof invites.$inferSelect | null = null; + for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { + const candidateToken = createInviteToken(); + try { + created = await db + .insert(invites) + .values({ + ...insertValues, + tokenHash: hashToken(candidateToken), + }) + .returning() + .then((rows) => rows[0] ?? null); + token = candidateToken; + break; + } catch (error) { + if (!isInviteTokenHashCollisionError(error)) throw error; + } + } + if (!token || !created) throw new Error("Failed to generate a unique invite token"); + await logPluginActivity({ + companyId, + action: "invite.created_by_plugin", + entityType: "invite", + entityId: created.id, + details: { + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage), + }, + }); + return { ...redactInvite(created), token }; + }, + async revokeInvite(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const invite = await db + .select() + .from(invites) + .where(and(eq(invites.id, params.inviteId), eq(invites.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!invite) throw new Error("Invite not found"); + if (invite.acceptedAt) throw new Error("Invite already consumed"); + if (invite.revokedAt) return redactInvite(invite); + const revoked = await db + .update(invites) + .set({ revokedAt: new Date(), updatedAt: new Date() }) + .where(eq(invites.id, invite.id)) + .returning() + .then((rows) => rows[0] ?? invite); + await logPluginActivity({ + companyId, + action: "invite.revoked_by_plugin", + entityType: "invite", + entityId: invite.id, + }); + return redactInvite(revoked); + }, + }, + + authorization: { + async listGrants(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const conditions = [ + eq(principalPermissionGrants.companyId, companyId), + params.principalType ? eq(principalPermissionGrants.principalType, params.principalType) : undefined, + params.principalId ? eq(principalPermissionGrants.principalId, params.principalId) : undefined, + ].filter((condition): condition is NonNullable => Boolean(condition)); + const rows = await db + .select() + .from(principalPermissionGrants) + .where(and(...conditions)) + .orderBy(principalPermissionGrants.principalType, principalPermissionGrants.principalId, principalPermissionGrants.permissionKey); + return rows.map(redactGrant); + }, + async setGrants(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + if (params.principalType !== "agent" && params.principalType !== "user") { + throw new Error("principalType must be 'agent' or 'user'"); + } + if (params.principalType === "agent") { + requireInCompany("Agent", await agents.getById(params.principalId), companyId); + } else { + const membership = await access.getMembership(companyId, params.principalType as PrincipalType, params.principalId); + if (!membership) throw new Error("Principal is not a member of this company"); + } + await access.setPrincipalGrants( + companyId, + params.principalType as PrincipalType, + params.principalId, + params.grants.map((grant) => ({ + permissionKey: grant.permissionKey as PermissionKey, + scope: grant.scope ? sanitizeRecord(grant.scope) : null, + })), + params.grantedByUserId ?? null, + ); + await logPluginActivity({ + companyId, + action: "authorization.grants_updated_by_plugin", + entityType: "principal_permission_grants", + entityId: `${params.principalType}:${params.principalId}`, + details: { grantCount: params.grants.length }, + }); + return access + .listPrincipalGrants(companyId, params.principalType as PrincipalType, params.principalId) + .then((rows) => rows.map(redactGrant)); + }, + async policySummary(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const [members, grants] = await Promise.all([ + access.listMembers(companyId), + db + .select({ id: principalPermissionGrants.id }) + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.companyId, companyId)), + ]); + return { + companyId, + permissionsMode: "simple" as const, + memberCount: members.length, + activeMemberCount: members.filter((member) => member.status === "active").length, + grantCount: grants.length, + advancedPolicyAvailable: false as const, + }; + }, + async getPolicy(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return readAuthorizationPolicy(companyId, params.resourceType, params.resourceId); + }, + async updatePolicy(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const policy = params.policy ? sanitizeRecord(params.policy) : null; + if (params.resourceType === "agent") { + const agent = requireInCompany("Agent", await agents.getById(params.resourceId), companyId); + const permissions = agent.permissions && typeof agent.permissions === "object" + ? { ...(agent.permissions as Record) } + : {}; + if (policy) permissions.authorizationPolicy = policy; + else delete permissions.authorizationPolicy; + await db + .update(agentsTable) + .set({ permissions, updatedAt: new Date() }) + .where(eq(agentsTable.id, agent.id)); + } else if (params.resourceType === "project") { + const project = requireInCompany("Project", await projects.getById(params.resourceId), companyId); + const executionWorkspacePolicy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object" + ? { ...(project.executionWorkspacePolicy as unknown as Record) } + : {}; + if (policy) executionWorkspacePolicy.authorizationPolicy = policy; + else delete executionWorkspacePolicy.authorizationPolicy; + await db + .update(projectsTable) + .set({ executionWorkspacePolicy, updatedAt: new Date() }) + .where(eq(projectsTable.id, project.id)); + } else if (params.resourceType === "issue") { + const issue = requireInCompany("Issue", await issues.getById(params.resourceId), companyId); + const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object" + ? { ...(issue.executionPolicy as Record) } + : {}; + if (policy) executionPolicy.authorizationPolicy = policy; + else delete executionPolicy.authorizationPolicy; + await db + .update(issuesTable) + .set({ executionPolicy, updatedAt: new Date() }) + .where(eq(issuesTable.id, issue.id)); + } else { + const company = await companies.getById(params.resourceId); + if (!company || company.id !== companyId) throw new Error("Company not found"); + throw new Error("Company authorization policy updates are not supported by the current core schema"); + } + await logPluginActivity({ + companyId, + action: "authorization.policy_updated_by_plugin", + entityType: params.resourceType, + entityId: params.resourceId, + details: { hasPolicy: Boolean(policy) }, + }); + const updated = await readAuthorizationPolicy(companyId, params.resourceType, params.resourceId); + if (!updated) throw new Error("Policy resource not found"); + return updated; + }, + async previewAssignment(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return authorization.decide({ + actor: pluginAssignmentActor(params.actor), + action: "tasks:assign", + resource: { type: "issue", companyId, ...params.target }, + scope: { + issueId: params.target.issueId ?? null, + projectId: params.target.projectId ?? null, + parentIssueId: params.target.parentIssueId ?? null, + assigneeAgentId: params.target.assigneeAgentId ?? null, + assigneeUserId: params.target.assigneeUserId ?? null, + }, + }); + }, + async explainAssignment(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return authorization.decide({ + actor: pluginAssignmentActor(params.actor), + action: "tasks:assign", + resource: { type: "issue", companyId, ...params.target }, + scope: { + issueId: params.target.issueId ?? null, + projectId: params.target.projectId ?? null, + parentIssueId: params.target.parentIssueId ?? null, + assigneeAgentId: params.target.assigneeAgentId ?? null, + assigneeUserId: params.target.assigneeUserId ?? null, + }, + }); + }, + async searchAudit(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const limit = Math.min(Math.max(Number(params.limit ?? 50), 1), 100); + const offset = Math.max(Number(params.offset ?? 0), 0); + const decisionFilter = typeof params.decision === "string" && params.decision.trim() + ? params.decision.trim().toLowerCase() + : null; + const conditions = [ + eq(activityLog.companyId, companyId), + params.action ? eq(activityLog.action, params.action) : undefined, + params.actorType ? eq(activityLog.actorType, params.actorType) : undefined, + params.actorId ? eq(activityLog.actorId, params.actorId) : undefined, + params.entityType ? eq(activityLog.entityType, params.entityType) : undefined, + params.entityId ? eq(activityLog.entityId, params.entityId) : undefined, + decisionFilter ? authorizationAuditDecisionCondition(decisionFilter) : undefined, + ].filter((condition): condition is NonNullable => Boolean(condition)); + const rows = await db + .select() + .from(activityLog) + .where(and(...conditions)) + .orderBy(desc(activityLog.createdAt)) + .limit(limit) + .offset(offset); + return rows.map((row) => ({ + ...row, + details: row.details && typeof row.details === "object" + ? sanitizeRecord(row.details) + : row.details ?? null, + })); + }, + }, + agentSessions: { async create(params) { const companyId = ensureCompanyId(params.companyId); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 6e1d7689be2..81f21a91f85 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -19,6 +19,7 @@ */ import { fork, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; @@ -39,9 +40,12 @@ import { } from "@paperclipai/plugin-sdk"; import type { JsonRpcId, + PluginInvocationContext, + PluginInvocationScope, JsonRpcResponse, JsonRpcRequest, JsonRpcNotification, + WorkerHostCallContext, HostToWorkerMethodName, HostToWorkerMethods, WorkerToHostMethodName, @@ -108,6 +112,7 @@ export type WorkerStatus = */ export type WorkerToHostHandler = ( params: WorkerToHostMethods[M][0], + context?: WorkerHostCallContext, ) => Promise; /** @@ -201,6 +206,11 @@ interface PendingRequest { sentAt: number; } +interface ActiveInvocation { + scope: PluginInvocationScope; + timer?: ReturnType; +} + // --------------------------------------------------------------------------- // PluginWorkerHandle — manages a single worker process // --------------------------------------------------------------------------- @@ -379,6 +389,7 @@ export function createPluginWorkerHandle( // Pending RPC requests awaiting a response const pendingRequests = new Map(); let nextRequestId = 1; + const activeInvocations = new Map(); // Optional methods reported by the worker during initialization let supportedMethods: string[] = []; @@ -475,13 +486,78 @@ export function createPluginWorkerHandle( pending.resolve(response); } + function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function deriveInvocationScope( + method: HostToWorkerMethodName | string, + params: unknown, + ): PluginInvocationScope | null { + if (!isRecord(params)) return null; + + const directCompanyId = readNonEmptyString(params.companyId); + if (directCompanyId) return { companyId: directCompanyId }; + + if (method === "executeTool" && isRecord(params.runContext)) { + const companyId = readNonEmptyString(params.runContext.companyId); + return companyId ? { companyId } : null; + } + + if (method === "onEvent" && isRecord(params.event)) { + const companyId = readNonEmptyString(params.event.companyId); + return companyId ? { companyId } : null; + } + + return null; + } + + function registerInvocation(scope: PluginInvocationScope, ttlMs?: number): PluginInvocationContext { + const invocation: PluginInvocationContext = { + id: randomUUID(), + scope, + }; + const entry: ActiveInvocation = { scope }; + if (ttlMs !== undefined) { + entry.timer = setTimeout(() => { + activeInvocations.delete(invocation.id); + }, ttlMs); + if (entry.timer.unref) entry.timer.unref(); + } + activeInvocations.set(invocation.id, entry); + return invocation; + } + + function clearInvocation(invocation: PluginInvocationContext | null): void { + if (!invocation) return; + const entry = activeInvocations.get(invocation.id); + if (entry?.timer) clearTimeout(entry.timer); + activeInvocations.delete(invocation.id); + } + + function contextForWorkerMessage(message: JsonRpcRequest | JsonRpcNotification): WorkerHostCallContext { + const invocationId = readNonEmptyString( + (message as { paperclipInvocationId?: unknown }).paperclipInvocationId, + ); + if (!invocationId) { + return activeInvocations.size > 0 ? { invalidInvocationScope: true } : {}; + } + const entry = activeInvocations.get(invocationId); + if (!entry) return { invalidInvocationScope: true }; + return { invocationScope: entry.scope }; + } + /** * Handle a JSON-RPC request from the worker (worker→host call). */ async function handleWorkerRequest(request: JsonRpcRequest): Promise { const method = request.method as WorkerToHostMethodName; const handler = options.hostHandlers[method] as - | ((params: unknown) => Promise) + | ((params: unknown, context?: WorkerHostCallContext) => Promise) | undefined; if (!handler) { @@ -501,7 +577,7 @@ export function createPluginWorkerHandle( } try { - const result = await handler(request.params); + const result = await handler(request.params, contextForWorkerMessage(request)); sendMessage({ jsonrpc: JSONRPC_VERSION, id: request.id, @@ -509,12 +585,15 @@ export function createPluginWorkerHandle( }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); + const errorCode = typeof (err as { code?: unknown }).code === "number" + ? (err as { code: number }).code + : JSONRPC_ERROR_CODES.INTERNAL_ERROR; log.error({ method, err: errorMessage }, "host handler error"); try { sendMessage( createErrorResponse( request.id, - JSONRPC_ERROR_CODES.INTERNAL_ERROR, + errorCode, errorMessage, ), ); @@ -572,12 +651,28 @@ export function createPluginWorkerHandle( notification.method === "streams.close" ) { const params = (notification.params ?? {}) as Record; + const companyId = String(params.companyId ?? ""); + const context = contextForWorkerMessage(notification); + if (context.invalidInvocationScope) { + log.warn( + { method: notification.method, companyId }, + "dropping plugin stream notification with invalid invocation scope", + ); + return; + } + const allowedCompanyId = context.invocationScope?.companyId; + if (allowedCompanyId && companyId !== allowedCompanyId) { + log.warn( + { method: notification.method, companyId, allowedCompanyId }, + "dropping plugin stream notification outside invocation company scope", + ); + return; + } // Track open channels so we can emit synthetic close on crash if (notification.method === "streams.open") { const ch = String(params.channel ?? ""); - const co = String(params.companyId ?? ""); - if (ch) openStreamChannels.set(ch, co); + if (ch) openStreamChannels.set(ch, companyId); } else if (notification.method === "streams.close") { openStreamChannels.delete(String(params.channel ?? "")); } @@ -760,6 +855,10 @@ export function createPluginWorkerHandle( ); } pendingRequests.clear(); + for (const invocation of activeInvocations.values()) { + if (invocation.timer) clearTimeout(invocation.timer); + } + activeInvocations.clear(); } // ----------------------------------------------------------------------- @@ -1020,6 +1119,8 @@ export function createPluginWorkerHandle( const id = nextRequestId++; const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS); + const invocationScope = deriveInvocationScope(method, params); + const invocation = invocationScope ? registerInvocation(invocationScope) : null; // Guard against double-settlement. When a process exits all pending // requests are rejected via rejectAllPending(), but the timeout timer @@ -1032,6 +1133,7 @@ export function createPluginWorkerHandle( settled = true; clearTimeout(timer); pendingRequests.delete(id); + clearInvocation(invocation); fn(value); }; @@ -1064,11 +1166,15 @@ export function createPluginWorkerHandle( pendingRequests.set(id, pending); try { - const request = createRequest(method, params, id); + const request = { + ...createRequest(method, params, id), + ...(invocation ? { paperclipInvocation: invocation } : {}), + }; sendMessage(request); } catch (err) { clearTimeout(timer); pendingRequests.delete(id); + clearInvocation(invocation); reject( new Error( `Failed to send "${method}" to worker: ${ @@ -1135,13 +1241,17 @@ export function createPluginWorkerHandle( notify(method: string, params: unknown) { if (status !== "running") return; + const invocationScope = deriveInvocationScope(method, params); + const invocation = invocationScope ? registerInvocation(invocationScope, MAX_RPC_TIMEOUT_MS) : null; try { sendMessage({ jsonrpc: JSONRPC_VERSION, method, params, + ...(invocation ? { paperclipInvocation: invocation } : {}), }); } catch { + clearInvocation(invocation); log.warn({ method }, "failed to send notification to worker"); } }, diff --git a/server/src/services/principal-access-compatibility.ts b/server/src/services/principal-access-compatibility.ts new file mode 100644 index 00000000000..cb1e86be82f --- /dev/null +++ b/server/src/services/principal-access-compatibility.ts @@ -0,0 +1,141 @@ +import { and, eq, notInArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; +import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; +import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js"; + +type GrantInput = { + permissionKey: PermissionKey; + scope?: Record | null; +}; + +export type PrincipalAccessCompatibilityBackfillStats = { + agentMembershipsInserted: number; + humanGrantsInserted: number; +}; + +export async function insertMissingPrincipalGrants( + db: Db, + input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + grants: GrantInput[]; + grantedByUserId: string | null; + }, +): Promise { + if (input.grants.length === 0) return 0; + + const now = new Date(); + const inserted = await db + .insert(principalPermissionGrants) + .values( + input.grants.map((grant) => ({ + companyId: input.companyId, + principalType: input.principalType, + principalId: input.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId: input.grantedByUserId, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [ + principalPermissionGrants.companyId, + principalPermissionGrants.principalType, + principalPermissionGrants.principalId, + principalPermissionGrants.permissionKey, + ], + }) + .returning({ id: principalPermissionGrants.id }); + + return inserted.length; +} + +export async function ensureHumanRoleDefaultGrants( + db: Db, + input: { + companyId: string; + principalId: string; + membershipRole: string | null | undefined; + grantedByUserId: string | null; + }, +): Promise { + const role = normalizeHumanRole(input.membershipRole, "operator"); + return insertMissingPrincipalGrants(db, { + companyId: input.companyId, + principalType: "user", + principalId: input.principalId, + grants: grantsForHumanRole(role), + grantedByUserId: input.grantedByUserId, + }); +} + +export async function backfillPrincipalAccessCompatibility( + db: Db, +): Promise { + const now = new Date(); + const nonTerminalAgents = await db + .select({ + companyId: agents.companyId, + principalId: agents.id, + }) + .from(agents) + .where(notInArray(agents.status, ["pending_approval", "terminated"])); + + const agentMembershipsInserted = nonTerminalAgents.length > 0 + ? await db + .insert(companyMemberships) + .values( + nonTerminalAgents.map((agent) => ({ + companyId: agent.companyId, + principalType: "agent", + principalId: agent.principalId, + status: "active", + membershipRole: "member", + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [ + companyMemberships.companyId, + companyMemberships.principalType, + companyMemberships.principalId, + ], + }) + .returning({ id: companyMemberships.id }) + .then((rows) => rows.length) + : 0; + + const activeHumanMemberships = await db + .select({ + companyId: companyMemberships.companyId, + principalId: companyMemberships.principalId, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ); + + let humanGrantsInserted = 0; + for (const membership of activeHumanMemberships) { + humanGrantsInserted += await ensureHumanRoleDefaultGrants(db, { + companyId: membership.companyId, + principalId: membership.principalId, + membershipRole: membership.membershipRole, + grantedByUserId: null, + }); + } + + return { + agentMembershipsInserted, + humanGrantsInserted, + }; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0da91b9a183..abc17fc2487 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -30,7 +30,8 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; -import { CompanyAccess } from "./pages/CompanyAccess"; +import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; +import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { Secrets } from "./pages/Secrets"; @@ -69,11 +70,13 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 429d0812237..6514cd4a99b 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -10,6 +10,7 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockSidebarBadgesApi = vi.hoisted(() => ({ get: vi.fn(), })); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ @@ -61,6 +62,10 @@ vi.mock("@/api/sidebarBadges", () => ({ sidebarBadgesApi: mockSidebarBadgesApi, })); +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -83,6 +88,11 @@ describe("CompanySettingsSidebar", () => { failedRuns: 0, joinRequests: 2, }); + mockUsePluginSlots.mockReturnValue({ + slots: [], + isLoading: false, + errorMessage: null, + }); }); afterEach(() => { @@ -110,7 +120,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); - expect(container.textContent).toContain("Access"); + expect(container.textContent).toContain("Members"); expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Secrets"); expect(sidebarNavItemMock).toHaveBeenCalledWith( @@ -129,8 +139,8 @@ describe("CompanySettingsSidebar", () => { ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ - to: "/company/settings/access", - label: "Access", + to: "/company/settings/members", + label: "Members", badge: 2, end: true, }), @@ -154,4 +164,50 @@ describe("CompanySettingsSidebar", () => { root.unmount(); }); }); + + it("renders company settings pages contributed by ready plugins", async () => { + mockUsePluginSlots.mockReturnValue({ + slots: [ + { + type: "companySettingsPage", + id: "permissions", + displayName: "Permissions", + exportName: "PermissionsPage", + routePath: "permissions", + pluginId: "plugin-1", + pluginKey: "permissions-extension", + pluginDisplayName: "Permissions Extension", + pluginVersion: "0.1.0", + }, + ], + isLoading: false, + errorMessage: null, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Permissions"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/permissions", + label: "Permissions", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index fb8870c999c..ed84e9baed3 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,16 +1,22 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; +import { usePluginSlots } from "@/plugins/slots"; import { SidebarNavItem } from "./SidebarNavItem"; export function CompanySettingsSidebar() { const { selectedCompany, selectedCompanyId } = useCompany(); const { isMobile, setSidebarOpen } = useSidebar(); + const { slots: companySettingsPluginSlots } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: selectedCompanyId, + enabled: !!selectedCompanyId, + }); const { data: badges } = useQuery({ queryKey: selectedCompanyId ? queryKeys.sidebarBadges(selectedCompanyId) @@ -61,12 +67,23 @@ export function CompanySettingsSidebar() { end /> + {companySettingsPluginSlots + .filter((slot) => slot.routePath) + .map((slot) => ( + + ))} diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 4dad91b1a89..27f93a0b8ed 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -3,10 +3,14 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; +import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared"; import { IssueBlockedNotice } from "./IssueBlockedNotice"; +import { ToastProvider } from "../context/ToastContext"; + +const retryNowMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( @@ -14,11 +18,57 @@ vi.mock("@/lib/router", () => ({ ), })); +vi.mock("../api/issues", () => ({ + issuesApi: { + retryScheduledRetryNow: retryNowMock, + }, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; let root: ReturnType | null = null; let container: HTMLDivElement | null = null; +let dateNowSpy: ReturnType | null = null; + +const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime(); + +const baseRetry: IssueScheduledRetry = { + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "agent-1", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: "2026-04-19T20:00:00.000Z", + scheduledRetryAttempt: 1, + scheduledRetryReason: "max_turns_continuation", + retryExhaustedReason: null, + error: null, + errorCode: null, +}; + +function buildRetryResponse(outcome: IssueRetryNowOutcome) { + return { + outcome, + message: + outcome === "promoted" + ? "Promoted scheduled retry" + : outcome === "already_promoted" + ? "Scheduled retry already promoted" + : outcome === "no_scheduled_retry" + ? "No scheduled retry" + : "Promotion suppressed by gate", + scheduledRetry: + outcome === "promoted" || outcome === "already_promoted" + ? { ...baseRetry, status: "queued" as const } + : null, + }; +} + +beforeEach(() => { + dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW); + retryNowMock.mockReset(); +}); afterEach(() => { if (root) { @@ -27,13 +77,22 @@ afterEach(() => { root = null; container?.remove(); container = null; + dateNowSpy?.mockRestore(); + dateNowSpy = null; }); function withProviders(node: ReactNode) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } }); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }); return ( - {node} + + {node} + ); } @@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => { expect(node.textContent).toContain("This issue still needs a next step."); expect(node.textContent).toContain("Corrective wake queued for CodexCoder"); expect(node.textContent).toContain("Detected progress: Updated the plan"); + expect(node.textContent).not.toContain("Retry now"); expect(node.textContent).not.toContain("Work on this issue is blocked until"); expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull(); }); + it("shows retry-now action for next-step notices with a scheduled retry", async () => { + retryNowMock.mockResolvedValue(buildRetryResponse("promoted")); + const node = render( + , + ); + + expect(node.textContent).toContain("Corrective wake scheduled in 1d"); + const button = node.querySelector('[data-testid="issue-next-step-retry-now"]'); + expect(button).not.toBeNull(); + expect(button!.textContent ?? "").toContain("Retry now"); + + await act(async () => { + button!.click(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(retryNowMock).toHaveBeenCalledWith("issue-1"); + expect(button!.textContent ?? "").toContain("Promoted"); + expect(button!.disabled).toBe(true); + }); + }); + it("does not render when the issue is done even if a stale handoff state is required", () => { const node = render( +
+
+ Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately. +
+ +
+ { + retryNow.reset(); + retryNow.mutate(); + }} + /> + + ); +} + export function IssueBlockedNotice({ + issueId, issueStatus, blockers, blockerAttention, successfulRunHandoff, + scheduledRetry, agentName, }: { + issueId?: string | null; issueStatus?: string; blockers: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; + scheduledRetry?: IssueScheduledRetry | null; agentName?: string | null; }) { if (issueStatus === "done" || issueStatus === "cancelled") return null; const showSuccessfulRunHandoff = successfulRunHandoff?.required === true; if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null; + const successfulRunRetryNow = showSuccessfulRunHandoff + && issueId + && scheduledRetry?.status === "scheduled_retry" + ? { issueId, scheduledRetry } + : null; const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues"; const terminalBlockers = blockers @@ -162,6 +241,12 @@ export function IssueBlockedNotice({ Detected progress: {successfulRunHandoff.detectedProgressSummary}

) : null} + {successfulRunRetryNow ? ( + + ) : null} ) : null} {showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? ( diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index b25eb39fd40..a08c58e57aa 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -38,6 +38,7 @@ import type { IssueBlockerAttention, IssueRecoveryAction, IssueRelationIssueSummary, + IssueScheduledRetry, SuccessfulRunHandoffState, IssueWorkMode, } from "@paperclipai/shared"; @@ -296,9 +297,11 @@ interface IssueChatThreadProps { timelineEvents?: IssueTimelineEvent[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; + issueId?: string | null; blockedBy?: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; + scheduledRetry?: IssueScheduledRetry | null; recoveryAction?: IssueRecoveryAction | null; onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void; canFalsePositiveRecoveryAction?: boolean; @@ -3617,9 +3620,11 @@ export function IssueChatThread({ timelineEvents = [], liveRuns = [], activeRun = null, + issueId = null, blockedBy = [], blockerAttention = null, successfulRunHandoff = null, + scheduledRetry = null, recoveryAction = null, onResolveRecoveryAction, canFalsePositiveRecoveryAction = false, @@ -4299,10 +4304,12 @@ export function IssueChatThread({ /> ) : null} ({ + Link: ({ + children, + to, + ...props + }: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => ( + {children} + ), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + get: vi.fn(), + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) { + flushSync(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; +}); + +function renderMarkdown(children: string) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + flushSync(() => { + root?.render( + + + {children} + + , + ); + }); + + return container; +} + +function click(element: Element | null) { + if (!element) throw new Error("Expected element to exist"); + flushSync(() => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("MarkdownBody code block interactions", () => { + it("toggles line wrapping for indented preformatted markdown blocks", () => { + const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox"); + const pre = node.querySelector("pre"); + const wrapButton = node.querySelector(".paperclip-markdown-codeblock-wrap"); + + expect(pre?.style.whiteSpace).toBe(""); + expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines"); + + click(wrapButton); + + expect(pre?.style.whiteSpace).toBe("pre-wrap"); + expect(pre?.style.overflowWrap).toBe("anywhere"); + expect(wrapButton?.getAttribute("aria-pressed")).toBe("true"); + expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines"); + + click(wrapButton); + + expect(pre?.style.whiteSpace).toBe(""); + expect(wrapButton?.getAttribute("aria-pressed")).toBe("false"); + expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines"); + }); +}); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 75675a0f695..41ec495a52c 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,6 @@ import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Check, Copy, ExternalLink, Github } from "lucide-react"; +import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; @@ -364,6 +364,7 @@ function CodeBlock({ }) { const [copied, setCopied] = useState(false); const [failed, setFailed] = useState(false); + const [wrapLines, setWrapLines] = useState(false); const preRef = useRef(null); const timerRef = useRef>(undefined); @@ -401,33 +402,57 @@ function CodeBlock({ }, 1500); }, [children]); - const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy"; + const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy"; + const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines"; return ( -
+
         {children}
       
- +
+ + +
); } diff --git a/ui/src/components/access/CompanySettingsNav.test.tsx b/ui/src/components/access/CompanySettingsNav.test.tsx index be3daa819c3..7d795ee825a 100644 --- a/ui/src/components/access/CompanySettingsNav.test.tsx +++ b/ui/src/components/access/CompanySettingsNav.test.tsx @@ -60,27 +60,29 @@ describe("CompanySettingsNav", () => { expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general"); expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments"); expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments"); - expect(getCompanySettingsTab("/company/settings/access")).toBe("access"); - expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access"); + expect(getCompanySettingsTab("/company/settings/members")).toBe("members"); + expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members"); + expect(getCompanySettingsTab("/company/settings/access")).toBe("members"); + expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members"); expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites"); }); it("renders the active tab and navigates when a different tab is selected", async () => { - currentPathname = "/PAP/company/settings/access"; + currentPathname = "/PAP/company/settings/members"; const root = createRoot(container); await act(async () => { root.render(); }); - expect(container.textContent).toContain("access"); + expect(container.textContent).toContain("members"); expect(pageTabBarMock).toHaveBeenCalledWith( expect.objectContaining({ - value: "access", + value: "members", items: [ { value: "general", label: "General" }, { value: "environments", label: "Environments" }, - { value: "access", label: "Access" }, + { value: "members", label: "Members" }, { value: "invites", label: "Invites" }, ], }), diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index 82fb224551e..ded7a63def8 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router"; const items = [ { value: "general", label: "General", href: "/company/settings" }, { value: "environments", label: "Environments", href: "/company/settings/environments" }, - { value: "access", label: "Access", href: "/company/settings/access" }, + { value: "members", label: "Members", href: "/company/settings/members" }, { value: "invites", label: "Invites", href: "/company/settings/invites" }, ] as const; @@ -16,8 +16,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab { return "environments"; } - if (pathname.includes("/company/settings/access")) { - return "access"; + if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) { + return "members"; } if (pathname.includes("/company/settings/invites")) { diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 74f94256d8e..6c001e2fae4 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings"; import { heartbeatsApi } from "../../api/heartbeats"; import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters"; import { queryKeys } from "../../lib/queryKeys"; +import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url"; const LOG_POLL_INTERVAL_MS = 2000; const LOG_READ_LIMIT_BYTES = 256_000; @@ -279,8 +280,9 @@ export function useLiveRunTranscripts({ const connect = () => { if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; + const url = buildSameOriginWebSocketUrl( + `/api/companies/${encodeURIComponent(companyId)}/events/ws`, + ); socket = new WebSocket(url); socket.onmessage = (message) => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 783537e0708..376a92ad5db 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -14,6 +14,7 @@ import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-iss import { queryKeys } from "../lib/queryKeys"; import { toCompanyRelativePath } from "../lib/company-routes"; import { useLocation } from "../lib/router"; +import { buildSameOriginWebSocketUrl } from "../lib/websocket-url"; const TOAST_COOLDOWN_WINDOW_MS = 10_000; const TOAST_COOLDOWN_MAX = 3; @@ -979,8 +980,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const connect = () => { if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`; + const url = buildSameOriginWebSocketUrl( + `/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`, + ); const nextSocket = new WebSocket(url); socket = nextSocket; diff --git a/ui/src/index.css b/ui/src/index.css index a29d4b67bc8..82128ed9a02 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -717,15 +717,23 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { background: none; } -/* Copy-to-clipboard button on fenced code blocks */ +/* Actions for fenced and indented preformatted markdown blocks */ .paperclip-markdown-codeblock { position: relative; } -.paperclip-markdown-codeblock-copy { +.paperclip-markdown-codeblock-actions { position: absolute; top: 0.4rem; right: 0.4rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + opacity: 0; + transition: opacity 0.12s ease; +} + +.paperclip-markdown-codeblock-action { display: inline-flex; align-items: center; gap: 0.25rem; @@ -737,30 +745,31 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { font-size: 0.7rem; line-height: 1; cursor: pointer; - opacity: 0; - transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease; + transition: background-color 0.12s ease, color 0.12s ease; } -.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy, -.paperclip-markdown-codeblock-copy:focus-visible, -.paperclip-markdown-codeblock-copy[data-copied] { +.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions, +.paperclip-markdown-codeblock-actions:focus-within, +.paperclip-markdown-codeblock-action[data-copied], +.paperclip-markdown-codeblock-action[data-active] { opacity: 1; } -.paperclip-markdown-codeblock-copy:hover { +.paperclip-markdown-codeblock-action:hover { background-color: var(--accent); color: var(--accent-foreground); } -.paperclip-markdown-codeblock-copy[data-copied] { +.paperclip-markdown-codeblock-action[data-active], +.paperclip-markdown-codeblock-action[data-copied] { color: var(--primary); } -.paperclip-markdown-codeblock-copy[data-failed] { +.paperclip-markdown-codeblock-action[data-failed] { color: var(--destructive); } -.paperclip-markdown-codeblock-copy-label { +.paperclip-markdown-codeblock-action-label { font-weight: 500; } diff --git a/ui/src/lib/websocket-url.test.ts b/ui/src/lib/websocket-url.test.ts new file mode 100644 index 00000000000..5971bd8cc28 --- /dev/null +++ b/ui/src/lib/websocket-url.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { browserReachableHost, buildSameOriginWebSocketUrl } from "./websocket-url"; + +describe("browserReachableHost", () => { + it("keeps concrete browser hosts unchanged", () => { + expect(browserReachableHost({ + protocol: "http:", + hostname: "paperclip-dev", + host: "paperclip-dev:46259", + port: "46259", + })).toBe("paperclip-dev:46259"); + }); + + it("rewrites wildcard IPv4 bind hosts to localhost", () => { + expect(browserReachableHost({ + protocol: "http:", + hostname: "0.0.0.0", + host: "0.0.0.0:46259", + port: "46259", + })).toBe("localhost:46259"); + }); + + it("rewrites wildcard IPv6 bind hosts to localhost", () => { + expect(browserReachableHost({ + protocol: "http:", + hostname: "::", + host: "[::]:46259", + port: "46259", + })).toBe("localhost:46259"); + }); +}); + +describe("buildSameOriginWebSocketUrl", () => { + it("uses wss for https pages", () => { + expect(buildSameOriginWebSocketUrl("/api/events/ws", { + protocol: "https:", + hostname: "example.com", + host: "example.com", + port: "", + })).toBe("wss://example.com/api/events/ws"); + }); + + it("does not emit 0.0.0.0 websocket URLs", () => { + expect(buildSameOriginWebSocketUrl("api/events/ws", { + protocol: "http:", + hostname: "0.0.0.0", + host: "0.0.0.0:46259", + port: "46259", + })).toBe("ws://localhost:46259/api/events/ws"); + }); +}); diff --git a/ui/src/lib/websocket-url.ts b/ui/src/lib/websocket-url.ts new file mode 100644 index 00000000000..a82ffe6db77 --- /dev/null +++ b/ui/src/lib/websocket-url.ts @@ -0,0 +1,20 @@ +type BrowserLocationLike = Pick; + +function isWildcardHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]"; +} + +export function browserReachableHost(location: BrowserLocationLike = window.location): string { + if (!isWildcardHost(location.hostname)) return location.host; + return location.port ? `localhost:${location.port}` : "localhost"; +} + +export function buildSameOriginWebSocketUrl( + path: string, + location: BrowserLocationLike = window.location, +): string { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${protocol}://${browserReachableHost(location)}${normalizedPath}`; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index a803b8f730d..81ab1cf864e 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom"; import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout"; import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge"; import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold"; +import { buildSameOriginWebSocketUrl } from "../lib/websocket-url"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { cn } from "../lib/utils"; import { describeRunRetryState } from "../lib/runRetryState"; @@ -1713,7 +1714,9 @@ function ConfigurationTab({ ? "Enabled automatically while this agent can create new agents." : taskAssignSource === "explicit_grant" ? "Enabled via explicit company permission grant." - : "Disabled unless explicitly granted."; + : taskAssignSource === "simple_default" + ? "Enabled by simple company-wide task assignment defaults." + : "Disabled unless explicitly granted."; return (
@@ -3863,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const connect = () => { if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`; + const url = buildSameOriginWebSocketUrl( + `/api/companies/${encodeURIComponent(run.companyId)}/events/ws`, + ); socket = new WebSocket(url); socket.onopen = () => { diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx index eefb7d73054..73f9e3f4fea 100644 --- a/ui/src/pages/CompanyAccess.test.tsx +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -4,23 +4,25 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CompanyAccess } from "./CompanyAccess"; +import { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess"; const listMembersMock = vi.hoisted(() => vi.fn()); const listJoinRequestsMock = vi.hoisted(() => vi.fn()); -const updateMemberAccessMock = vi.hoisted(() => vi.fn()); +const updateMemberMock = vi.hoisted(() => vi.fn()); const archiveMemberMock = vi.hoisted(() => vi.fn()); const listAgentsMock = vi.hoisted(() => vi.fn()); const listIssuesMock = vi.hoisted(() => vi.fn()); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); vi.mock("@/api/access", () => ({ accessApi: { listMembers: (companyId: string) => listMembersMock(companyId), listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status), - updateMember: vi.fn(), + updateMember: (companyId: string, memberId: string, input: unknown) => + updateMemberMock(companyId, memberId, input), updateMemberPermissions: vi.fn(), - updateMemberAccess: (companyId: string, memberId: string, input: unknown) => - updateMemberAccessMock(companyId, memberId, input), + updateMemberAccess: vi.fn(), archiveMember: (companyId: string, memberId: string, input: unknown) => archiveMemberMock(companyId, memberId, input), approveJoinRequest: vi.fn(), @@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({ }, })); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => { + mockNavigate(to, replace); + return
{to}
; + }, +})); + +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, +})); + vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -146,7 +160,7 @@ describe("CompanyAccess", () => { }, }, ]); - updateMemberAccessMock.mockResolvedValue({}); + updateMemberMock.mockResolvedValue({}); archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 }); listAgentsMock.mockResolvedValue([ { @@ -164,6 +178,11 @@ describe("CompanyAccess", () => { status: "todo", }, ]); + mockUsePluginSlots.mockReturnValue({ + slots: [], + isLoading: false, + errorMessage: null, + }); }); afterEach(() => { @@ -172,7 +191,7 @@ describe("CompanyAccess", () => { vi.clearAllMocks(); }); - it("keeps the page human-focused and explains implicit versus explicit grants", async () => { + it("keeps the page human-focused and hides advanced permission controls", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -188,10 +207,15 @@ describe("CompanyAccess", () => { await flushReact(); await flushReact(); - expect(container.textContent).toContain("Manage company user memberships"); + expect(container.textContent).toContain("Manage the people who can work in Paperclip"); + expect(container.textContent).toContain("Members can collaborate across the company by default"); + expect(container.textContent).toContain("Core keeps this page focused on membership"); expect(container.textContent).toContain("Humans"); expect(container.textContent).toContain("Pending human joins"); expect(container.textContent).toContain("User account"); + expect(container.textContent).not.toContain("Grants"); + expect(container.textContent).not.toContain("explicit grants"); + expect(container.textContent).not.toContain("Assign scoped tasks"); expect(container.textContent).not.toContain("Agents"); expect(container.textContent).not.toContain("Pending agent joins"); expect(container.textContent).not.toContain("Open join request queue"); @@ -210,18 +234,16 @@ describe("CompanyAccess", () => { }); await flushReact(); - expect(document.body.textContent).toContain("Implicit grants from role"); - expect(document.body.textContent).toContain("Owner currently includes these permissions automatically."); - expect(document.body.textContent).toContain( - "Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.", - ); + expect(document.body.textContent).toContain("Update company role and membership status"); + expect(document.body.textContent).not.toContain("Implicit grants from role"); + expect(document.body.textContent).not.toContain("permissionKey"); await act(async () => { root.unmount(); }); }); - it("saves member role, status, and grants in one request", async () => { + it("saves member role and status without touching grants", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -248,7 +270,7 @@ describe("CompanyAccess", () => { await flushReact(); const saveButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent === "Save access", + (button) => button.textContent === "Save member", ); expect(saveButton).toBeTruthy(); @@ -257,10 +279,9 @@ describe("CompanyAccess", () => { }); await flushReact(); - expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", { + expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", { membershipRole: "owner", status: "active", - grants: [], }); await act(async () => { @@ -382,4 +403,65 @@ describe("CompanyAccess", () => { root.unmount(); }); }); + + it("redirects legacy access deep links to the permissions extension route when installed", async () => { + mockUsePluginSlots.mockReturnValue({ + slots: [ + { + type: "companySettingsPage", + id: "permissions", + displayName: "Permissions", + routePath: "permissions", + pluginKey: "permissions-extension", + }, + ], + isLoading: false, + errorMessage: null, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockNavigate).toHaveBeenCalledWith("/company/settings/permissions", true); + expect(container.textContent).toContain("/company/settings/permissions"); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows a read-only unavailable fallback for legacy access deep links", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Advanced Permissions"); + expect(container.textContent).toContain("Advanced permissions unavailable"); + expect(container.textContent).toContain("Open Members"); + expect(container.textContent).toContain("Open Invites"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 3f31fe1ef5b..9c43d5ae70a 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, - PERMISSION_KEYS, type Agent, - type PermissionKey, } from "@paperclipai/shared"; -import { ShieldCheck, Trash2, Users } from "lucide-react"; +import { Shield, ShieldCheck, Trash2, Users } from "lucide-react"; import { accessApi, type CompanyMember } from "@/api/access"; import { agentsApi } from "@/api/agents"; import { ApiError } from "@/api/client"; import { issuesApi } from "@/api/issues"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -25,38 +22,13 @@ import { Badge } from "@/components/ui/badge"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; +import { Link, Navigate } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; - -const permissionLabels: Record = { - "agents:create": "Create agents", - "users:invite": "Invite humans and agents", - "users:manage_permissions": "Manage members and grants", - "tasks:assign": "Assign tasks", - "tasks:assign_scope": "Assign scoped tasks", - "tasks:manage_active_checkouts": "Manage active task checkouts", - "joins:approve": "Approve join requests", - "environments:manage": "Manage environments", -}; - -function formatGrantSummary(member: CompanyMember) { - if (member.grants.length === 0) return "No explicit grants"; - return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", "); -} - -const implicitRoleGrantMap: Record, PermissionKey[]> = { - owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"], - admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"], - operator: ["tasks:assign"], - viewer: [], -}; +import { usePluginSlots } from "@/plugins/slots"; const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out"; type EditableMemberStatus = "pending" | "active" | "suspended"; -function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) { - return role ? implicitRoleGrantMap[role] : []; -} - export function CompanyAccess() { const { selectedCompany, selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -67,13 +39,12 @@ export function CompanyAccess() { const [reassignmentTarget, setReassignmentTarget] = useState("__unassigned"); const [draftRole, setDraftRole] = useState(null); const [draftStatus, setDraftStatus] = useState("active"); - const [draftGrants, setDraftGrants] = useState>(new Set()); useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, { label: "Settings", href: "/company/settings" }, - { label: "Access" }, + { label: "Members" }, ]); }, [selectedCompany?.name, setBreadcrumbs]); @@ -103,11 +74,10 @@ export function CompanyAccess() { }; const updateMemberMutation = useMutation({ - mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => { - return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, { + mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => { + return accessApi.updateMember(selectedCompanyId!, input.memberId, { membershipRole: input.membershipRole, status: input.status, - grants: input.grants.map((permissionKey) => ({ permissionKey })), }); }, onSuccess: async () => { @@ -223,7 +193,6 @@ export function CompanyAccess() { if (!editingMember) return; setDraftRole(editingMember.membershipRole); setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended"); - setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey))); }, [editingMember]); useEffect(() => { @@ -255,8 +224,6 @@ export function CompanyAccess() { joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? []; const joinRequestActionPending = approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending; - const implicitGrantKeys = getImplicitGrantKeys(draftRole); - const implicitGrantSet = new Set(implicitGrantKeys); const activeReassignmentUsers = members.filter( (member) => member.status === "active" && @@ -271,11 +238,14 @@ export function CompanyAccess() {
-

Company Access

+

Company Members

- Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}. + Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.

+
+ Core keeps this page focused on membership, invite approvals, and safe member removal. +
{access && !access.currentUserRole && ( @@ -291,7 +261,7 @@ export function CompanyAccess() {

Humans

- Manage human company memberships, status, and grants here. + Manage human company memberships and status here.

@@ -340,11 +310,10 @@ export function CompanyAccess() { ) : null}
-
+
User account
Role
Status
-
Grants
Action
{members.length === 0 ? ( @@ -356,7 +325,7 @@ export function CompanyAccess() { return (
{member.user?.name?.trim() || member.user?.email || member.principalId}
@@ -372,7 +341,6 @@ export function CompanyAccess() { {member.status.replace("_", " ")}
-
{formatGrantSummary(member)}
- -
-
-

Grants

-

- Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes. -

-
-
-
Implicit grants from role
-

- {draftRole - ? `${HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole]} currently includes these permissions automatically.` - : "No role is selected, so this member has no implicit grants right now."} -

- {implicitGrantKeys.length > 0 ? ( -
- {implicitGrantKeys.map((permissionKey) => ( - - {permissionLabels[permissionKey]} - - ))} -
- ) : null} -
-
- {PERMISSION_KEYS.map((permissionKey) => ( - - ))} -
-
)} @@ -516,12 +424,11 @@ export function CompanyAccess() { memberId: editingMember.id, membershipRole: draftRole, status: draftStatus, - grants: [...draftGrants], }); }} disabled={updateMemberMutation.isPending} > - {updateMemberMutation.isPending ? "Saving…" : "Save access"} + {updateMemberMutation.isPending ? "Saving…" : "Save member"} @@ -616,6 +523,66 @@ export function CompanyAccess() { ); } +export function CompanyAccessLegacyRoute() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { slots, isLoading, errorMessage } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: selectedCompanyId, + enabled: !!selectedCompanyId, + }); + + useEffect(() => { + setBreadcrumbs([ + { label: "Settings", href: "/company/settings" }, + { label: "Access" }, + ]); + }, [setBreadcrumbs]); + + const permissionsSlot = slots.find((slot) => slot.routePath === "permissions"); + if (permissionsSlot) { + return ; + } + + if (isLoading) { + return
Checking for advanced permission extensions...
; + } + + return ( +
+
+
+ +

Advanced Permissions

+
+

+ Advanced access, scoped assignment, and explicit grant controls are provided by installed company settings extensions. +

+
+ +
+
+

Advanced permissions unavailable

+

+ Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires an installed extension. +

+ {errorMessage ? ( +

Plugin extensions unavailable: {errorMessage}

+ ) : null} +
+
+ + +
+
+
+ ); +} + function memberDisplayName(member: CompanyMember | null) { if (!member) return "this member"; return member.user?.name?.trim() || member.user?.email || member.principalId; diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx index 03937030a7d..27961dbc73c 100644 --- a/ui/src/pages/CompanyInvites.test.tsx +++ b/ui/src/pages/CompanyInvites.test.tsx @@ -152,7 +152,8 @@ describe("CompanyInvites", () => { expect(container.textContent).toContain("Choose a role"); expect(container.textContent).toContain("Each invite link is single-use."); expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests."); - expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants."); + expect(container.textContent).toContain("Everything in Admin, plus managing members."); + expect(container.textContent).not.toContain("permission grants"); expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 }); const viewMoreButton = Array.from(container.querySelectorAll("button")).find( diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx index 0d52bf314dc..f20ee636b3a 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/pages/CompanyInvites.tsx @@ -14,8 +14,8 @@ const inviteRoleOptions = [ { value: "viewer", label: "Viewer", - description: "Can view company work and follow along without operational permissions.", - gets: "No built-in grants.", + description: "Can view company work and follow along.", + gets: "View-only company membership.", }, { value: "operator", @@ -32,8 +32,8 @@ const inviteRoleOptions = [ { value: "owner", label: "Owner", - description: "Full company access, including membership and permission management.", - gets: "Everything in Admin, plus managing members and permission grants.", + description: "Full company access, including membership management.", + gets: "Everything in Admin, plus managing members.", }, ] as const; diff --git a/ui/src/pages/CompanySettingsPluginPage.test.tsx b/ui/src/pages/CompanySettingsPluginPage.test.tsx new file mode 100644 index 00000000000..12c5a8bb17a --- /dev/null +++ b/ui/src/pages/CompanySettingsPluginPage.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanySettingsPluginPage } from "./CompanySettingsPluginPage"; + +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); +const mockParams = vi.hoisted(() => ({ + companyPrefix: "PAP" as string | undefined, + settingsRoutePath: "permissions" as string | undefined, +})); + +vi.mock("@/context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ + setBreadcrumbs: mockSetBreadcrumbs, + }), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }], + selectedCompanyId: "company-1", + }), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, + useLocation: () => ({ pathname: "/PAP/company/settings/permissions", search: "", hash: "" }), + useParams: () => mockParams, +})); + +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, + PluginSlotMount: ({ + slot, + context, + }: { + slot: { displayName: string }; + context: { companyId: string | null; companyPrefix: string | null }; + }) => ( +
+ {slot.displayName}:{context.companyId}:{context.companyPrefix} +
+ ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +async function renderPage(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + return root; +} + +describe("CompanySettingsPluginPage", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockParams.companyPrefix = "PAP"; + mockParams.settingsRoutePath = "permissions"; + mockUsePluginSlots.mockReturnValue({ + slots: [ + { + type: "companySettingsPage", + id: "permissions", + displayName: "Permissions", + exportName: "PermissionsPage", + routePath: "permissions", + pluginId: "plugin-1", + pluginKey: "permissions-extension", + pluginDisplayName: "Permissions Extension", + pluginVersion: "0.1.0", + }, + ], + isLoading: false, + errorMessage: null, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("mounts the matching company settings slot with company context", async () => { + const root = await renderPage(container); + + expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe( + "Permissions:company-1:PAP", + ); + expect(mockSetBreadcrumbs).toHaveBeenCalledWith([ + { label: "Settings", href: "/company/settings" }, + { label: "Permissions" }, + ]); + + await act(async () => { + root.unmount(); + }); + }); + + it("fails closed when no ready plugin declares the route", async () => { + mockUsePluginSlots.mockReturnValue({ + slots: [], + isLoading: false, + errorMessage: null, + }); + const root = await renderPage(container); + + expect(container.textContent).toContain("Page not found"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/CompanySettingsPluginPage.tsx b/ui/src/pages/CompanySettingsPluginPage.tsx new file mode 100644 index 00000000000..e4961a369c3 --- /dev/null +++ b/ui/src/pages/CompanySettingsPluginPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useMemo } from "react"; +import { useParams } from "@/lib/router"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useCompany } from "@/context/CompanyContext"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; +import { NotFoundPage } from "./NotFound"; + +export function CompanySettingsPluginPage() { + const params = useParams<{ + companyPrefix?: string; + settingsRoutePath?: string; + }>(); + const { companyPrefix: routeCompanyPrefix, settingsRoutePath } = params; + const { companies, selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + + const routeCompany = useMemo(() => { + if (!routeCompanyPrefix) return null; + const requested = routeCompanyPrefix.toUpperCase(); + return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null; + }, [companies, routeCompanyPrefix]); + const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany; + const resolvedCompanyId = routeCompany?.id ?? (routeCompanyPrefix ? null : selectedCompanyId ?? null); + const companyPrefix = resolvedCompanyId + ? companies.find((company) => company.id === resolvedCompanyId)?.issuePrefix ?? null + : null; + + const { slots, isLoading, errorMessage } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: resolvedCompanyId, + enabled: Boolean(resolvedCompanyId && settingsRoutePath), + }); + + const pageSlots = useMemo(() => { + if (!settingsRoutePath) return []; + return slots.filter((slot) => slot.routePath === settingsRoutePath); + }, [settingsRoutePath, slots]); + + const pageSlot = pageSlots.length === 1 ? pageSlots[0] : null; + + useEffect(() => { + if (!pageSlot) return; + setBreadcrumbs([ + { label: "Settings", href: "/company/settings" }, + { label: pageSlot.displayName }, + ]); + }, [pageSlot, setBreadcrumbs]); + + if (!resolvedCompanyId) { + if (hasInvalidCompanyPrefix) { + return ; + } + return
Select a company to view this page.
; + } + + if (!settingsRoutePath || isLoading) { + return
Loading...
; + } + + if (errorMessage) { + return ( +
+ Plugin extensions unavailable: {errorMessage} +
+ ); + } + + if (pageSlots.length > 1) { + return ( +
+ Multiple plugins declare the company settings route {settingsRoutePath}. Disable one plugin or change its route. +
+ ); + } + + if (!pageSlot) { + return ; + } + + return ( + + ); +} diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index 0fe56c2dc2f..a800bd6f036 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -403,16 +403,16 @@ describe("InviteLandingPage", () => { expect(container.textContent).toContain("Request to join Acme Robotics"); expect(container.textContent).toContain("A company admin must approve your request to join."); expect(container.textContent).toContain( - "Ask them to visit Company Settings → Access to approve your request.", + "Ask them to visit Company Settings → Members to approve your request.", ); expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull(); - expect(container.textContent).not.toContain("http://localhost/company/settings/access"); + expect(container.textContent).not.toContain("http://localhost/company/settings/members"); const approvalLinks = Array.from(container.querySelectorAll("a")).filter( - (link) => link.textContent === "Company Settings → Access", + (link) => link.textContent === "Company Settings → Members", ); expect(approvalLinks).toHaveLength(2); - const expectedApprovalUrl = `${window.location.origin}/company/settings/access`; + const expectedApprovalUrl = `${window.location.origin}/company/settings/members`; for (const link of approvalLinks) { expect(link.getAttribute("href")).toBe(expectedApprovalUrl); } @@ -471,7 +471,7 @@ describe("InviteLandingPage", () => { expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull(); expect(container.textContent).toContain("Your request is still awaiting approval."); expect(container.textContent).toContain( - "Ask them to visit Company Settings → Access to approve your request.", + "Ask them to visit Company Settings → Members to approve your request.", ); await act(async () => { diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 7dcef629b3f..cb394505900 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({ claimApiKeyPath = null, onboardingTextUrl = null, }: AwaitingJoinApprovalPanelProps) { - const approvalUrl = `${window.location.origin}/company/settings/access`; + const approvalUrl = `${window.location.origin}/company/settings/members`; const approverLabel = invitedByUserName ?? "A company admin"; return ( @@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({ href={approvalUrl} className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100" > - Company Settings → Access + Company Settings → Members

- Ask them to visit Company Settings → Access to approve your request. + Ask them to visit Company Settings → Members to approve your request.

Refresh this page after you've been approved — you'll be redirected automatically. diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx index 941a4875512..9a356d0a27d 100644 --- a/ui/src/pages/InviteUxLab.tsx +++ b/ui/src/pages/InviteUxLab.tsx @@ -23,8 +23,8 @@ const inviteRoleOptions = [ { value: "viewer", label: "Viewer", - description: "Can view company work and follow along without operational permissions.", - gets: "No built-in grants.", + description: "Can view company work and follow along.", + gets: "View-only company membership.", }, { value: "operator", @@ -41,8 +41,8 @@ const inviteRoleOptions = [ { value: "owner", label: "Owner", - description: "Full company access, including membership and permission management.", - gets: "Everything in Admin, plus managing members and permission grants.", + description: "Full company access, including membership management.", + gets: "Everything in Admin, plus managing members.", }, ] as const; @@ -423,8 +423,8 @@ function InviteResultPreview({ <>

@@ -897,7 +897,7 @@ export function InviteUxLab() { />

diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 2784a9546e8..e674029a57a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -617,6 +617,7 @@ type IssueDetailChatTabProps = { blockedBy: Issue["blockedBy"]; blockerAttention: Issue["blockerAttention"] | null; successfulRunHandoff: Issue["successfulRunHandoff"] | null; + scheduledRetry: Issue["scheduledRetry"] | null; recoveryAction: Issue["activeRecoveryAction"]; onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void; canFalsePositiveRecoveryAction?: boolean; @@ -689,6 +690,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ blockedBy, blockerAttention, successfulRunHandoff, + scheduledRetry, recoveryAction, onResolveRecoveryAction, canFalsePositiveRecoveryAction, @@ -897,9 +899,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ timelineEvents={timelineEvents} liveRuns={resolvedLiveRuns} activeRun={resolvedActiveRun} + issueId={issueId} blockedBy={blockedBy ?? []} blockerAttention={blockerAttention} successfulRunHandoff={successfulRunHandoff} + scheduledRetry={scheduledRetry} recoveryAction={recoveryAction ?? null} onResolveRecoveryAction={onResolveRecoveryAction} canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction} @@ -3914,6 +3918,7 @@ export function IssueDetail() { blockedBy={issue.blockedBy ?? []} blockerAttention={issue.blockerAttention ?? null} successfulRunHandoff={issue.successfulRunHandoff ?? null} + scheduledRetry={issue.scheduledRetry ?? null} recoveryAction={issue.activeRecoveryAction ?? null} onResolveRecoveryAction={handleResolveRecoveryAction} canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction} diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts index 2019830b70f..c208df8319d 100644 --- a/ui/src/plugins/bridge-init.ts +++ b/ui/src/plugins/bridge-init.ts @@ -21,7 +21,7 @@ import { usePluginStream, usePluginToast, } from "./bridge.js"; -import { createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react"; +import { Component, createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { User } from "lucide-react"; import { @@ -524,6 +524,127 @@ function FragmentSafe({ children }: { children?: ReactNode }) { return createElement("span", { className: "contents" }, children); } +type PluginStatusBadgeProps = { + label: string; + status: "ok" | "warning" | "error" | "info" | "pending"; +}; + +function PluginSdkStatusBadge({ label, status }: PluginStatusBadgeProps) { + const className = { + ok: "border-emerald-300 bg-emerald-50 text-emerald-700", + warning: "border-amber-300 bg-amber-50 text-amber-800", + error: "border-red-300 bg-red-50 text-red-700", + info: "border-slate-300 bg-slate-50 text-slate-700", + pending: "border-slate-300 bg-slate-50 text-slate-600", + }[status]; + return createElement( + "span", + { className: `inline-flex w-fit items-center rounded-full border px-2 py-0.5 text-xs font-medium ${className}` }, + label, + ); +} + +type PluginDataTableColumn = { + key: string; + header: string; + render?: (value: unknown, row: Record) => ReactNode; + width?: string; +}; + +type PluginDataTableProps = { + columns: PluginDataTableColumn[]; + rows: Array & { id?: string }>; + loading?: boolean; + emptyMessage?: string; +}; + +function PluginSdkDataTable({ columns, rows, loading, emptyMessage = "No rows." }: PluginDataTableProps) { + if (loading) return createElement("div", { className: "text-sm text-muted-foreground" }, "Loading..."); + if (!rows.length) return createElement("div", { className: "text-sm text-muted-foreground" }, emptyMessage); + const gridColumns = columns.map((column) => column.width ?? "minmax(0, 1fr)").join(" "); + return createElement( + "div", + { className: "overflow-hidden rounded-md border" }, + createElement( + "div", + { + className: "hidden border-b bg-muted/35 px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground md:grid md:[grid-template-columns:var(--plugin-grid-cols)]", + style: { "--plugin-grid-cols": gridColumns }, + }, + columns.map((column) => createElement("div", { key: column.key }, column.header)), + ), + createElement( + "div", + { className: "divide-y" }, + rows.map((row, index) => createElement( + "div", + { + key: String(row.id ?? index), + className: "grid gap-2 px-3 py-3 md:items-center md:[grid-template-columns:var(--plugin-grid-cols)]", + style: { "--plugin-grid-cols": gridColumns }, + }, + columns.map((column) => createElement( + "div", + { key: column.key, className: "min-w-0 text-sm" }, + createElement("div", { className: "mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground md:hidden" }, column.header), + column.render ? column.render(row[column.key], row) : String(row[column.key] ?? ""), + )), + )), + ), + ); +} + +type PluginKeyValueListProps = { + pairs: Array<{ label: string; value: ReactNode }>; +}; + +function PluginSdkKeyValueList({ pairs }: PluginKeyValueListProps) { + return createElement( + "dl", + { className: "grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]" }, + pairs.flatMap((pair) => [ + createElement("dt", { key: `${pair.label}:label`, className: "text-muted-foreground" }, pair.label), + createElement("dd", { key: `${pair.label}:value`, className: "min-w-0" }, pair.value), + ]), + ); +} + +function PluginSdkMetricCard({ label, value, unit }: { label: string; value: string | number; unit?: string }) { + return createElement( + "div", + { className: "rounded-md border bg-card p-3" }, + createElement("div", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground" }, label), + createElement("div", { className: "mt-1 text-lg font-semibold" }, `${value}${unit ?? ""}`), + ); +} + +function PluginSdkJsonTree({ data }: { data: unknown }) { + return createElement("pre", { className: "max-h-80 overflow-auto rounded-md border bg-muted/30 p-2 text-xs" }, JSON.stringify(data, null, 2)); +} + +function PluginSdkSpinner({ label = "Loading" }: { size?: "sm" | "md" | "lg"; label?: string }) { + return createElement("span", { + className: "inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground align-middle", + role: "status", + "aria-label": label, + }); +} + +class PluginSdkErrorBoundary extends Component<{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean }> { + override state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + override render() { + if (this.state.hasError) { + return this.props.fallback ?? createElement("div", { className: "rounded-md border border-destructive/30 p-3 text-sm text-destructive" }, "Plugin UI failed to render."); + } + return this.props.children; + } +} + /** * Initialize the plugin bridge global registry. * @@ -564,6 +685,13 @@ export function initPluginBridge( resolveWikiLinkHref, children: content, }), + MetricCard: PluginSdkMetricCard, + StatusBadge: PluginSdkStatusBadge, + DataTable: PluginSdkDataTable, + KeyValueList: PluginSdkKeyValueList, + JsonTree: PluginSdkJsonTree, + Spinner: PluginSdkSpinner, + ErrorBoundary: PluginSdkErrorBoundary, MarkdownEditor: PluginSdkMarkdownEditor, FileTree: PluginSdkFileTree, IssuesList: PluginSdkIssuesList, From a1835cfa5e5f4926591cb956055dd870ede492ef Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 22 May 2026 09:16:24 -0500 Subject: [PATCH 2/4] [codex] Harden plugin runtime invocation scope (#6547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI-agent companies through a company-scoped control plane. > - Plugins extend that control plane, but plugin workers still call back into host APIs. > - Those worker-to-host calls need the same company boundary guarantees as normal API routes. > - Plugin action handlers also need authenticated actor context from the host instead of trusting caller-supplied params. > - This pull request hardens plugin bridge/action scope and keeps plugin operation issues out of normal issue surfaces. > - The benefit is safer plugin execution with clearer authorization boundaries and better test coverage. ## What Changed - Added host-owned invocation context plumbing for nested plugin worker calls. - Added actor context to plugin `performAction` calls and test harness helpers. - Enforced company invocation scope on worker-to-host calls and filtered company lists to the active invocation scope. - Extended plugin action route tests for board and agent actor context, spoofed company params, and cross-company rejection. - Extended plugin worker manager coverage for invocation-scope propagation. - Filtered typed and legacy plugin operation issue origins from default issue/inbox lists. ## Verification - `pnpm --filter @paperclipai/plugin-sdk build` - `NODE_ENV=test pnpm exec vitest run packages/plugins/sdk/tests/host-client-factory.test.ts packages/plugins/sdk/tests/testing-actions.test.ts server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/plugin-worker-manager.test.ts server/src/__tests__/issues-service.test.ts` Note: embedded Postgres issue-service tests reported host-level Postgres init skip for 47 tests; the non-embedded targeted tests passed. ## Risks - Medium: plugin host authorization paths are sensitive, and external plugins may rely on previously loose company params. - Mitigation: the change only tightens calls when the host attached a company invocation scope and includes explicit tests for board, agent, and nested worker calls. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session; exact context window not exposed by this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../plugins/sdk/src/host-client-factory.ts | 2 +- packages/plugins/sdk/src/index.ts | 3 + packages/plugins/sdk/src/protocol.ts | 26 +++ packages/plugins/sdk/src/testing.ts | 71 +++++++- packages/plugins/sdk/src/types.ts | 8 +- packages/plugins/sdk/src/ui/types.ts | 1 + packages/plugins/sdk/src/worker-rpc-host.ts | 50 +++++- .../sdk/tests/host-client-factory.test.ts | 9 + .../plugins/sdk/tests/testing-actions.test.ts | 74 ++++++++ .../plugins/sdk/tests/worker-rpc-host.test.ts | 84 +++++++++ packages/shared/src/constants.ts | 1 + .../plugin-worker-invocation-scope.cjs | 4 +- server/src/__tests__/issues-service.test.ts | 37 ++++ .../src/__tests__/plugin-routes-authz.test.ts | 161 ++++++++++++++++++ .../__tests__/plugin-worker-manager.test.ts | 152 ++++++++++++++++- server/src/routes/plugins.ts | 62 ++++++- server/src/services/issues.ts | 13 +- server/src/services/plugin-worker-manager.ts | 21 ++- 18 files changed, 749 insertions(+), 30 deletions(-) create mode 100644 packages/plugins/sdk/tests/testing-actions.test.ts diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 3264afbccb9..8fc8fdfa093 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -79,7 +79,7 @@ export class CapabilityDeniedError extends Error { */ export class InvocationScopeDeniedError extends Error { override readonly name = "InvocationScopeDeniedError"; - readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED; + readonly code = PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED; constructor(pluginId: string, method: string, message: string) { super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 2ca0fa9a7c3..1fbf172b509 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -149,6 +149,9 @@ export type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorType, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentDiagnostic, PluginEnvironmentDriverBaseParams, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 48075b2f6b9..2c76beab23a 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -233,6 +233,8 @@ export const PLUGIN_RPC_ERROR_CODES = { TIMEOUT: -32003, /** The worker does not implement the requested optional method. */ METHOD_NOT_IMPLEMENTED: -32004, + /** The worker→host call attempted to escape the current invocation company scope. */ + INVOCATION_SCOPE_DENIED: -32005, /** A catch-all for errors that do not fit other categories. */ UNKNOWN: -32099, } as const; @@ -368,6 +370,28 @@ export interface GetDataParams { * * @see PLUGIN_SPEC.md §13.9 — `performAction` */ +export type PluginPerformActionActorType = "user" | "agent" | "system"; + +export interface PluginPerformActionActorContext { + /** Authenticated principal type resolved by the Paperclip host. */ + type: PluginPerformActionActorType; + /** Authenticated board user id when `type === "user"`, otherwise null. */ + userId: string | null; + /** Authenticated agent id when `type === "agent"`, otherwise null. */ + agentId: string | null; + /** Authenticated heartbeat/run id when available. */ + runId: string | null; + /** Company id authorized by the host bridge for this action, when applicable. */ + companyId: string | null; +} + +export interface PluginPerformActionContext { + /** Immutable authenticated actor context supplied by the host. */ + actor: Readonly; + /** Convenience alias for `actor.companyId`. */ + companyId: string | null; +} + export interface PerformActionParams { /** Plugin-defined action key (e.g. `"resync"`). */ key: string; @@ -375,6 +399,8 @@ export interface PerformActionParams { companyId?: string | null; /** Action parameters from the UI. */ params: Record; + /** Authenticated actor context resolved by the host, never by caller params. */ + actorContext?: PluginPerformActionActorContext | null; /** Optional launcher/container metadata from the host render environment. */ renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 299af7c6866..f8f1fc262aa 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -57,6 +57,8 @@ import type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginPerformActionActorContext, + PluginPerformActionContext, } from "./protocol.js"; export interface TestHarnessOptions { @@ -74,6 +76,20 @@ export interface TestHarnessLogEntry { meta?: Record; } +export interface TestHarnessPerformActionOptions { + /** + * Authenticated actor context to expose to the action handler. Omitted fields + * default to null, and `type` defaults to `system`. + */ + actor?: Partial | null; + /** + * Host-authorized company scope. When provided, this is injected into + * `params.companyId` so tests match the production bridge's anti-spoofing + * behavior. + */ + companyId?: string | null; +} + export interface TestHarness { /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ ctx: PluginContext; @@ -98,7 +114,11 @@ export interface TestHarness { /** Invoke a `ctx.data.register(...)` handler by key. */ getData(key: string, params?: Record): Promise; /** Invoke a `ctx.actions.register(...)` handler by key. */ - performAction(key: string, params?: Record): Promise; + performAction( + key: string, + params?: Record, + options?: TestHarnessPerformActionOptions, + ): Promise; /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ executeTool(name: string, params: unknown, runCtx?: Partial): Promise; /** Read raw in-memory state for assertions. */ @@ -491,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const jobs = new Map Promise>(); const launchers = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map Promise>(); function localFolderKey(companyId: string, folderKey: string): string { @@ -502,6 +525,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return `${localFolderKey(companyId, folderKey)}:${relativePath}`; } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFor( + params: Record, + options?: TestHarnessPerformActionOptions, + ): PluginPerformActionContext { + const actorInput = options?.actor ?? null; + const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId); + const actor = Object.freeze({ + type: actorTypeOrSystem(actorInput?.type), + userId: stringOrNull(actorInput?.userId), + agentId: stringOrNull(actorInput?.agentId), + runId: stringOrNull(actorInput?.runId), + companyId, + }); + return Object.freeze({ actor, companyId }); + } + + function paramsWithHostCompanyScope( + params: Record, + context: PluginPerformActionContext, + options?: TestHarnessPerformActionOptions, + ): Record { + if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) { + return context.companyId ? { ...params, companyId: context.companyId } : { ...params }; + } + return params; + } + function normalizeLocalFolderRelativePath(relativePath: string): string { const parts: string[] = []; for (const segment of relativePath.split(/[\\/]+/)) { @@ -2302,10 +2360,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!handler) throw new Error(`No data handler registered for '${key}'`); return await handler(params) as T; }, - async performAction(key: string, params: Record = {}) { + async performAction( + key: string, + params: Record = {}, + options?: TestHarnessPerformActionOptions, + ) { const handler = actionHandlers.get(key); if (!handler) throw new Error(`No action handler registered for '${key}'`); - return await handler(params) as T; + const context = actionContextFor(params, options); + return await handler(paramsWithHostCompanyScope(params, context, options), context) as T; }, async executeTool(name: string, params: unknown, runCtx: Partial = {}) { const handler = toolHandlers.get(name); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 456f4a9e7c0..90af99b4803 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -46,6 +46,7 @@ import type { PrincipalPermissionGrant, PrincipalType, } from "@paperclipai/shared"; +import type { PluginPerformActionContext } from "./protocol.js"; // --------------------------------------------------------------------------- // Re-exports from @paperclipai/shared (plugin authors import from one place) @@ -957,9 +958,12 @@ export interface PluginActionsClient { * Register a handler for a plugin-defined action key. * * @param key - Stable string identifier for this action (e.g. `"resync"`) - * @param handler - Async function that receives action params and returns a result + * @param handler - Async function that receives action params plus immutable host actor context and returns a result */ - register(key: string, handler: (params: Record) => Promise): void; + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void; } /** diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index 8923803e4e5..10478216838 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -54,6 +54,7 @@ export type { * Error codes: * - `WORKER_UNAVAILABLE` — plugin worker is not running * - `CAPABILITY_DENIED` — plugin lacks the required capability + * - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope * - `WORKER_ERROR` — worker returned an error from its handler * - `TIMEOUT` — worker did not respond within the configured timeout * - `UNKNOWN` — unexpected bridge-level failure diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index a50b6d8f14c..a135d1f0a9c 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -78,6 +78,8 @@ import type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentAcquireLeaseParams, PluginEnvironmentDestroyLeaseParams, @@ -289,7 +291,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost const jobHandlers = new Map Promise>(); const launcherRegistrations = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map; fn: (params: unknown, runCtx: ToolRunContext) => Promise; @@ -1184,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, actions: { - register(key: string, handler: (params: Record) => Promise): void { + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void { actionHandlers.set(key, handler); }, }, @@ -1514,16 +1522,44 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }); } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext { + const rawActor = params.actorContext && typeof params.actorContext === "object" + ? params.actorContext + : null; + const actor = Object.freeze({ + type: actorTypeOrSystem(rawActor?.type), + userId: stringOrNull(rawActor?.userId), + agentId: stringOrNull(rawActor?.agentId), + runId: stringOrNull(rawActor?.runId), + companyId: stringOrNull(rawActor?.companyId), + }); + return Object.freeze({ + actor, + companyId: actor.companyId, + }); + } + async function handlePerformAction(params: PerformActionParams): Promise { const handler = actionHandlers.get(params.key); if (!handler) { throw new Error(`No action handler registered for key "${params.key}"`); } - return handler({ - ...params.params, - ...(params.companyId === undefined ? {} : { companyId: params.companyId }), - ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), - }); + return handler( + { + ...params.params, + ...(params.companyId === undefined ? {} : { companyId: params.companyId }), + ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), + }, + actionContextFromParams(params), + ); } async function handleExecuteTool(params: ExecuteToolParams): Promise { diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts index 8cded9c8a15..eb79043e57d 100644 --- a/packages/plugins/sdk/tests/host-client-factory.test.ts +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -6,6 +6,7 @@ import { createHostClientHandlers, InvocationScopeDeniedError, } from "../src/host-client-factory.js"; +import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js"; describe("createHostClientHandlers invocation company scope", () => { it("rejects company-scoped host calls outside the current invocation company", async () => { @@ -28,6 +29,14 @@ describe("createHostClientHandlers invocation company scope", () => { { invocationScope: { companyId: "company-a" } }, ), ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + await expect( + handlers["projects.list"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + }); expect(projectsList).not.toHaveBeenCalled(); }); diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts new file mode 100644 index 00000000000..ab0026b283f --- /dev/null +++ b/packages/plugins/sdk/tests/testing-actions.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { createTestHarness } from "../src/testing.js"; +import type { PaperclipPluginManifestV1 } from "../src/types.js"; + +const manifest = { + id: "paperclip.test-actions", + apiVersion: 1, + version: "1.0.0", + displayName: "Test Actions", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, +} satisfies PaperclipPluginManifestV1; + +describe("createTestHarness action context", () => { + it("passes immutable authenticated actor context and overrides caller company scope", async () => { + const harness = createTestHarness({ manifest }); + + harness.ctx.actions.register("inspect", async (params, context) => ({ + paramsCompanyId: params.companyId, + actor: context.actor, + companyId: context.companyId, + contextFrozen: Object.isFrozen(context), + actorFrozen: Object.isFrozen(context.actor), + })); + + const result = await harness.performAction<{ + paramsCompanyId: unknown; + actor: { + type: string; + userId: string | null; + agentId: string | null; + runId: string | null; + companyId: string | null; + }; + companyId: string | null; + contextFrozen: boolean; + actorFrozen: boolean; + }>( + "inspect", + { companyId: "spoofed-company", value: true }, + { + companyId: "host-company", + actor: { + type: "user", + userId: "board-user-1", + runId: "run-1", + }, + }, + ); + + expect(result.paramsCompanyId).toBe("host-company"); + expect(result.companyId).toBe("host-company"); + expect(result.actor).toEqual({ + type: "user", + userId: "board-user-1", + agentId: null, + runId: "run-1", + companyId: "host-company", + }); + expect(result.contextFrozen).toBe(true); + expect(result.actorFrozen).toBe(true); + }); + + it("keeps existing one-argument action handlers compatible", async () => { + const harness = createTestHarness({ manifest }); + harness.ctx.actions.register("legacy", async (params) => ({ ok: params.ok })); + + await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index b7f15781b5f..d41aa863aff 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -71,6 +71,90 @@ describe("isWorkerEntrypoint", () => { }); }); +describe("worker performAction context", () => { + it("does not derive context companyId from caller params without host actor context", async () => { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + let nextRequestId = 1; + const plugin = definePlugin({ + async setup(ctx) { + ctx.actions.register("inspect", async (params, context) => ({ + paramsCompanyId: params.companyId, + actor: context.actor, + companyId: context.companyId, + })); + }, + }); + const worker = startWorkerRpcHost({ + plugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown) { + const id = `host-${nextRequestId++}`; + const result = new Promise((resolve, reject) => { + pending.set(id, (response) => { + if ("error" in response && response.error) { + reject(new Error(response.error.message)); + return; + } + resolve((response as { result?: unknown }).result); + }); + }); + hostToWorker.write(serializeMessage(createRequest(method, params, id))); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (!isJsonRpcResponse(message)) return; + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + }); + + try { + await expect(callWorker("initialize", { + manifest: { + id: "paperclip.test-worker-context", + apiVersion: 1, + version: "1.0.0", + displayName: "Worker Context Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, + }, + config: {}, + databaseNamespace: null, + })).resolves.toMatchObject({ ok: true }); + + await expect(callWorker("performAction", { + key: "inspect", + params: { companyId: "spoofed-company" }, + })).resolves.toEqual({ + paramsCompanyId: "spoofed-company", + actor: { + type: "system", + userId: null, + agentId: null, + runId: null, + companyId: null, + }, + companyId: null, + }); + } finally { + worker.stop(); + hostReadline.close(); + hostToWorker.destroy(); + workerToHost.destroy(); + } + }); +}); + describe("worker invocation scope propagation", () => { it("keeps overlapping company scopes local to each getData invocation", async () => { const hostToWorker = new PassThrough(); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0c92acb006..c102fa73f2a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1095,6 +1095,7 @@ export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number]; export const PLUGIN_BRIDGE_ERROR_CODES = [ "WORKER_UNAVAILABLE", "CAPABILITY_DENIED", + "INVOCATION_SCOPE_DENIED", "WORKER_ERROR", "TIMEOUT", "UNKNOWN", diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs index 6e54689fc3e..9e1f06dfb36 100644 --- a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -68,13 +68,13 @@ rl.on("line", (line) => { id: message.id, result: { ok: true, - supportedMethods: ["getData"], + supportedMethods: ["getData", "performAction"], }, }); return; } - if (method === "getData") { + if (method === "getData" || method === "performAction") { sendNestedHostRequest(message, message.paperclipInvocation?.id); return; } diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ef328416d01..150625c3d0f 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -773,6 +773,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const normalIssueId = randomUUID(); const pluginVisibleIssueId = randomUUID(); const operationIssueId = randomUUID(); + const typedOperationIssueId = randomUUID(); + const legacyContentMachineOperationIssueId = randomUUID(); await db.insert(companies).values({ id: companyId, @@ -826,12 +828,36 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { originKind: "plugin:paperclip.missions:operation", originId: "mission-alpha:operation-1", }, + { + id: typedOperationIssueId, + companyId, + projectId, + title: "Typed plugin operation issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclip.missions:operation:evaluation", + originId: "mission-alpha:operation-2", + }, + { + id: legacyContentMachineOperationIssueId, + companyId, + projectId, + title: "Legacy Content Machine operation issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclipai.content-machine:evaluation", + originId: "content-machine-operation-1", + }, ]); const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id); expect(defaultIssueIds).toContain(normalIssueId); expect(defaultIssueIds).toContain(pluginVisibleIssueId); expect(defaultIssueIds).not.toContain(operationIssueId); + expect(defaultIssueIds).not.toContain(typedOperationIssueId); + expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId); const inboxIssueIds = (await svc.list(companyId, { assigneeAgentId: agentId, @@ -840,17 +866,28 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { })).map((issue) => issue.id); expect(inboxIssueIds).toContain(normalIssueId); expect(inboxIssueIds).not.toContain(operationIssueId); + expect(inboxIssueIds).not.toContain(typedOperationIssueId); + expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId); await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" })) .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" })) .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); + await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" })) + .resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: operationIssueId }), + expect.objectContaining({ id: typedOperationIssueId }), + ])); const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id); expect(projectIssueIds).toContain(operationIssueId); + expect(projectIssueIds).toContain(typedOperationIssueId); + expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId); const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id); expect(advancedIssueIds).toContain(operationIssueId); + expect(advancedIssueIds).toContain(typedOperationIssueId); + expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId); }); it("excludes plugin operation issues from unread inbox counts", async () => { diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 52fe1c84750..272e20c3575 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -114,6 +114,17 @@ function boardActor(overrides: Record = {}) { }; } +function agentActor(overrides: Record = {}) { + return { + type: "agent", + agentId: agentA, + companyId: companyA, + runId: runA, + source: "agent_jwt", + ...overrides, + }; +} + function readyPlugin() { mockRegistry.getById.mockResolvedValue({ id: pluginId, @@ -656,10 +667,160 @@ describe.sequential("plugin tool and bridge authz", () => { expect(call).toHaveBeenCalledWith(pluginId, "performAction", { key: "sync", params: {}, + actorContext: { + type: "user", + userId: "admin-1", + agentId: null, + runId: null, + companyId: null, + }, renderEnvironment: null, }); }); + it("passes authenticated actor context and overrides spoofed company scope for plugin actions", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor({ runId: runA }), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ + companyId: companyA, + params: { + companyId: companyB, + reviewerUserId: "spoofed-user", + }, + }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerUserId: "spoofed-user", + }, + actorContext: { + type: "user", + userId: "user-1", + agentId: null, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + }); + + it("uses null for board actor userId when no authenticated user id is present", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor({ userId: undefined }), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ companyId: companyA }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", expect.objectContaining({ + actorContext: expect.objectContaining({ + type: "user", + userId: null, + companyId: companyA, + }), + })); + }); + + it("allows agent-scoped plugin actions with authenticated actor context", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(agentActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ + companyId: companyA, + params: { + companyId: companyB, + reviewerAgentId: "spoofed-agent", + }, + }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerAgentId: "spoofed-agent", + }, + actorContext: { + type: "agent", + userId: null, + agentId: agentA, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + + call.mockClear(); + const legacyRes = await request(app) + .post(`/api/plugins/${pluginId}/bridge/action`) + .send({ + key: "sync", + companyId: companyA, + params: { + companyId: companyB, + reviewerAgentId: "spoofed-agent", + }, + }); + + expect(legacyRes.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerAgentId: "spoofed-agent", + }, + actorContext: { + type: "agent", + userId: null, + agentId: agentA, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + }); + + it("rejects agent plugin actions outside the authenticated company scope", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(agentActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ companyId: companyB }); + + expect(res.status).toBe(403); + expect(call).not.toHaveBeenCalled(); + }); + it("attaches worker bridge errors to the HTTP logger context", async () => { readyPlugin(); const call = vi.fn().mockRejectedValue(new Error("missing source_objects column")); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index 626ed448cba..8d2d719401e 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -186,6 +186,58 @@ describe("plugin-worker-manager stderr failure context", () => { } }); + it("passes performAction invocation scope to nested worker host calls", async () => { + const companiesGet = vi.fn(async ( + params: { companyId: string }, + context?: { invocationScope?: { companyId?: string | null } | null }, + ) => ({ + id: params.companyId, + scopedCompanyId: context?.invocationScope?.companyId ?? null, + })); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: { + "companies.get": companiesGet as never, + }, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + mode: "echo", + requestedCompanyId: "company-a", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).resolves.toEqual({ + id: "company-a", + scopedCompanyId: "company-a", + }); + expect(companiesGet).toHaveBeenCalledWith( + { companyId: "company-a" }, + { invocationScope: { companyId: "company-a" } }, + ); + } finally { + await handle.stop().catch(() => undefined); + } + }); + it("passes echoed invocation scope to worker-to-host handlers", async () => { const companiesGet = vi.fn(async () => ({ id: "company-1" })); const handle = createPluginWorkerHandle("test.plugin", { @@ -223,6 +275,104 @@ describe("plugin-worker-manager stderr failure context", () => { } }); + it("rejects performAction nested host calls that omit the invocation id", async () => { + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read"], + services: { + companies: { + list: vi.fn(async () => []), + get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })), + }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: handlers, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + requestedCompanyId: "company-b", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + message: expect.stringContaining("unknown invocation scope"), + }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("rejects nested worker host calls that forge an unknown invocation id", async () => { + const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })); + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read"], + services: { + companies: { + get: companiesGet, + }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: handlers, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + mode: "unknown", + requestedCompanyId: "company-a", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + message: expect.stringContaining("unknown invocation scope"), + }); + expect(companiesGet).not.toHaveBeenCalled(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + it("rejects missing or unknown invocation ids while a company invocation is active", async () => { const companiesGet = vi.fn(async () => ({ id: "company-2" })); const hostHandlers = createHostClientHandlers({ @@ -258,7 +408,7 @@ describe("plugin-worker-manager stderr failure context", () => { requestedCompanyId: "company-2", }, } as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ - code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, }); } diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 741fa1513fe..d572c90d6fa 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -55,7 +55,7 @@ import type { PluginJobStore } from "../services/plugin-job-store.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; -import type { ToolRunContext } from "@paperclipai/plugin-sdk"; +import type { PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; import { assertAuthenticated, @@ -572,6 +572,43 @@ export function pluginRoutes( return companyId; } + function performActionActorContext(req: Request, companyId: string | undefined): PluginPerformActionActorContext { + const scopedCompanyId = companyId ?? null; + if (req.actor.type === "agent") { + return { + type: "agent", + userId: null, + agentId: req.actor.agentId ?? null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + if (req.actor.type === "board") { + return { + type: "user", + userId: req.actor.userId ?? null, + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + return { + type: "system", + userId: null, + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + + function actionParamsWithAuthorizedCompanyScope( + params: Record | undefined, + companyId: string | undefined, + ): Record { + const base = params ?? {}; + return companyId === undefined ? base : { ...base, companyId }; + } + async function validateToolRunContextScope(runContext: ToolRunContext): Promise { const [agent] = await db .select({ companyId: agents.companyId }) @@ -984,6 +1021,12 @@ export function pluginRoutes( message: err.message, details: err.data, }; + case PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED: + return { + code: "INVOCATION_SCOPE_DENIED", + message: err.message, + details: err.data, + }; case PLUGIN_RPC_ERROR_CODES.TIMEOUT: return { code: "TIMEOUT", @@ -1171,7 +1214,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1217,8 +1260,8 @@ export function pluginRoutes( "performAction", { key: body.key, - ...(companyId ? { companyId } : {}), - params: body.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body.renderEnvironment ?? null, }, ); @@ -1355,7 +1398,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1401,8 +1444,8 @@ export function pluginRoutes( "performAction", { key, - ...(companyId ? { companyId } : {}), - params: body?.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body?.renderEnvironment ?? null, }, ); @@ -1606,7 +1649,10 @@ export function pluginRoutes( } catch (err) { const status = typeof (err as { status?: unknown }).status === "number" ? (err as { status: number }).status - : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED + : err instanceof JsonRpcCallError && ( + err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED || + err.code === PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED + ) ? 403 : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED ? 501 diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 0ab88d3eebc..a61d7f2b892 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -683,14 +683,25 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) { `; } +const LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS = [ + "plugin:paperclipai.content-machine:case", + "plugin:paperclipai.content-machine:evaluation", + "plugin:paperclipai.content-machine:source-sync", +] as const; + function nonPluginOperationIssueCondition() { - return sql`NOT (${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%')`; + return sql`NOT ( + ${issues.originKind} LIKE 'plugin:%:operation' + OR ${issues.originKind} LIKE 'plugin:%:operation:%' + OR ${inArray(issues.originKind, LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS)} + )`; } function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) { return Boolean( filters?.includePluginOperations || filters?.originKind || + filters?.originKindPrefix || filters?.originId || filters?.projectId, ); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 81f21a91f85..9847e879c16 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -204,6 +204,8 @@ interface PendingRequest { timer: ReturnType; /** Timestamp when the request was sent. */ sentAt: number; + /** Active host-owned invocation id attached to this host→worker call. */ + invocationId?: string; } interface ActiveInvocation { @@ -435,6 +437,14 @@ export function createPluginWorkerHandle( childProcess.stdin.write(serialized); } + function errorCodeForWorkerHostError(err: unknown): number { + const code = (err as { code?: unknown } | null)?.code; + const pluginErrorCodes: readonly number[] = Object.values(PLUGIN_RPC_ERROR_CODES); + return typeof code === "number" && pluginErrorCodes.includes(code) + ? code + : JSONRPC_ERROR_CODES.INTERNAL_ERROR; + } + // ----------------------------------------------------------------------- // Incoming message handling // ----------------------------------------------------------------------- @@ -503,6 +513,11 @@ export function createPluginWorkerHandle( const directCompanyId = readNonEmptyString(params.companyId); if (directCompanyId) return { companyId: directCompanyId }; + if (method === "performAction" && isRecord(params.actorContext)) { + const companyId = readNonEmptyString(params.actorContext.companyId); + return companyId ? { companyId } : null; + } + if (method === "executeTool" && isRecord(params.runContext)) { const companyId = readNonEmptyString(params.runContext.companyId); return companyId ? { companyId } : null; @@ -585,15 +600,12 @@ export function createPluginWorkerHandle( }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); - const errorCode = typeof (err as { code?: unknown }).code === "number" - ? (err as { code: number }).code - : JSONRPC_ERROR_CODES.INTERNAL_ERROR; log.error({ method, err: errorMessage }, "host handler error"); try { sendMessage( createErrorResponse( request.id, - errorCode, + errorCodeForWorkerHostError(err), errorMessage, ), ); @@ -1161,6 +1173,7 @@ export function createPluginWorkerHandle( }, timer, sentAt: Date.now(), + invocationId: invocation?.id, }; pendingRequests.set(id, pending); From e43b392a7931730c75f15b2c5deab212802d99c4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 22 May 2026 09:56:22 -0500 Subject: [PATCH 3/4] [codex] Add local Cloud Upstream sync (#6548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the control plane for AI-agent companies. > - Operators need a path to move local company state toward Paperclip Cloud without losing local-first control. > - The Cloud Upstream flow needs API, persistence, CLI, and board UI surfaces that agree on the same manifest/run model. > - The existing branch had the feature work plus UX and error-handling follow-ups. > - This pull request packages the remaining Cloud Upstream sync work into one standalone branch. > - The benefit is an inspectable local-to-cloud sync workflow with preview, conflicts, activation, and captured UX review states. ## What Changed - Added Cloud Upstream shared types, server routes/services, and persisted run schema/migration. - Added Paperclip Cloud CLI sync helpers and local connection storage. - Added the Cloud Upstream board UI, settings entry points, query keys, and UX lab page. - Added preview/activation checklist behavior, redirect handling, manifest-only preview support, friendly errors, in-flight hints, and entity count summaries. ## Verification - `pnpm --filter @paperclipai/plugin-sdk build` - `NODE_ENV=test pnpm exec vitest run cli/src/__tests__/cloud.test.ts server/src/__tests__/instance-settings-routes.test.ts server/src/__tests__/instance-settings-service.test.ts ui/src/pages/CloudUpstream.test.tsx ui/src/components/CompanySettingsSidebar.test.tsx` - `NODE_ENV=test pnpm exec vitest run server/src/__tests__/cloud-upstreams.test.ts` Worktree setup note: the isolated worktree install skipped native sqlite build scripts, so I copied the already-built local sqlite binding from the main checkout before running `server/src/__tests__/cloud-upstreams.test.ts`. The test then passed. ## Risks - Medium: this adds a database migration and a broad feature path across CLI/server/UI. - Merge order: this is the only PR in this split with a DB migration; merge it before any future Cloud Upstream migration follow-up. - Mitigation: the PR is based directly on current `origin/master`, has targeted route/service/UI tests, and keeps the feature behind existing experimental Cloud Sync settings. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session; exact context window not exposed by this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, screenshot artifacts are intentionally omitted per reviewer request - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- cli/README.md | 15 + cli/src/__tests__/cloud.test.ts | 243 +++ cli/src/commands/client/cloud-store.ts | 177 +++ cli/src/commands/client/cloud-transfer.ts | 297 ++++ cli/src/commands/client/cloud.ts | 721 +++++++++ cli/src/index.ts | 2 + .../src/migrations/0089_cloud_upstreams.sql | 71 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/cloud_upstreams.ts | 75 + packages/db/src/schema/index.ts | 1 + packages/shared/src/index.ts | 16 + packages/shared/src/types/cloud-upstream.ts | 110 ++ packages/shared/src/types/instance.ts | 1 + packages/shared/src/validators/instance.ts | 1 + .../__tests__/cleanup-removal-service.test.ts | 30 + server/src/__tests__/cloud-upstreams.test.ts | 334 +++++ .../instance-settings-routes.test.ts | 3 + .../instance-settings-service.test.ts | 23 + .../server-startup-feedback-export.test.ts | 1 + server/src/index.ts | 14 + server/src/routes/cloud-upstreams.ts | 118 ++ server/src/routes/index.ts | 1 + server/src/services/cloud-upstreams.ts | 1298 +++++++++++++++++ server/src/services/companies.ts | 10 + server/src/services/index.ts | 1 + server/src/services/instance-settings.ts | 10 +- ui/src/App.tsx | 4 + ui/src/api/cloudUpstreams.ts | 40 + .../CompanySettingsSidebar.test.tsx | 43 + ui/src/components/CompanySettingsSidebar.tsx | 16 +- ui/src/lib/queryKeys.ts | 1 + ui/src/pages/CloudUpstream.test.tsx | 408 ++++++ ui/src/pages/CloudUpstream.tsx | 646 ++++++++ ui/src/pages/CloudUpstreamUxLab.tsx | 822 +++++++++++ ui/src/pages/CompanySettings.tsx | 20 +- ui/src/pages/InstanceExperimentalSettings.tsx | 19 + 36 files changed, 5592 insertions(+), 7 deletions(-) create mode 100644 cli/src/__tests__/cloud.test.ts create mode 100644 cli/src/commands/client/cloud-store.ts create mode 100644 cli/src/commands/client/cloud-transfer.ts create mode 100644 cli/src/commands/client/cloud.ts create mode 100644 packages/db/src/migrations/0089_cloud_upstreams.sql create mode 100644 packages/db/src/schema/cloud_upstreams.ts create mode 100644 packages/shared/src/types/cloud-upstream.ts create mode 100644 server/src/__tests__/cloud-upstreams.test.ts create mode 100644 server/src/__tests__/instance-settings-service.test.ts create mode 100644 server/src/routes/cloud-upstreams.ts create mode 100644 server/src/services/cloud-upstreams.ts create mode 100644 ui/src/api/cloudUpstreams.ts create mode 100644 ui/src/pages/CloudUpstream.test.tsx create mode 100644 ui/src/pages/CloudUpstream.tsx create mode 100644 ui/src/pages/CloudUpstreamUxLab.tsx diff --git a/cli/README.md b/cli/README.md index b23c32a69cf..8aa2cc16ac1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -226,6 +226,21 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
+## Paperclip Cloud Sync + +Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing. + +```bash +paperclipai cloud connect https://your-stack.paperclip.app +paperclipai cloud connect https://your-stack.paperclip.app --no-browser +paperclipai cloud push --company --dry-run +paperclipai cloud push --company +``` + +`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code. + +`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events. + ## Development ```bash diff --git a/cli/src/__tests__/cloud.test.ts b/cli/src/__tests__/cloud.test.ts new file mode 100644 index 00000000000..c66fd33f465 --- /dev/null +++ b/cli/src/__tests__/cloud.test.ts @@ -0,0 +1,243 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompanyPortabilityExportResult } from "@paperclipai/shared"; +import { + assertDiscoveryCompatible, + buildBundleFromLocalCompany, + cloudCommandExitCodes, + connectCloud, + resolveDeviceCodeExpiresAt, +} from "../commands/client/cloud.js"; +import { + LocalUpstreamPushCoordinator, + normalizedContentHash, + type LocalUpstreamExportBundle, +} from "../commands/client/cloud-transfer.js"; +import { getCloudConnection } from "../commands/client/cloud-store.js"; + +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; + +describe("cloud CLI helpers", () => { + let tempHome: string; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-")); + process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it("connects with the device-code flow and stores the resulting cloud connection", async () => { + globalThis.fetch = vi.fn(async (url, init) => { + const requestUrl = String(url); + if (requestUrl.endsWith("/.well-known/paperclip-upstream")) { + return jsonResponse(discovery()); + } + if (requestUrl.endsWith("/api/upstream-sync/device-code")) { + expect(JSON.parse(String(init?.body))).toMatchObject({ + stackId: "stack-1", + scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"], + }); + return jsonResponse({ + deviceCode: "device-1", + userCode: "ABCD-EFGH", + verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + intervalSeconds: 0, + }); + } + if (requestUrl.endsWith("/api/upstream-sync/token")) { + return jsonResponse({ + accessToken: "upt_test", + scopes: ["upstream_import:preview"], + token: { + id: "token-1", + companyStackId: "stack-1", + targetOrigin: "https://cloud.example.test", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }); + } + return jsonResponse({ error: "not_found" }, 404); + }) as typeof fetch; + + const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true }); + + expect(connection.accessToken).toBe("upt_test"); + expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1"); + }); + + it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => { + expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i); + expect(cloudCommandExitCodes.schemaMismatch).toBe(3); + }); + + it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => { + const now = Date.UTC(2026, 4, 22, 13, 0, 0); + const validExpiry = "2026-05-22T13:05:00.000Z"; + + expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry)); + expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000); + expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000); + }); + + it("builds deterministic chunks with validated payload hashes", async () => { + const bundle = await buildTestBundle(); + + expect(bundle.chunks).toHaveLength(2); + expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload)); + expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash); + expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey); + }); + + it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => { + const bundle = await buildTestBundle(); + const calls: Array<{ path: string; body: unknown }> = []; + const coordinator = new LocalUpstreamPushCoordinator({ + targetOrigin: "https://cloud.example.test", + paperclipCompanyId: "target-company-1", + fetch: async (url, init) => { + const parsed = new URL(String(url)); + const body = init?.body ? JSON.parse(String(init.body)) as unknown : {}; + calls.push({ path: parsed.pathname, body }); + if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } }); + return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } }); + }, + }); + + await coordinator.apply(bundle); + await coordinator.apply(bundle); + + const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } }); + const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string }); + expect(runBodies).toHaveLength(2); + expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey); + expect(chunkBodies[0]).toEqual(chunkBodies[2]); + expect(chunkBodies[1]).toEqual(chunkBodies[3]); + }); +}); + +async function buildTestBundle(): Promise { + return buildBundleFromLocalCompany({ + localCompanyId: "local-company-1", + connection: { + id: "conn-1", + remoteUrl: "https://cloud.example.test", + targetOrigin: "https://cloud.example.test", + targetHost: "cloud.example.test", + stackId: "stack-1", + targetCompanyId: "target-company-1", + accessToken: "upt_test", + token: { + id: "token-1", + companyStackId: "stack-1", + targetOrigin: "https://cloud.example.test", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + privateKeyPem: "unused", + sourcePublicKey: "unused", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + createdAt: "2026-05-18T00:00:00.000Z", + updatedAt: "2026-05-18T00:00:00.000Z", + }, + discovery: discovery(), + localApi: { + post: async () => portabilityExport() as T, + }, + maxEntitiesPerChunk: 1, + mode: "apply", + }); +} + +function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) { + return { + schema: "paperclip-upstream-discovery-v1", + stack: { + id: "stack-1", + slug: "cloud-test", + displayName: "Cloud Test", + companyId: "target-company-1", + origin: "https://cloud.example.test", + }, + auth: { + deviceCode: { + deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code", + verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve", + tokenUrl: "https://cloud.example.test/api/upstream-sync/token", + }, + scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"], + }, + transfer: { + supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1, + featureFlags: ["cloud_sync"], + }, + }; +} + +function portabilityExport(): CompanyPortabilityExportResult { + return { + rootPath: ".", + paperclipExtensionPath: ".paperclip.yaml", + manifest: { + schemaVersion: 1, + generatedAt: "2026-05-18T00:00:00.000Z", + source: { + companyId: "local-company-1", + companyName: "Local Company", + }, + includes: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + company: { + path: "company.json", + name: "Local Company", + description: null, + brandColor: null, + logoPath: null, + attachmentMaxBytes: null, + requireBoardApprovalForNewAgents: false, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + }, + sidebar: null, + agents: [], + skills: [], + projects: [], + issues: [], + envInputs: [], + }, + files: { + "README.md": "Local Company", + }, + warnings: [], + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/cli/src/commands/client/cloud-store.ts b/cli/src/commands/client/cloud-store.ts new file mode 100644 index 00000000000..fa63c7133b0 --- /dev/null +++ b/cli/src/commands/client/cloud-store.ts @@ -0,0 +1,177 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolvePaperclipInstanceRoot } from "../../config/home.js"; + +export interface CloudConnectionTokenRecord { + id: string; + companyStackId: string; + targetOrigin: string; + sourceInstanceId: string; + sourceInstanceFingerprint: string; + scopes: string[]; + expiresAt: string; + [key: string]: unknown; +} + +export interface CloudConnection { + id: string; + remoteUrl: string; + targetOrigin: string; + targetHost: string; + stackId: string; + stackSlug?: string | null; + stackDisplayName?: string | null; + targetCompanyId: string; + accessToken: string; + token: CloudConnectionTokenRecord; + privateKeyPem: string; + sourcePublicKey: string; + sourceInstanceId: string; + sourceInstanceFingerprint: string; + scopes: string[]; + createdAt: string; + updatedAt: string; +} + +interface CloudConnectionStore { + version: 1; + connections: Record; + currentConnectionId?: string; +} + +function defaultStore(): CloudConnectionStore { + return { + version: 1, + connections: {}, + }; +} + +export function resolveCloudConnectionStorePath(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json"); +} + +export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore { + if (!fs.existsSync(storePath)) return defaultStore(); + const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial | null; + const connections: Record = {}; + if (raw?.connections && typeof raw.connections === "object") { + for (const [id, value] of Object.entries(raw.connections)) { + const normalized = normalizeConnection(value); + if (normalized) connections[id] = normalized; + } + } + const currentConnectionId = + typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId] + ? raw.currentConnectionId + : Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id; + return { + version: 1, + connections, + currentConnectionId, + }; +} + +export function writeCloudConnectionStore( + store: CloudConnectionStore, + storePath = resolveCloudConnectionStorePath(), +): void { + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); +} + +export function upsertCloudConnection( + connection: CloudConnection, + storePath = resolveCloudConnectionStorePath(), +): CloudConnection { + const store = readCloudConnectionStore(storePath); + const existing = store.connections[connection.id]; + const now = new Date().toISOString(); + const next = { + ...connection, + createdAt: existing?.createdAt ?? connection.createdAt ?? now, + updatedAt: now, + }; + store.connections[next.id] = next; + store.currentConnectionId = next.id; + writeCloudConnectionStore(store, storePath); + return next; +} + +export function getCloudConnection( + remoteUrlOrOrigin?: string, + storePath = resolveCloudConnectionStorePath(), +): CloudConnection | null { + const store = readCloudConnectionStore(storePath); + if (remoteUrlOrOrigin?.trim()) { + const needle = normalizeRemoteLookup(remoteUrlOrOrigin); + return Object.values(store.connections).find((connection) => + normalizeRemoteLookup(connection.remoteUrl) === needle || + normalizeRemoteLookup(connection.targetOrigin) === needle + ) ?? null; + } + return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null; +} + +function normalizeRemoteLookup(value: string): string { + try { + const url = new URL(value); + return url.origin.replace(/\/+$/u, ""); + } catch { + return value.trim().replace(/\/+$/u, ""); + } +} + +function normalizeConnection(value: unknown): CloudConnection | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + const id = stringValue(record.id); + const remoteUrl = stringValue(record.remoteUrl); + const targetOrigin = stringValue(record.targetOrigin); + const targetHost = stringValue(record.targetHost); + const stackId = stringValue(record.stackId); + const targetCompanyId = stringValue(record.targetCompanyId); + const accessToken = stringValue(record.accessToken); + const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token) + ? record.token as CloudConnectionTokenRecord + : null; + const privateKeyPem = stringValue(record.privateKeyPem); + const sourcePublicKey = stringValue(record.sourcePublicKey); + const sourceInstanceId = stringValue(record.sourceInstanceId); + const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint); + const createdAt = stringValue(record.createdAt); + const updatedAt = stringValue(record.updatedAt); + if ( + !id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId || + !accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId || + !sourceInstanceFingerprint || !createdAt || !updatedAt + ) { + return null; + } + return { + id, + remoteUrl, + targetOrigin, + targetHost, + stackId, + stackSlug: stringValue(record.stackSlug), + stackDisplayName: stringValue(record.stackDisplayName), + targetCompanyId, + accessToken, + token, + privateKeyPem, + sourcePublicKey, + sourceInstanceId, + sourceInstanceFingerprint, + scopes: stringArray(record.scopes), + createdAt, + updatedAt, + }; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} diff --git a/cli/src/commands/client/cloud-transfer.ts b/cli/src/commands/client/cloud-transfer.ts new file mode 100644 index 00000000000..9cd1ccbe74a --- /dev/null +++ b/cli/src/commands/client/cloud-transfer.ts @@ -0,0 +1,297 @@ +import { createHash } from "node:crypto"; + +export const upstreamTransferSchema = { + family: "paperclip-upstream-transfer", + version: "1.0.0", + major: 1, + minor: 0, +} as const; + +export type NormalizedSha256 = `sha256:${string}`; + +export interface SourceEntityKey { + sourceInstanceId: string; + sourceCompanyId: string; + sourceEntityType: string; + sourceEntityId: string; + sourceNaturalKey?: string; +} + +export interface UpstreamTransferWarning { + code: string; + severity: "info" | "warning" | "blocker"; + message: string; + entity?: SourceEntityKey; +} + +export interface UpstreamTransferEntityRecord { + key: SourceEntityKey; + contentHash: NormalizedSha256; + dependencies: SourceEntityKey[]; + warnings: UpstreamTransferWarning[]; +} + +export interface UpstreamTransferManifestSource { + sourceInstanceId: string; + sourceCompanyId: string; + sourceInstanceKeyFingerprint: string; + exporterVersion: string; + sourceSchemaVersion: string; +} + +export interface UpstreamTransferManifestTarget { + targetStackId: string; + targetCompanyId: string; + targetOrigin: string; + supportedSchemaMajor: number; +} + +export interface UpstreamTransferChunk { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + manifestHash: NormalizedSha256; +} + +export interface UpstreamTransferManifest { + schema: typeof upstreamTransferSchema; + source: UpstreamTransferManifestSource; + target: UpstreamTransferManifestTarget; + runId: string; + idempotencyKey: string; + generatedAt: string; + entityCount: number; + entities: UpstreamTransferEntityRecord[]; + chunks: UpstreamTransferChunk[]; + warnings: UpstreamTransferWarning[]; + featureFlags: string[]; + manifestHash: NormalizedSha256; +} + +export interface LocalUpstreamExportEntityInput { + key: SourceEntityKey; + body: Record; + dependencies?: SourceEntityKey[]; + warnings?: UpstreamTransferWarning[]; + conflictKeys?: string[]; +} + +export interface LocalUpstreamExportEntity { + record: UpstreamTransferEntityRecord; + body: Record; + conflictKeys?: string[]; +} + +export interface LocalUpstreamExportChunk { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + payload: { + entityKeys: SourceEntityKey[]; + }; +} + +export interface LocalUpstreamExportBundle { + manifest: UpstreamTransferManifest; + entities: LocalUpstreamExportEntity[]; + chunks: LocalUpstreamExportChunk[]; +} + +export interface BuildLocalUpstreamExportBundleInput { + source: UpstreamTransferManifestSource; + target: UpstreamTransferManifestTarget; + runId: string; + idempotencyKey: string; + entities: LocalUpstreamExportEntityInput[]; + warnings?: UpstreamTransferWarning[]; + featureFlags?: string[]; + maxEntitiesPerChunk?: number; +} + +export interface LocalUpstreamPushCoordinatorOptions { + targetOrigin: string; + paperclipCompanyId: string; + fetch?: typeof fetch; + headers?: (input: { method: string; path: string }) => HeadersInit | Promise; +} + +export class UpstreamImportRequestError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +export class LocalUpstreamPushCoordinator { + readonly #targetOrigin: string; + readonly #paperclipCompanyId: string; + readonly #fetch: typeof fetch; + readonly #headers: NonNullable; + + constructor(options: LocalUpstreamPushCoordinatorOptions) { + this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, ""); + this.#paperclipCompanyId = options.paperclipCompanyId; + this.#fetch = options.fetch ?? fetch; + this.#headers = options.headers ?? (() => ({})); + } + + async preview(bundle: LocalUpstreamExportBundle): Promise { + return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, { + manifest: bundle.manifest, + entities: bundle.entities, + }); + } + + async apply(bundle: LocalUpstreamExportBundle): Promise { + const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, { + mode: "apply", + manifest: bundle.manifest, + entities: bundle.entities, + }) as { run?: { id?: unknown } }; + const runId = typeof run.run?.id === "string" ? run.run.id : undefined; + if (!runId) { + throw new Error("Remote upstream importer did not return a run id"); + } + + for (const chunk of bundle.chunks) { + await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk); + } + + return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {}); + } + + async events(runId: string): Promise { + return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`); + } + + private async get(path: string): Promise { + const response = await this.#fetch(`${this.#targetOrigin}${path}`, { + method: "GET", + headers: await this.#headers({ method: "GET", path }), + }); + return parseCoordinatorResponse(response); + } + + private async post(path: string, body: unknown): Promise { + const response = await this.#fetch(`${this.#targetOrigin}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(await this.#headers({ method: "POST", path })), + }, + body: JSON.stringify(body), + }); + return parseCoordinatorResponse(response); + } +} + +export function buildLocalUpstreamExportBundle( + input: BuildLocalUpstreamExportBundleInput, +): LocalUpstreamExportBundle { + const entities = input.entities.map((entity) => ({ + record: { + key: entity.key, + contentHash: normalizedContentHash(entity.body), + dependencies: entity.dependencies ?? [], + warnings: entity.warnings ?? [], + }, + body: entity.body, + conflictKeys: entity.conflictKeys, + })); + const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100); + const manifestWithoutHash = { + schema: upstreamTransferSchema, + source: input.source, + target: input.target, + runId: input.runId, + idempotencyKey: input.idempotencyKey, + generatedAt: new Date(0).toISOString(), + entityCount: entities.length, + entities: entities.map((entity) => entity.record), + chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk), + warnings: input.warnings ?? [], + featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(), + }; + const manifestHash = normalizedContentHash(manifestWithoutHash); + return { + manifest: { + ...manifestWithoutHash, + chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })), + manifestHash, + }, + entities, + chunks, + }; +} + +export function normalizedContentHash(value: unknown): NormalizedSha256 { + return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function buildLocalChunks( + entities: LocalUpstreamExportEntity[], + maxEntitiesPerChunk: number, +): LocalUpstreamExportChunk[] { + if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) { + throw new Error("maxEntitiesPerChunk must be a positive integer"); + } + if (entities.length === 0) return []; + + const groups: LocalUpstreamExportEntity[][] = []; + for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) { + groups.push(entities.slice(index, index + maxEntitiesPerChunk)); + } + + return groups.map((group, index) => { + const payload = { + entityKeys: group.map((entity) => entity.record.key), + }; + return { + chunkIndex: index, + totalChunks: groups.length, + byteLength: Buffer.byteLength(canonicalJson(payload)), + sha256: normalizedContentHash(payload), + payload, + }; + }); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]), + ); +} + +async function parseCoordinatorResponse(response: Response): Promise { + const text = await response.text(); + const parsed = text.trim() ? safeParseJson(text) : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Upstream importer request failed with ${response.status}`; + throw new UpstreamImportRequestError(response.status, message, parsed); + } + return parsed; +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/cli/src/commands/client/cloud.ts b/cli/src/commands/client/cloud.ts new file mode 100644 index 00000000000..f87098a0b9b --- /dev/null +++ b/cli/src/commands/client/cloud.ts @@ -0,0 +1,721 @@ +import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { URL } from "node:url"; +import { Command } from "commander"; +import pc from "picocolors"; +import type { + CompanyPortabilityExportResult, + CompanyPortabilityFileEntry, + InstanceExperimentalSettings, +} from "@paperclipai/shared"; +import { openUrl } from "../../client/board-auth.js"; +import { resolvePaperclipInstanceId } from "../../config/home.js"; +import { + addCommonClientOptions, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; +import { + buildLocalUpstreamExportBundle, + LocalUpstreamPushCoordinator, + normalizedContentHash, + upstreamTransferSchema, + UpstreamImportRequestError, + type LocalUpstreamExportBundle, + type LocalUpstreamExportEntityInput, + type SourceEntityKey, + type UpstreamTransferManifestSource, + type UpstreamTransferManifestTarget, + type UpstreamTransferWarning, +} from "./cloud-transfer.js"; +import { + getCloudConnection, + upsertCloudConnection, + type CloudConnection, + type CloudConnectionTokenRecord, +} from "./cloud-store.js"; + +const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2; +const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3; +const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"]; +const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000; + +interface CloudConnectOptions extends BaseClientOptions { + noBrowser?: boolean; +} + +interface CloudPushOptions extends BaseClientOptions { + company?: string; + remoteUrl?: string; + dryRun?: boolean; + maxEntitiesPerChunk?: number; +} + +interface UpstreamDiscovery { + schema: string; + stack: { + id: string; + slug?: string; + displayName?: string; + companyId: string; + origin: string; + }; + auth: { + pkce?: { + authorizeUrl: string; + tokenUrl: string; + codeChallengeMethod: string; + }; + deviceCode?: { + deviceCodeUrl: string; + verificationUrl: string; + tokenUrl: string; + }; + scopes?: string[]; + }; + transfer: { + supportedSchemaMajor: number; + featureFlags?: string[]; + }; +} + +interface TokenResponse { + accessToken: string; + token: CloudConnectionTokenRecord; + scopes?: string[]; + expiresAt?: string; +} + +class CloudAuthRequestError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +export function registerCloudCommands(program: Command): void { + const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands"); + + addCommonClientOptions( + cloud + .command("connect") + .description("Authorize this local instance to push into a Paperclip Cloud stack") + .argument("", "Paperclip Cloud stack URL") + .option("--no-browser", "Use the device-code flow instead of opening a browser", false) + .action(async (remoteUrl: string, opts: CloudConnectOptions) => { + try { + await connectCloud(remoteUrl, opts); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + cloud + .command("push") + .description("Preview or apply a local company push into the connected Paperclip Cloud stack") + .requiredOption("--company ", "Local company ID to export") + .option("--remote-url ", "Use a specific stored cloud connection") + .option("--dry-run", "Preview without applying", false) + .option("--max-entities-per-chunk ", "Chunk size for upstream uploads", (value) => Number(value), 100) + .action(async (opts: CloudPushOptions) => { + try { + await pushCloud(opts); + } catch (err) { + if (isSchemaMismatchError(err)) { + console.error(pc.red(err instanceof Error ? err.message : String(err))); + process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE; + return; + } + handleCommandError(err); + } + }), + ); +} + +export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise { + const ctx = resolveCommandContext(opts); + const discovery = await discoverUpstream(remoteUrl); + assertDiscoveryCompatible(discovery); + const source = createSourceIdentity(); + const token = await authorizeConnection(discovery, source, { + noBrowser: Boolean(opts.noBrowser), + }); + const targetOrigin = discovery.stack.origin.replace(/\/+$/u, ""); + const targetHost = new URL(targetOrigin).host; + const now = new Date().toISOString(); + const connection = upsertCloudConnection({ + id: connectionId(targetOrigin), + remoteUrl, + targetOrigin, + targetHost, + stackId: discovery.stack.id, + stackSlug: discovery.stack.slug ?? null, + stackDisplayName: discovery.stack.displayName ?? null, + targetCompanyId: discovery.stack.companyId, + accessToken: token.accessToken, + token: token.token, + privateKeyPem: source.privateKeyPem, + sourcePublicKey: source.sourcePublicKey, + sourceInstanceId: source.sourceInstanceId, + sourceInstanceFingerprint: source.sourceInstanceFingerprint, + scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES, + createdAt: now, + updatedAt: now, + }); + + if (ctx.json) { + printOutput(redactConnection(connection), { json: true }); + } else { + console.log(pc.bold("Connected to Paperclip Cloud")); + console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`); + console.log(`origin=${connection.targetOrigin}`); + console.log(`company=${connection.targetCompanyId}`); + } + return connection; +} + +export async function pushCloud(opts: CloudPushOptions): Promise { + const ctx = resolveCommandContext(opts, { requireCompany: false }); + const localCompanyId = requiredString(opts.company, "--company"); + await assertCloudSyncEnabled(ctx.api.get("/api/instance/settings/experimental")); + const connection = getCloudConnection(opts.remoteUrl); + if (!connection) { + throw new Error("No cloud connection found. Run `paperclipai cloud connect ` first."); + } + + const discovery = await discoverUpstream(connection.targetOrigin); + assertDiscoveryCompatible(discovery); + const bundle = await buildBundleFromLocalCompany({ + localCompanyId, + connection, + discovery, + localApi: ctx.api, + maxEntitiesPerChunk: opts.maxEntitiesPerChunk, + mode: opts.dryRun ? "preview" : "apply", + }); + const coordinator = new LocalUpstreamPushCoordinator({ + targetOrigin: connection.targetOrigin, + paperclipCompanyId: connection.targetCompanyId, + headers: ({ method, path }) => cloudProofHeaders(connection, method, path), + }); + + const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle); + const runId = getRunId(result); + const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null; + const summary = summarizeResult(result); + const conflictCount = summary.conflict + summary.staleMapping; + + if (ctx.json) { + printOutput({ result, events }, { json: true }); + } else { + console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied")); + console.log(`run=${runId ?? "-"}`); + console.log(`manifest=${bundle.manifest.manifestHash}`); + console.log( + `create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` + + `skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`, + ); + printWarnings(result); + printConflicts(result); + printEvents(events); + } + + if (conflictCount > 0) { + process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE; + } + return result; +} + +export async function discoverUpstream(remoteUrl: string): Promise { + const base = new URL(remoteUrl); + const discoveryUrl = new URL("/.well-known/paperclip-upstream", base); + return requestCloudJson(discoveryUrl.toString(), { method: "GET" }); +} + +export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void { + if (discovery.schema !== "paperclip-upstream-discovery-v1") { + throw new Error("Remote URL is not a Paperclip Cloud upstream target."); + } + if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) { + throw new Error( + `Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`, + ); + } + if (!discovery.transfer.featureFlags?.includes("cloud_sync")) { + throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag."); + } +} + +export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number { + const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN; + return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS; +} + +export async function buildBundleFromLocalCompany(input: { + localCompanyId: string; + connection: CloudConnection; + discovery: UpstreamDiscovery; + localApi: { + post(path: string, body?: unknown): Promise; + }; + maxEntitiesPerChunk?: number; + mode: "preview" | "apply"; +}): Promise { + const exported = await input.localApi.post( + `/api/companies/${input.localCompanyId}/export`, + { + include: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + expandReferencedSkills: true, + }, + ); + if (!exported) throw new Error("Local company export returned no data."); + + const sourceHash = normalizedContentHash({ + manifest: exported.manifest, + files: exported.files, + }); + const source: UpstreamTransferManifestSource = { + sourceInstanceId: input.connection.sourceInstanceId, + sourceCompanyId: input.localCompanyId, + sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint, + exporterVersion: "paperclipai-cli-cloud-v1", + sourceSchemaVersion: "paperclip-local-portability-v1", + }; + const target: UpstreamTransferManifestTarget = { + targetStackId: input.discovery.stack.id, + targetCompanyId: input.discovery.stack.companyId, + targetOrigin: input.discovery.stack.origin, + supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor, + }; + const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported); + const idempotencyKey = [ + input.mode, + input.connection.sourceInstanceId, + input.localCompanyId, + input.discovery.stack.id, + sourceHash, + ].join(":"); + return buildLocalUpstreamExportBundle({ + source, + target, + runId: `local-${input.mode}-${shortHash(idempotencyKey)}`, + idempotencyKey, + entities, + warnings: exported.warnings.map((message): UpstreamTransferWarning => ({ + code: "local_company_export_warning", + severity: "warning", + message, + })), + featureFlags: ["cloud_sync"], + maxEntitiesPerChunk: input.maxEntitiesPerChunk, + }); +} + +async function authorizeConnection( + discovery: UpstreamDiscovery, + source: ReturnType, + opts: { noBrowser: boolean }, +): Promise { + if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) { + try { + return await authorizeWithBrowser(discovery, source); + } catch (error) { + console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`)); + } + } + if (!discovery.auth.deviceCode) { + throw new Error("Remote Paperclip Cloud stack does not support device-code authorization."); + } + return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() }); +} + +async function authorizeWithBrowser( + discovery: UpstreamDiscovery, + source: ReturnType, +): Promise { + const pkce = discovery.auth.pkce; + if (!pkce) throw new Error("Remote did not advertise PKCE authorization."); + const callback = await startPkceCallbackServer(); + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomUUID(); + const authorizeUrl = new URL(pkce.authorizeUrl); + authorizeUrl.searchParams.set("redirectUri", callback.redirectUri); + authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("codeChallenge", challenge); + authorizeUrl.searchParams.set("codeChallengeMethod", "S256"); + authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId); + authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint); + authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey); + authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" ")); + + try { + console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`); + if (!openUrl(authorizeUrl.toString())) { + throw new Error("Could not open a browser."); + } + const code = await callback.waitForCode(state); + return requestCloudJson(pkce.tokenUrl, { + method: "POST", + body: JSON.stringify({ + grantType: "authorization_code", + code, + redirectUri: callback.redirectUri, + codeVerifier: verifier, + }), + }); + } finally { + await callback.close(); + } +} + +async function authorizeWithDeviceCode( + discovery: UpstreamDiscovery, + source: ReturnType, + opts: { openBrowser: boolean }, +): Promise { + const device = discovery.auth.deviceCode; + if (!device) throw new Error("Remote did not advertise device-code authorization."); + const response = await requestCloudJson<{ + deviceCode: string; + userCode: string; + verificationUri: string; + expiresAt?: string; + intervalSeconds?: number; + }>(device.deviceCodeUrl, { + method: "POST", + body: JSON.stringify({ + stackId: discovery.stack.id, + sourceInstanceId: source.sourceInstanceId, + sourceInstanceFingerprint: source.sourceInstanceFingerprint, + sourcePublicKey: source.sourcePublicKey, + scopes: CLOUD_SYNC_SCOPES, + }), + }); + console.error(pc.bold("Cloud device authorization required")); + console.error(`Open: ${response.verificationUri}`); + console.error(`Code: ${response.userCode}`); + if (opts.openBrowser) openUrl(response.verificationUri); + + const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt); + const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000); + while (Date.now() < expiresAt) { + await sleep(intervalMs); + try { + return await requestCloudJson(device.tokenUrl, { + method: "POST", + body: JSON.stringify({ + grantType: "device_code", + deviceCode: response.deviceCode, + }), + }); + } catch (error) { + if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") { + const code = (error.body as { error?: unknown }).error; + if (code === "authorization_pending") continue; + } + throw error; + } + } + throw new Error("Device-code authorization expired before it was approved."); +} + +function buildEntitiesFromPortableExport( + localCompanyId: string, + sourceInstanceId: string, + exported: CompanyPortabilityExportResult, +): LocalUpstreamExportEntityInput[] { + const companyKey: SourceEntityKey = { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company", + sourceEntityId: localCompanyId, + sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId, + }; + const entities: LocalUpstreamExportEntityInput[] = [ + { + key: companyKey, + body: { + kind: "paperclip_company_portability_manifest", + manifest: exported.manifest, + rootPath: exported.rootPath, + paperclipExtensionPath: exported.paperclipExtensionPath, + fileCount: Object.keys(exported.files).length, + }, + conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`], + }, + ]; + + for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) { + entities.push({ + key: { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company_setting", + sourceEntityId: shortHash(filePath), + sourceNaturalKey: filePath, + }, + body: { + kind: "paperclip_portable_file", + path: filePath, + entry: normalizePortableFileEntry(entry), + }, + dependencies: [companyKey], + conflictKeys: [`portable_file:${filePath}`], + }); + } + return entities; +} + +function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record { + if (typeof entry === "string") { + return { encoding: "utf8", data: entry }; + } + return { ...entry }; +} + +async function assertCloudSyncEnabled(settingsPromise: Promise): Promise { + const settings = await settingsPromise; + if (settings?.enableCloudSync !== true) { + throw new Error( + "Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.", + ); + } +} + +function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record { + const timestamp = new Date().toISOString(); + const nonce = randomUUID(); + const payload = [ + method, + connection.targetHost.toLowerCase(), + pathAndSearch, + connection.token.id, + connection.sourceInstanceId, + timestamp, + nonce, + ].join("\n"); + return { + Authorization: `Bearer ${connection.accessToken}`, + "X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId, + "X-Paperclip-Upstream-Proof-Timestamp": timestamp, + "X-Paperclip-Upstream-Proof-Nonce": nonce, + "X-Paperclip-Upstream-Proof-Signature": sign( + null, + Buffer.from(payload, "utf8"), + connection.privateKeyPem, + ).toString("base64url"), + }; +} + +async function requestCloudJson(url: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body !== undefined && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const response = await fetch(url, { ...init, headers }); + const text = await response.text(); + const parsed = text.trim() ? JSON.parse(text) as unknown : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Cloud request failed with ${response.status}`; + throw new CloudAuthRequestError(response.status, message, parsed); + } + return parsed as T; +} + +function createSourceIdentity() { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString(); + const sourceInstanceFingerprint = `sha256:${createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; + return { + sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`, + sourceInstanceFingerprint, + sourcePublicKey, + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; +} + +async function startPkceCallbackServer(): Promise<{ + redirectUri: string; + waitForCode: (state: string) => Promise; + close: () => Promise; +}> { + let resolveCode: ((code: string) => void) | null = null; + let rejectCode: ((error: Error) => void) | null = null; + let expectedState = ""; + const codePromise = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Paperclip Cloud authorization failed. You can close this tab."); + rejectCode?.(new Error("Authorization callback was missing a valid code or state.")); + return; + } + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Paperclip Cloud authorization complete. You can close this tab."); + resolveCode?.(code); + }); + await listenOnLoopback(server); + const address = server.address(); + if (typeof address !== "object" || !address?.port) { + throw new Error("Failed to start local authorization callback server."); + } + return { + redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`, + waitForCode: (state: string) => { + expectedState = state; + return codePromise; + }, + close: () => closeServer(server), + }; +} + +function listenOnLoopback(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +function canOpenBrowser(): boolean { + if (process.platform === "darwin" || process.platform === "win32") return true; + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); +} + +function summarizeResult(result: unknown): { + create: number; + update: number; + adopt: number; + skip: number; + conflict: number; + staleMapping: number; +} { + const summary = asRecord(asRecord(result)?.summary); + return { + create: numberValue(summary?.create), + update: numberValue(summary?.update), + adopt: numberValue(summary?.adopt), + skip: numberValue(summary?.skip), + conflict: numberValue(summary?.conflict), + staleMapping: numberValue(summary?.staleMapping), + }; +} + +function printWarnings(result: unknown): void { + const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : []; + for (const warning of warnings) { + const record = asRecord(warning); + console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim())); + } +} + +function printConflicts(result: unknown): void { + const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : []; + for (const conflict of conflicts.slice(0, 10)) { + const record = asRecord(conflict); + console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`)); + } + if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`)); +} + +function printEvents(events: unknown): void { + const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : []; + for (const row of rows.slice(-10)) { + const event = asRecord(row); + console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`)); + } +} + +function getRunId(result: unknown): string | null { + const run = asRecord(asRecord(result)?.run); + return typeof run?.id === "string" ? run.id : null; +} + +function redactConnection(connection: CloudConnection): Record { + return { + id: connection.id, + remoteUrl: connection.remoteUrl, + targetOrigin: connection.targetOrigin, + stackId: connection.stackId, + targetCompanyId: connection.targetCompanyId, + scopes: connection.scopes, + expiresAt: connection.token.expiresAt, + }; +} + +function connectionId(targetOrigin: string): string { + return `cloud-${shortHash(targetOrigin)}`; +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +function requiredString(value: unknown, label: string): string { + if (typeof value === "string" && value.trim()) return value.trim(); + throw new Error(`${label} is required.`); +} + +function numberValue(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function isSchemaMismatchError(error: unknown): boolean { + if (error instanceof UpstreamImportRequestError) { + return JSON.stringify(error.body).toLowerCase().includes("schema"); + } + return error instanceof Error && error.message.toLowerCase().includes("schema mismatch"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const cloudCommandExitCodes = { + conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE, + schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE, +} as const; diff --git a/cli/src/index.ts b/cli/src/index.ts index f1a2084a727..b9fd115972c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -19,6 +19,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { registerRoutineCommands } from "./commands/routines.js"; import { registerFeedbackCommands } from "./commands/client/feedback.js"; import { registerSecretCommands } from "./commands/client/secrets.js"; +import { registerCloudCommands } from "./commands/client/cloud.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; import { loadPaperclipEnvFile } from "./config/env.js"; import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; @@ -149,6 +150,7 @@ registerDashboardCommands(program); registerRoutineCommands(program); registerFeedbackCommands(program); registerSecretCommands(program); +registerCloudCommands(program); registerWorktreeCommands(program); registerEnvLabCommands(program); registerPluginCommands(program); diff --git a/packages/db/src/migrations/0089_cloud_upstreams.sql b/packages/db/src/migrations/0089_cloud_upstreams.sql new file mode 100644 index 00000000000..caf14522551 --- /dev/null +++ b/packages/db/src/migrations/0089_cloud_upstreams.sql @@ -0,0 +1,71 @@ +CREATE TABLE IF NOT EXISTS "cloud_upstream_connections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "remote_url" text NOT NULL, + "source_instance_id" text NOT NULL, + "source_instance_fingerprint" text NOT NULL, + "source_public_key" text NOT NULL, + "private_key_pem" text NOT NULL, + "token_status" text NOT NULL, + "scopes" text[] DEFAULT '{}' NOT NULL, + "authorized_global_user_id" text, + "access_token" text, + "token_id" text, + "token_expires_at" timestamp with time zone, + "target_stack_id" text NOT NULL, + "target_stack_slug" text, + "target_stack_display_name" text, + "target_company_id" text NOT NULL, + "target_origin" text NOT NULL, + "target_primary_host" text NOT NULL, + "target_product" text NOT NULL, + "target_schema_major" integer NOT NULL, + "target_max_chunk_bytes" integer NOT NULL, + "pending_state" text, + "pending_code_verifier" text, + "pending_redirect_uri" text, + "pending_token_url" text, + "last_run_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "cloud_upstream_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "connection_id" uuid NOT NULL, + "company_id" uuid NOT NULL, + "remote_run_id" text, + "status" text NOT NULL, + "active_step" text NOT NULL, + "progress_percent" integer DEFAULT 0 NOT NULL, + "dry_run" boolean DEFAULT false NOT NULL, + "retry_of_run_id" uuid, + "summary" jsonb DEFAULT '[]'::jsonb NOT NULL, + "warnings" jsonb DEFAULT '[]'::jsonb NOT NULL, + "conflicts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "events" jsonb DEFAULT '[]'::jsonb NOT NULL, + "report" jsonb DEFAULT '{}'::jsonb NOT NULL, + "idempotency_key" text NOT NULL, + "manifest_hash" text NOT NULL, + "target_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone +);--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_connections_company_id_companies_id_fk') THEN + ALTER TABLE "cloud_upstream_connections" ADD CONSTRAINT "cloud_upstream_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk') THEN + ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."cloud_upstream_connections"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_company_id_companies_id_fk') THEN + ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cloud_upstream_connections_company_idx" ON "cloud_upstream_connections" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_company_created_idx" ON "cloud_upstream_runs" USING btree ("company_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_connection_idx" ON "cloud_upstream_runs" USING btree ("connection_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 7aaa96d3da3..abaa1136e25 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -624,6 +624,13 @@ "when": 1779446400000, "tag": "0088_backfill_principal_access_compatibility", "breakpoints": true + }, + { + "idx": 89, + "version": "7", + "when": 1779129600000, + "tag": "0089_cloud_upstreams", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/cloud_upstreams.ts b/packages/db/src/schema/cloud_upstreams.ts new file mode 100644 index 00000000000..93b4341f7e4 --- /dev/null +++ b/packages/db/src/schema/cloud_upstreams.ts @@ -0,0 +1,75 @@ +import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const cloudUpstreamConnections = pgTable( + "cloud_upstream_connections", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + remoteUrl: text("remote_url").notNull(), + sourceInstanceId: text("source_instance_id").notNull(), + sourceInstanceFingerprint: text("source_instance_fingerprint").notNull(), + sourcePublicKey: text("source_public_key").notNull(), + // Stored through the Cloud Upstream service as an encrypted credential envelope. + privateKeyPem: text("private_key_pem").notNull(), + tokenStatus: text("token_status").notNull(), + scopes: text("scopes").array().notNull().default([]), + authorizedGlobalUserId: text("authorized_global_user_id"), + // Stored through the Cloud Upstream service as an encrypted credential envelope. + accessToken: text("access_token"), + tokenId: text("token_id"), + tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }), + + targetStackId: text("target_stack_id").notNull(), + targetStackSlug: text("target_stack_slug"), + targetStackDisplayName: text("target_stack_display_name"), + targetCompanyId: text("target_company_id").notNull(), + targetOrigin: text("target_origin").notNull(), + targetPrimaryHost: text("target_primary_host").notNull(), + targetProduct: text("target_product").notNull(), + targetSchemaMajor: integer("target_schema_major").notNull(), + targetMaxChunkBytes: integer("target_max_chunk_bytes").notNull(), + + pendingState: text("pending_state"), + pendingCodeVerifier: text("pending_code_verifier"), + pendingRedirectUri: text("pending_redirect_uri"), + pendingTokenUrl: text("pending_token_url"), + + lastRunId: uuid("last_run_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("cloud_upstream_connections_company_idx").on(table.companyId), + ], +); + +export const cloudUpstreamRuns = pgTable( + "cloud_upstream_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + connectionId: uuid("connection_id").notNull().references(() => cloudUpstreamConnections.id, { onDelete: "cascade" }), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + remoteRunId: text("remote_run_id"), + status: text("status").notNull(), + activeStep: text("active_step").notNull(), + progressPercent: integer("progress_percent").notNull().default(0), + dryRun: boolean("dry_run").notNull().default(false), + retryOfRunId: uuid("retry_of_run_id"), + summary: jsonb("summary").$type().notNull().default([]), + warnings: jsonb("warnings").$type().notNull().default([]), + conflicts: jsonb("conflicts").$type().notNull().default([]), + events: jsonb("events").$type().notNull().default([]), + report: jsonb("report").$type>().notNull().default({}), + idempotencyKey: text("idempotency_key").notNull(), + manifestHash: text("manifest_hash").notNull(), + targetUrl: text("target_url"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp("completed_at", { withTimezone: true }), + }, + (table) => [ + index("cloud_upstream_runs_company_created_idx").on(table.companyId, table.createdAt), + index("cloud_upstream_runs_connection_idx").on(table.connectionId), + ], +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index fdf5f7cb92a..b7630a31656 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,6 +2,7 @@ export { companies } from "./companies.js"; export { companyLogos } from "./company_logos.js"; export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js"; export { instanceSettings } from "./instance_settings.js"; +export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.js"; export { instanceUserRoles } from "./instance_user_roles.js"; export { userSidebarPreferences } from "./user_sidebar_preferences.js"; export { agents } from "./agents.js"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9afa40f46a3..7f605326998 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -688,6 +688,22 @@ export { MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, } from "./types/instance.js"; +export type { + CloudUpstreamConnectStartResponse, + CloudUpstreamActivationDecision, + CloudUpstreamActivationEntityType, + CloudUpstreamConnection, + CloudUpstreamConflict, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamRunEvent, + CloudUpstreamsState, + CloudUpstreamStep, + CloudUpstreamSummaryCount, + CloudUpstreamTarget, + CloudUpstreamWarning, +} from "./types/cloud-upstream.js"; + export { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, diff --git a/packages/shared/src/types/cloud-upstream.ts b/packages/shared/src/types/cloud-upstream.ts new file mode 100644 index 00000000000..211fa25d53d --- /dev/null +++ b/packages/shared/src/types/cloud-upstream.ts @@ -0,0 +1,110 @@ +export type CloudUpstreamStep = "connect" | "scan" | "preview" | "push" | "verify" | "activate"; + +export type CloudUpstreamRunStatus = "previewed" | "running" | "succeeded" | "failed" | "cancelled"; + +export type CloudUpstreamActivationEntityType = "agents" | "routines" | "monitors"; + +export interface CloudUpstreamActivationDecision { + entityType: CloudUpstreamActivationEntityType; + count: number; + status: "paused" | "activated"; + activatedAt: string | null; +} + +export interface CloudUpstreamTarget { + stackId: string; + stackSlug: string | null; + stackDisplayName: string | null; + companyId: string; + primaryHost: string; + origin: string; + product: string; + schemaMajor: number; + maxChunkBytes: number; +} + +export interface CloudUpstreamConnection { + id: string; + companyId: string; + remoteUrl: string; + target: CloudUpstreamTarget; + tokenStatus: "pending" | "connected" | "expired" | "revoked"; + scopes: string[]; + authorizedGlobalUserId: string | null; + expiresAt: string | null; + createdAt: string; + updatedAt: string; + lastRunId: string | null; +} + +export interface CloudUpstreamSummaryCount { + key: string; + label: string; + count: number; +} + +export interface CloudUpstreamWarning { + code: string; + severity: "warning" | "blocker"; + title: string; + detail: string; +} + +export interface CloudUpstreamConflict { + id: string; + entityType: string; + sourceLabel: string; + targetLabel: string; + plannedAction: "create" | "update" | "skip" | "blocked"; + reason: string; +} + +export interface CloudUpstreamPreview { + connectionId: string; + sourceCompanyId: string; + target: CloudUpstreamTarget; + schemaCompatible: boolean; + summary: CloudUpstreamSummaryCount[]; + warnings: CloudUpstreamWarning[]; + conflicts: CloudUpstreamConflict[]; + generatedAt: string; +} + +export interface CloudUpstreamRunEvent { + id: string; + at: string; + phase: CloudUpstreamStep; + type: "created" | "updated" | "skipped" | "conflict" | "retrying" | "failed" | "completed"; + message: string; +} + +export interface CloudUpstreamRun { + id: string; + connectionId: string; + companyId: string; + status: CloudUpstreamRunStatus; + activeStep: CloudUpstreamStep; + progressPercent: number; + dryRun: boolean; + summary: CloudUpstreamSummaryCount[]; + warnings: CloudUpstreamWarning[]; + conflicts: CloudUpstreamConflict[]; + events: CloudUpstreamRunEvent[]; + targetUrl: string | null; + report: Record; + retryOfRunId: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; +} + +export interface CloudUpstreamsState { + connections: CloudUpstreamConnection[]; + runs: CloudUpstreamRun[]; +} + +export interface CloudUpstreamConnectStartResponse { + pendingConnectionId: string; + authorizationUrl: string; + connection: CloudUpstreamConnection; +} diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ee6a6553470..3be7c6fd6ce 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -29,6 +29,7 @@ export interface InstanceGeneralSettings { export interface InstanceExperimentalSettings { enableEnvironments: boolean; enableIsolatedWorkspaces: boolean; + enableCloudSync: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 3415539a2ad..52da4d5f637 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema. export const instanceExperimentalSettingsSchema = z.object({ enableEnvironments: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), + enableCloudSync: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/server/src/__tests__/cleanup-removal-service.test.ts b/server/src/__tests__/cleanup-removal-service.test.ts index 25dc11d679b..e65124cc9d3 100644 --- a/server/src/__tests__/cleanup-removal-service.test.ts +++ b/server/src/__tests__/cleanup-removal-service.test.ts @@ -9,6 +9,7 @@ import { createDb, documents, documentRevisions, + heartbeatRunEvents, heartbeatRuns, issueComments, issueDocuments, @@ -42,6 +43,7 @@ describeEmbeddedPostgres("cleanup removal services", () => { }, 20_000); afterEach(async () => { + await db.delete(heartbeatRunEvents); await db.delete(activityLog); await db.delete(issueReadStates); await db.delete(issueComments); @@ -228,4 +230,32 @@ describeEmbeddedPostgres("cleanup removal services", () => { await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0); await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0); }); + + it("removes heartbeat events by run id before deleting company-owned runs", async () => { + const { agentId, companyId, runId } = await seedFixture(); + const otherCompanyId = randomUUID(); + + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other Company", + issuePrefix: `O${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(heartbeatRunEvents).values({ + companyId: otherCompanyId, + runId, + agentId, + seq: 1, + eventType: "output", + message: "event with mismatched company scope", + }); + + const removed = await companyService(db).remove(companyId); + + expect(removed?.id).toBe(companyId); + await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0); + await expect(db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).resolves.toHaveLength(0); + await expect(db.select().from(companies).where(eq(companies.id, otherCompanyId))).resolves.toHaveLength(1); + }); }); diff --git a/server/src/__tests__/cloud-upstreams.test.ts b/server/src/__tests__/cloud-upstreams.test.ts new file mode 100644 index 00000000000..fce78dfd1ef --- /dev/null +++ b/server/src/__tests__/cloud-upstreams.test.ts @@ -0,0 +1,334 @@ +import { generateKeyPairSync, randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, cloudUpstreamConnections, cloudUpstreamRuns, companySkills, createDb } from "@paperclipai/db"; + +import { HttpError } from "../errors.js"; +import { + cloudUpstreamRemoteFailureReport, + cloudUpstreamService, + reconcileCloudUpstreamRunsOnStartup, + sealCloudUpstreamCredential, + unsealCloudUpstreamCredential, +} from "../services/cloud-upstreams.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres cloud upstream tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("cloud upstream remote failures", () => { + it("preserves the cloud response body and message on run reports", () => { + const body = { + error: "bad_request", + message: "entities[42].body must be an object", + errors: [{ path: "entities[42].body" }], + }; + + expect(cloudUpstreamRemoteFailureReport(new HttpError(400, "bad_request", body))).toEqual({ + error: "bad_request", + errorMessage: "entities[42].body must be an object", + details: body, + }); + }); + + it("falls back to the thrown error message for non-remote failures", () => { + expect(cloudUpstreamRemoteFailureReport(new Error("network failed"))).toEqual({ + error: "network failed", + }); + }); +}); + +describe("cloud upstream credential storage", () => { + const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + + afterEach(() => { + if (previousMasterKey === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey; + } + }); + + it("stores new credentials as encrypted envelopes and preserves legacy plaintext reads", async () => { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012"; + const sealed = await sealCloudUpstreamCredential("cloud-access-token"); + + expect(sealed).toMatch(/^paperclip-cloud-credential:/); + expect(sealed).not.toContain("cloud-access-token"); + await expect(unsealCloudUpstreamCredential(sealed)).resolves.toBe("cloud-access-token"); + await expect(unsealCloudUpstreamCredential("legacy-plaintext-token")).resolves.toBe("legacy-plaintext-token"); + }); +}); + +describeEmbeddedPostgres("cloud upstream persistence", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + + beforeAll(async () => { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012"; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-upstreams-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + vi.restoreAllMocks(); + await db.delete(cloudUpstreamRuns); + await db.delete(cloudUpstreamConnections); + await db.delete(companySkills); + await db.delete(companies); + }); + + afterAll(async () => { + if (previousMasterKey === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey; + } + await tempDb?.cleanup(); + }); + + it("encrypts stored upstream credentials while keeping connection flows usable", async () => { + const companyId = randomUUID(); + await seedCompany(companyId); + const tokenUrl = "https://cloud.example.test/oauth/token"; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + if (url.startsWith("https://cloud.example.test/.well-known/paperclip-upstream")) { + return jsonResponse({ + product: "Paperclip Cloud", + stack: { + id: "stack-1", + companyId: "cloud-company-1", + origin: "https://cloud.example.test", + primaryHost: "cloud.example.test", + }, + transfer: { + supportedSchemaMajor: 1, + maxChunkBytes: 8192, + }, + auth: { + scopes: ["upstream_import:write"], + pkce: { + authorizeUrl: "https://cloud.example.test/oauth/authorize", + tokenUrl, + }, + }, + }); + } + if (url === tokenUrl && init?.method === "POST") { + const payload = JSON.parse(String(init.body)); + expect(payload.codeVerifier).toEqual(expect.any(String)); + expect(payload.codeVerifier).not.toContain("paperclip-cloud-credential:"); + return jsonResponse({ + accessToken: "cloud-access-token", + token: { + id: "token-1", + expiresAt: "2026-05-22T13:00:00.000Z", + globalUserId: "user-1", + }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + const service = cloudUpstreamService(db, { instanceId: "test" }); + const started = await service.startConnect({ + companyId, + remoteUrl: "https://cloud.example.test", + redirectUri: "http://localhost:3100/callback", + }); + await service.finishConnect({ + pendingConnectionId: started.pendingConnectionId, + code: "auth-code", + state: new URL(started.authorizationUrl).searchParams.get("state") ?? "", + }); + + const [row] = await db.select().from(cloudUpstreamConnections); + expect(row.privateKeyPem).toMatch(/^paperclip-cloud-credential:/); + expect(row.privateKeyPem).not.toContain("BEGIN PRIVATE KEY"); + expect(row.accessToken).toMatch(/^paperclip-cloud-credential:/); + expect(row.accessToken).not.toContain("cloud-access-token"); + }); + + it("marks orphaned running runs failed during startup reconciliation", async () => { + const companyId = randomUUID(); + const connectionId = randomUUID(); + const runningRunId = randomUUID(); + const succeededRunId = randomUUID(); + const reconciledAt = new Date("2026-05-22T13:00:00.000Z"); + await seedCompany(companyId); + await db.insert(cloudUpstreamConnections).values({ + id: connectionId, + companyId, + remoteUrl: "https://cloud.example.test", + sourceInstanceId: "source-1", + sourceInstanceFingerprint: "sha256:test", + sourcePublicKey: "public-key", + privateKeyPem: "legacy-private-key", + tokenStatus: "connected", + scopes: ["upstream_import:write"], + authorizedGlobalUserId: "user-1", + accessToken: "legacy-token", + tokenId: "token-1", + targetStackId: "stack-1", + targetCompanyId: "cloud-company-1", + targetOrigin: "https://cloud.example.test", + targetPrimaryHost: "cloud.example.test", + targetProduct: "Paperclip Cloud", + targetSchemaMajor: 1, + targetMaxChunkBytes: 8192, + }); + await db.insert(cloudUpstreamRuns).values([ + cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }), + cloudRunRow({ id: succeededRunId, connectionId, companyId, status: "succeeded", completedAt: reconciledAt }), + ]); + + await expect(reconcileCloudUpstreamRunsOnStartup(db, reconciledAt)).resolves.toEqual({ reconciled: 1 }); + + const rows = await db.select().from(cloudUpstreamRuns); + const running = rows.find((row) => row.id === runningRunId); + const succeeded = rows.find((row) => row.id === succeededRunId); + expect(running?.status).toBe("failed"); + expect(running?.completedAt?.toISOString()).toBe(reconciledAt.toISOString()); + expect(running?.events.at(-1)?.message).toContain("server startup"); + expect(running?.report).toMatchObject({ + error: "orphaned_running_run", + reconciledAt: reconciledAt.toISOString(), + }); + expect(succeeded?.status).toBe("succeeded"); + }); + + it("rejects a new run when the connection already has a running run", async () => { + const companyId = randomUUID(); + const connectionId = randomUUID(); + const runningRunId = randomUUID(); + await seedCompany(companyId); + await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId })); + await db.insert(cloudUpstreamRuns).values( + cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }), + ); + + await expect(cloudUpstreamService(db).createRun({ connectionId, companyId })).rejects.toMatchObject({ + status: 409, + details: { runId: runningRunId }, + }); + }); + + it("preserves a cancelled run when an in-flight createRun tries to finish", async () => { + const companyId = randomUUID(); + const connectionId = randomUUID(); + await seedCompany(companyId); + await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId })); + + const service = cloudUpstreamService(db); + const remoteCalls: string[] = []; + globalThis.fetch = vi.fn(async (input) => { + const path = new URL(String(input)).pathname; + remoteCalls.push(path); + if (path.endsWith("/upstream-imports/runs")) { + return jsonResponse({ run: { id: "remote-run-1" } }); + } + if (path.endsWith("/chunks")) { + const run = await db.select().from(cloudUpstreamRuns).then((rows) => rows[0]); + expect(run?.status).toBe("running"); + await service.cancelRun(connectionId, run.id, companyId); + return jsonResponse({ ok: true }); + } + if (path.endsWith("/cancel")) { + return jsonResponse({ ok: true }); + } + if (path.endsWith("/apply")) { + return jsonResponse({ ok: true }); + } + if (path.endsWith("/events")) { + return jsonResponse({ events: [] }); + } + return jsonResponse({ error: "not_found" }, 404); + }) as typeof fetch; + + const result = await service.createRun({ connectionId, companyId }); + + expect(result.status).toBe("cancelled"); + expect(remoteCalls.some((path) => path.endsWith("/apply"))).toBe(false); + const rows = await db.select().from(cloudUpstreamRuns); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("cancelled"); + }); + + async function seedCompany(companyId: string) { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + } +}); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function cloudConnectionRow(input: { id: string; companyId: string }) { + const { privateKey } = generateKeyPairSync("ed25519"); + return { + id: input.id, + companyId: input.companyId, + remoteUrl: "https://cloud.example.test", + sourceInstanceId: "source-1", + sourceInstanceFingerprint: "sha256:test", + sourcePublicKey: "public-key", + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + tokenStatus: "connected", + scopes: ["upstream_import:write"], + authorizedGlobalUserId: "user-1", + accessToken: "legacy-token", + tokenId: "token-1", + targetStackId: "stack-1", + targetCompanyId: "cloud-company-1", + targetOrigin: "https://cloud.example.test", + targetPrimaryHost: "cloud.example.test", + targetProduct: "Paperclip Cloud", + targetSchemaMajor: 1, + targetMaxChunkBytes: 8192, + }; +} + +function cloudRunRow(input: { + id: string; + connectionId: string; + companyId: string; + status: string; + completedAt?: Date; +}) { + return { + id: input.id, + connectionId: input.connectionId, + companyId: input.companyId, + status: input.status, + activeStep: "push", + progressPercent: input.status === "running" ? 45 : 100, + dryRun: false, + summary: [], + warnings: [], + conflicts: [], + events: [], + report: {}, + idempotencyKey: `key-${input.id}`, + manifestHash: `sha256:${input.id.replace(/-/g, "")}`, + targetUrl: "https://cloud.example.test", + completedAt: input.completedAt, + }; +} diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 41e52190eb8..5d324cd4b02 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -64,6 +64,7 @@ describe("instance settings routes", () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -81,6 +82,7 @@ describe("instance settings routes", () => { experimental: { enableEnvironments: true, enableIsolatedWorkspaces: true, + enableCloudSync: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -123,6 +125,7 @@ describe("instance settings routes", () => { expect(getRes.body).toEqual({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts new file mode 100644 index 00000000000..6669414d697 --- /dev/null +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { normalizeExperimentalSettings } from "../services/instance-settings.js"; + +describe("instance settings service", () => { + it("ignores retired experimental flags without resetting current settings", () => { + expect(normalizeExperimentalSettings({ + enableEnvironments: true, + enableIsolatedWorkspaces: true, + enableCloudSync: true, + autoRestartDevServerWhenIdle: true, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 48, + enableNewestFirstIssueThread: true, + })).toEqual({ + enableEnvironments: true, + enableIsolatedWorkspaces: true, + enableCloudSync: true, + autoRestartDevServerWhenIdle: true, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 48, + }); + }); +}); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index f1023a91533..3cdab7c4ac8 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -166,6 +166,7 @@ vi.mock("../services/index.js", () => ({ }, })), })), + reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })), reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })), routineService: vi.fn(() => ({ tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })), diff --git a/server/src/index.ts b/server/src/index.ts index bb89a46e567..d9c52d27d65 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -33,6 +33,7 @@ import { backfillPrincipalAccessCompatibility, heartbeatService, instanceSettingsService, + reconcileCloudUpstreamRunsOnStartup, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -699,6 +700,19 @@ export async function startServer(): Promise { .catch((err) => { logger.error({ err }, "startup reconciliation of persisted runtime services failed"); }); + + void reconcileCloudUpstreamRunsOnStartup(db as any) + .then((result) => { + if (result.reconciled > 0) { + logger.warn( + { reconciled: result.reconciled }, + "reconciled cloud upstream runs from a previous server process", + ); + } + }) + .catch((err) => { + logger.error({ err }, "startup reconciliation of cloud upstream runs failed"); + }); if (config.heartbeatSchedulerEnabled) { const heartbeat = heartbeatService(db as any, { pluginWorkerManager }); diff --git a/server/src/routes/cloud-upstreams.ts b/server/src/routes/cloud-upstreams.ts new file mode 100644 index 00000000000..085afe470db --- /dev/null +++ b/server/src/routes/cloud-upstreams.ts @@ -0,0 +1,118 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { badRequest, notFound } from "../errors.js"; +import { assertBoardOrgAccess } from "./authz.js"; +import { cloudUpstreamService, instanceSettingsService } from "../services/index.js"; + +export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {}) { + const router = Router(); + const service = cloudUpstreamService(db, options); + const settings = instanceSettingsService(db); + + async function assertEnabled() { + const experimental = await settings.getExperimental(); + if (experimental.enableCloudSync !== true) { + throw notFound("Cloud sync is not enabled"); + } + } + + router.get("/cloud-upstreams", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + const companyId = stringQuery(req.query.companyId, "companyId"); + res.json(await service.list(companyId)); + }); + + router.post("/cloud-upstreams/connect/start", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + const companyId = stringBody(req.body, "companyId"); + const remoteUrl = stringBody(req.body, "remoteUrl"); + const redirectUri = stringBody(req.body, "redirectUri"); + res.json(await service.startConnect({ companyId, remoteUrl, redirectUri })); + }); + + router.post("/cloud-upstreams/connect/finish", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.finishConnect({ + pendingConnectionId: stringBody(req.body, "pendingConnectionId"), + code: stringBody(req.body, "code"), + state: stringBody(req.body, "state"), + })); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/preview", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.preview(req.params.connectionId, stringBody(req.body, "companyId"))); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.createRun({ + connectionId: req.params.connectionId, + companyId: stringBody(req.body, "companyId"), + retryOfRunId: optionalString(req.body?.retryOfRunId), + })); + }); + + router.get("/cloud-upstreams/:connectionId/push-runs/:runId", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.readRun(req.params.connectionId, req.params.runId, stringQuery(req.query.companyId, "companyId"))); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/:runId/cancel", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.cancelRun(req.params.connectionId, req.params.runId, stringBody(req.body, "companyId"))); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/:runId/activation", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.activateRunEntities({ + connectionId: req.params.connectionId, + runId: req.params.runId, + companyId: stringBody(req.body, "companyId"), + entityType: activationEntityTypeBody(req.body), + })); + }); + + return router; +} + +function stringQuery(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw badRequest(`${label} is required`); + } + return value; +} + +function stringBody(body: unknown, key: string): string { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest(`${key} is required`); + } + const value = (body as Record)[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw badRequest(`${key} is required`); + } + return value; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function activationEntityTypeBody(body: unknown): "agents" | "routines" | "monitors" { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest("entityType is required"); + } + const value = (body as Record).entityType; + if (value !== "agents" && value !== "routines" && value !== "monitors") { + throw badRequest("entityType must be agents, routines, or monitors"); + } + return value; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 983562e265a..74987ed4d61 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -19,3 +19,4 @@ export { llmRoutes } from "./llms.js"; export { accessRoutes } from "./access.js"; export { instanceSettingsRoutes } from "./instance-settings.js"; export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js"; +export { cloudUpstreamRoutes } from "./cloud-upstreams.js"; diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts new file mode 100644 index 00000000000..765dd4329b3 --- /dev/null +++ b/server/src/services/cloud-upstreams.ts @@ -0,0 +1,1298 @@ +import crypto, { sign } from "node:crypto"; +import { and, count, desc, eq, sql } from "drizzle-orm"; +import type { + CloudUpstreamConnectStartResponse, + CloudUpstreamActivationDecision, + CloudUpstreamActivationEntityType, + CloudUpstreamConnection, + CloudUpstreamConflict, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamRunEvent, + CloudUpstreamsState, + CloudUpstreamSummaryCount, + CloudUpstreamTarget, + CloudUpstreamWarning, + CompanyPortabilityExportResult, + CompanyPortabilityFileEntry, +} from "@paperclipai/shared"; +import type { Db } from "@paperclipai/db"; +import { + agents, + cloudUpstreamConnections, + cloudUpstreamRuns, + companies, + goals, + issueComments, + issues, + projects, + routines, +} from "@paperclipai/db"; +import { badRequest, conflict, HttpError, notFound } from "../errors.js"; +import { companyPortabilityService } from "./company-portability.js"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; + +const DEFAULT_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"]; +const TRANSFER_SCHEMA = { + family: "paperclip-upstream-transfer", + version: "1.0.0", + major: 1, + minor: 0, +} as const; +const DEFAULT_MAX_ENTITIES_PER_CHUNK = 100; +const DISCOVERY_FETCH_TIMEOUT_MS = 30_000; +const REMOTE_FETCH_TIMEOUT_MS = 120_000; +const CLOUD_CREDENTIAL_PREFIX = "paperclip-cloud-credential:"; + +type NormalizedSha256 = `sha256:${string}`; + +type SourceEntityKey = { + sourceInstanceId: string; + sourceCompanyId: string; + sourceEntityType: string; + sourceEntityId: string; + sourceNaturalKey?: string; +}; + +type UpstreamTransferWarning = { + code: string; + severity: "info" | "warning" | "blocker"; + message: string; + entity?: SourceEntityKey; +}; + +type UpstreamTransferEntityRecord = { + key: SourceEntityKey; + contentHash: NormalizedSha256; + dependencies: SourceEntityKey[]; + warnings: UpstreamTransferWarning[]; +}; + +type LocalUpstreamExportEntity = { + record: UpstreamTransferEntityRecord; + body: Record; + conflictKeys?: string[]; +}; + +type LocalUpstreamExportChunk = { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + payload: { + entityKeys: SourceEntityKey[]; + }; +}; + +type UpstreamTransferManifest = { + schema: typeof TRANSFER_SCHEMA; + source: { + sourceInstanceId: string; + sourceCompanyId: string; + sourceInstanceKeyFingerprint: string; + exporterVersion: string; + sourceSchemaVersion: string; + }; + target: { + targetStackId: string; + targetCompanyId: string; + targetOrigin: string; + supportedSchemaMajor: number; + }; + runId: string; + idempotencyKey: string; + generatedAt: string; + entityCount: number; + entities: UpstreamTransferEntityRecord[]; + chunks: Array & { manifestHash: NormalizedSha256 }>; + warnings: UpstreamTransferWarning[]; + featureFlags: string[]; + manifestHash: NormalizedSha256; +}; + +type LocalUpstreamExportBundle = { + manifest: UpstreamTransferManifest; + entities: LocalUpstreamExportEntity[]; + chunks: LocalUpstreamExportChunk[]; +}; + +type ConnectionRow = typeof cloudUpstreamConnections.$inferSelect; +type RunRow = typeof cloudUpstreamRuns.$inferSelect; + +export function cloudUpstreamService(db: Db, options: { instanceId?: string } = {}) { + const sourceInstanceId = `paperclip-local-${options.instanceId ?? "default"}`; + const portability = companyPortabilityService(db); + + return { + list: async (companyId: string): Promise => { + const [connectionRows, runRows] = await Promise.all([ + db + .select() + .from(cloudUpstreamConnections) + .where(eq(cloudUpstreamConnections.companyId, companyId)) + .orderBy(desc(cloudUpstreamConnections.updatedAt)), + db + .select() + .from(cloudUpstreamRuns) + .where(eq(cloudUpstreamRuns.companyId, companyId)) + .orderBy(desc(cloudUpstreamRuns.createdAt)) + .limit(50), + ]); + return { + connections: connectionRows.map(connectionFromRow), + runs: runRows.map(runFromRow), + }; + }, + + startConnect: async (input: { + companyId: string; + remoteUrl: string; + redirectUri: string; + }): Promise => { + await requireCompany(input.companyId); + const remoteUrl = input.remoteUrl.trim(); + if (!remoteUrl) throw badRequest("Remote URL is required"); + + const discovery = await fetchDiscovery(remoteUrl); + const target = targetFromDiscovery(discovery); + const connectionId = crypto.randomUUID(); + const state = crypto.randomBytes(24).toString("base64url"); + const codeVerifier = crypto.randomBytes(32).toString("base64url"); + const codeChallenge = crypto.createHash("sha256").update(codeVerifier, "utf8").digest("base64url"); + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString(); + const sourceInstanceFingerprint = `sha256:${crypto + .createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; + + const [row] = await db.insert(cloudUpstreamConnections).values({ + id: connectionId, + companyId: input.companyId, + remoteUrl, + sourceInstanceId, + sourceInstanceFingerprint, + sourcePublicKey, + privateKeyPem: await sealCloudUpstreamCredential(privateKey.export({ type: "pkcs8", format: "pem" }).toString()), + tokenStatus: "pending", + scopes: scopesFromDiscovery(discovery), + targetStackId: target.stackId, + targetStackSlug: target.stackSlug, + targetStackDisplayName: target.stackDisplayName, + targetCompanyId: target.companyId, + targetOrigin: target.origin, + targetPrimaryHost: target.primaryHost, + targetProduct: target.product, + targetSchemaMajor: target.schemaMajor, + targetMaxChunkBytes: target.maxChunkBytes, + pendingState: state, + pendingCodeVerifier: await sealCloudUpstreamCredential(codeVerifier), + pendingRedirectUri: input.redirectUri, + pendingTokenUrl: tokenUrlFromDiscovery(discovery), + }).returning(); + if (!row) throw badRequest("Failed to create cloud upstream connection"); + + const authorizationUrl = new URL(consentUrlFromDiscovery(discovery)); + authorizationUrl.searchParams.set("stackId", target.stackId); + authorizationUrl.searchParams.set("redirectUri", input.redirectUri); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set("codeChallenge", codeChallenge); + authorizationUrl.searchParams.set("codeChallengeMethod", "S256"); + authorizationUrl.searchParams.set("sourceInstanceId", sourceInstanceId); + authorizationUrl.searchParams.set("sourceInstanceFingerprint", sourceInstanceFingerprint); + authorizationUrl.searchParams.set("sourcePublicKey", sourcePublicKey); + authorizationUrl.searchParams.set("scopes", row.scopes.join(" ")); + + return { + pendingConnectionId: row.id, + authorizationUrl: authorizationUrl.toString(), + connection: connectionFromRow(row), + }; + }, + + finishConnect: async (input: { + pendingConnectionId: string; + code: string; + state: string; + }): Promise => { + const pending = await getConnectionRow(input.pendingConnectionId); + if (!pending.pendingState || !pending.pendingCodeVerifier || !pending.pendingRedirectUri || !pending.pendingTokenUrl) { + throw notFound("Pending cloud upstream connection was not found"); + } + if (input.state !== pending.pendingState) throw badRequest("Cloud upstream state did not match"); + const tokenResponse = await postJson>(pending.pendingTokenUrl, { + grantType: "authorization_code", + code: input.code, + redirectUri: pending.pendingRedirectUri, + codeVerifier: await unsealCloudUpstreamCredential(pending.pendingCodeVerifier), + }); + const accessToken = stringField(tokenResponse, "accessToken"); + const token = objectField(tokenResponse, "token"); + const expiresAt = optionalString(token.expiresAt) ?? optionalString(tokenResponse.expiresAt); + const [updated] = await db + .update(cloudUpstreamConnections) + .set({ + tokenStatus: "connected", + authorizedGlobalUserId: optionalString(token.globalUserId), + accessToken: await sealCloudUpstreamCredential(accessToken), + tokenId: optionalString(token.id), + tokenExpiresAt: expiresAt ? new Date(expiresAt) : null, + pendingState: null, + pendingCodeVerifier: null, + pendingRedirectUri: null, + pendingTokenUrl: null, + updatedAt: new Date(), + }) + .where(eq(cloudUpstreamConnections.id, pending.id)) + .returning(); + if (!updated) throw notFound("Cloud upstream connection was not found"); + return connectionFromRow(updated); + }, + + preview: async (connectionId: string, companyId: string): Promise => { + const connection = await getConnectionRow(connectionId, companyId); + const basePreview = await localPreview(connection); + if (!basePreview.schemaCompatible || connection.tokenStatus !== "connected") { + return basePreview; + } + + const bundle = await buildBundle(connection, "preview"); + const conflictKeysBySource: Record = {}; + for (const entity of bundle.entities) { + if (!entity.conflictKeys || entity.conflictKeys.length === 0) continue; + conflictKeysBySource[sourceEntityKeyString(entity.record.key)] = [...entity.conflictKeys]; + } + const remotePreview = await remotePost(connection, `/api/companies/${encodeURIComponent(connection.targetCompanyId)}/upstream-imports/preview`, { + manifest: bundle.manifest, + previewShape: "manifest_only", + conflictKeysBySource, + }); + return { + ...basePreview, + warnings: mergeWarnings(basePreview.warnings, warningsFromRemote(remotePreview)), + conflicts: conflictsFromRemote(remotePreview), + }; + }, + + createRun: async (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }): Promise => { + const connection = await getConnectionRow(input.connectionId, input.companyId); + if (connection.tokenStatus !== "connected") { + throw badRequest("Cloud upstream connection is not connected"); + } + await assertNoRunningRun(input.connectionId, input.companyId, db); + const preview = await localPreview(connection); + if (!preview.schemaCompatible) { + throw badRequest("Cloud stack schema is not compatible with this local Paperclip version"); + } + + const bundle = await buildBundle(connection, "apply"); + const runId = crypto.randomUUID(); + const now = new Date(); + const initialEvents = [ + event(now.toISOString(), "connect", "completed", "Connected to the target Paperclip Cloud stack."), + event(now.toISOString(), "scan", "completed", "Scanned the local company inventory."), + event(now.toISOString(), "preview", "completed", "Generated the transfer manifest."), + ...(input.retryOfRunId + ? [event(now.toISOString(), "push", "retrying", `Retrying run ${input.retryOfRunId} with the same import ledger idempotency key.`)] + : []), + ]; + const created = await db.transaction(async (tx) => { + await tx.execute( + sql`select ${cloudUpstreamConnections.id} from ${cloudUpstreamConnections} where ${cloudUpstreamConnections.id} = ${connection.id} and ${cloudUpstreamConnections.companyId} = ${connection.companyId} for update`, + ); + await assertNoRunningRun(input.connectionId, input.companyId, tx); + const [row] = await tx.insert(cloudUpstreamRuns).values({ + id: runId, + connectionId: connection.id, + companyId: connection.companyId, + status: "running", + activeStep: "push", + progressPercent: 45, + dryRun: false, + retryOfRunId: input.retryOfRunId ?? null, + summary: preview.summary, + warnings: preview.warnings, + conflicts: preview.conflicts, + events: initialEvents, + report: {}, + idempotencyKey: bundle.manifest.idempotencyKey, + manifestHash: bundle.manifest.manifestHash, + targetUrl: connection.targetOrigin, + createdAt: now, + updatedAt: now, + }).returning(); + return row; + }); + if (!created) throw badRequest("Failed to create cloud upstream run"); + + try { + const remoteRun = await remotePost(connection, `/api/companies/${encodeURIComponent(connection.targetCompanyId)}/upstream-imports/runs`, { + mode: "apply", + manifest: bundle.manifest, + entities: bundle.entities, + }); + const remoteRunId = remoteRunIdFromResponse(remoteRun); + const pushedRun = await updateRunIfRunning(runId, { + remoteRunId, + activeStep: "push", + progressPercent: 60, + events: [ + ...initialEvents, + event(new Date().toISOString(), "push", "updated", "Created or resumed the cloud import ledger run."), + ], + }); + if (pushedRun.status !== "running") return pushedRun; + + for (const chunk of bundle.chunks) { + await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/chunks`, chunk); + } + const verifiedRun = await updateRunIfRunning(runId, { + activeStep: "verify", + progressPercent: 82, + events: [ + ...initialEvents, + event(new Date().toISOString(), "push", "completed", `Uploaded ${bundle.chunks.length} manifest chunk${bundle.chunks.length === 1 ? "" : "s"}.`), + ], + }); + if (verifiedRun.status !== "running") return verifiedRun; + + const applied = await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/apply`, {}); + const remoteEvents = await remoteGet(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/events`).catch(() => null); + const completedAt = new Date(); + const finalEvents = [ + ...initialEvents, + event(completedAt.toISOString(), "push", "completed", "Pushed mapped objects without duplicate creation."), + event(completedAt.toISOString(), "verify", "completed", "Verified the cloud import ledger and generated a run report."), + event(completedAt.toISOString(), "activate", "completed", "Activation checklist is ready for manual unpause decisions."), + ...eventsFromRemote(remoteEvents), + ]; + const finalRun = await updateRunIfRunning(runId, { + remoteRunId, + status: "succeeded", + activeStep: "activate", + progressPercent: 100, + warnings: mergeWarnings(preview.warnings, warningsFromRemote(applied)), + conflicts: conflictsFromRemote(applied), + events: finalEvents, + report: { + runId, + remoteRunId, + target: targetFromConnectionRow(connection), + manifestHash: bundle.manifest.manifestHash, + idempotencyKey: bundle.manifest.idempotencyKey, + retryOfRunId: input.retryOfRunId ?? null, + result: applied, + events: remoteEvents, + }, + completedAt, + }); + if (finalRun.status === "succeeded") { + await db + .update(cloudUpstreamConnections) + .set({ lastRunId: finalRun.id, updatedAt: new Date() }) + .where(eq(cloudUpstreamConnections.id, connection.id)); + } + return finalRun; + } catch (error) { + const failedAt = new Date(); + const failure = cloudUpstreamRemoteFailureReport(error); + return updateRunIfRunning(runId, { + status: "failed", + activeStep: "push", + progressPercent: 100, + events: [ + ...initialEvents, + event(failedAt.toISOString(), "push", "failed", failure.errorMessage ?? failure.error), + ], + report: { + runId, + target: targetFromConnectionRow(connection), + manifestHash: bundle.manifest.manifestHash, + idempotencyKey: bundle.manifest.idempotencyKey, + retryOfRunId: input.retryOfRunId ?? null, + ...failure, + }, + completedAt: failedAt, + }); + } + }, + + readRun: async (connectionId: string, runId: string, companyId: string): Promise => { + const row = await getRunRow(connectionId, runId, companyId); + return runFromRow(row); + }, + + cancelRun: async (connectionId: string, runId: string, companyId: string): Promise => { + const row = await getRunRow(connectionId, runId, companyId); + if (row.status !== "running") return runFromRow(row); + const connection = await getConnectionRow(connectionId, companyId); + if (row.remoteRunId) { + await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(row.remoteRunId)}/cancel`, {}).catch(() => null); + } + return updateRun(row.id, { + status: "cancelled", + activeStep: "push", + progressPercent: 100, + completedAt: new Date(), + events: [ + ...row.events, + event(new Date().toISOString(), "push", "failed", "Push cancelled locally before remote apply completed."), + ], + }); + }, + + activateRunEntities: async (input: { + connectionId: string; + runId: string; + companyId: string; + entityType: CloudUpstreamActivationEntityType; + }): Promise => { + const row = await getRunRow(input.connectionId, input.runId, input.companyId); + assertActivationEntityType(input.entityType); + if (row.status !== "succeeded") { + throw badRequest("Only succeeded cloud upstream runs can activate imported entities"); + } + + const activatedAt = new Date().toISOString(); + const count = summaryCount(row.summary, input.entityType); + const nextDecision: CloudUpstreamActivationDecision = { + entityType: input.entityType, + count, + status: "activated", + activatedAt, + }; + const report = asRecord(row.report); + const activationChecklist = activationChecklistFromReport(report); + const label = activationEntityLabel(input.entityType, count); + + return updateRun(row.id, { + report: { + ...report, + activationChecklist: { + ...activationChecklist, + [input.entityType]: nextDecision, + }, + }, + events: [ + ...row.events, + event(activatedAt, "activate", "completed", `Activated ${count} imported ${label}.`), + ], + }); + }, + }; + + async function requireCompany(companyId: string) { + const row = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).then((rows) => rows[0]); + if (!row) throw notFound("Company was not found"); + } + + async function getConnectionRow(connectionId: string, companyId?: string): Promise { + const row = await db + .select() + .from(cloudUpstreamConnections) + .where(companyId + ? and(eq(cloudUpstreamConnections.id, connectionId), eq(cloudUpstreamConnections.companyId, companyId)) + : eq(cloudUpstreamConnections.id, connectionId)) + .then((rows) => rows[0]); + if (!row) throw notFound("Cloud upstream connection was not found"); + return row; + } + + async function getRunRow(connectionId: string, runId: string, companyId: string): Promise { + const row = await db + .select() + .from(cloudUpstreamRuns) + .where(and( + eq(cloudUpstreamRuns.id, runId), + eq(cloudUpstreamRuns.connectionId, connectionId), + eq(cloudUpstreamRuns.companyId, companyId), + )) + .then((rows) => rows[0]); + if (!row) throw notFound("Cloud upstream run was not found"); + return row; + } + + async function assertNoRunningRun( + connectionId: string, + companyId: string, + database: Pick, + ) { + const [running] = await database + .select({ id: cloudUpstreamRuns.id }) + .from(cloudUpstreamRuns) + .where(and( + eq(cloudUpstreamRuns.connectionId, connectionId), + eq(cloudUpstreamRuns.companyId, companyId), + eq(cloudUpstreamRuns.status, "running"), + )) + .limit(1); + if (running) { + throw conflict("A cloud upstream run is already running for this connection", { runId: running.id }); + } + } + + async function updateRun(runId: string, patch: Partial): Promise { + const [updated] = await db + .update(cloudUpstreamRuns) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(cloudUpstreamRuns.id, runId)) + .returning(); + if (!updated) throw notFound("Cloud upstream run was not found"); + return runFromRow(updated); + } + + async function updateRunIfRunning(runId: string, patch: Partial): Promise { + const [updated] = await db + .update(cloudUpstreamRuns) + .set({ ...patch, updatedAt: new Date() }) + .where(and(eq(cloudUpstreamRuns.id, runId), eq(cloudUpstreamRuns.status, "running"))) + .returning(); + if (updated) return runFromRow(updated); + + const [current] = await db + .select() + .from(cloudUpstreamRuns) + .where(eq(cloudUpstreamRuns.id, runId)) + .limit(1); + if (!current) throw notFound("Cloud upstream run was not found"); + return runFromRow(current); + } + + async function localPreview(connection: ConnectionRow): Promise { + return { + connectionId: connection.id, + sourceCompanyId: connection.companyId, + target: targetFromConnectionRow(connection), + schemaCompatible: connection.targetSchemaMajor === TRANSFER_SCHEMA.major, + summary: await buildSummary(connection.companyId), + warnings: buildWarnings(connection.targetSchemaMajor), + conflicts: [], + generatedAt: new Date().toISOString(), + }; + } + + async function buildSummary(companyId: string): Promise { + const [agentCount, projectCount, goalCount, issueCount, commentCount, routineCount] = await Promise.all([ + db.select({ count: count() }).from(agents).where(eq(agents.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(projects).where(eq(projects.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(goals).where(eq(goals.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(issues).where(eq(issues.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(issueComments).where(eq(issueComments.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(routines).where(eq(routines.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + ]); + return [ + { key: "companies", label: "Companies", count: 1 }, + { key: "goals", label: "Goals", count: goalCount }, + { key: "projects", label: "Projects", count: projectCount }, + { key: "agents", label: "Agents", count: agentCount }, + { key: "issues", label: "Issues", count: issueCount }, + { key: "comments", label: "Comments", count: commentCount }, + { key: "routines", label: "Routines", count: routineCount }, + { key: "warnings", label: "Warnings", count: buildWarnings(TRANSFER_SCHEMA.major).length }, + ]; + } + + async function buildBundle(connection: ConnectionRow, mode: "preview" | "apply"): Promise { + const exported = await portability.exportBundle(connection.companyId, { + include: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + expandReferencedSkills: true, + }); + const sourceHash = normalizedContentHash({ + manifest: exported.manifest, + files: exported.files, + }); + const source = { + sourceInstanceId: connection.sourceInstanceId, + sourceCompanyId: connection.companyId, + sourceInstanceKeyFingerprint: connection.sourceInstanceFingerprint, + exporterVersion: "paperclip-local-cloud-ui-v1", + sourceSchemaVersion: TRANSFER_SCHEMA.version, + }; + const target = { + targetStackId: connection.targetStackId, + targetCompanyId: connection.targetCompanyId, + targetOrigin: connection.targetOrigin, + supportedSchemaMajor: connection.targetSchemaMajor, + }; + const idempotencyKey = [ + mode, + connection.sourceInstanceId, + connection.companyId, + connection.targetStackId, + sourceHash, + ].join(":"); + return buildLocalUpstreamExportBundle({ + source, + target, + runId: `local-${mode}-${shortHash(idempotencyKey)}`, + idempotencyKey, + entities: buildEntitiesFromPortableExport(connection.companyId, connection.sourceInstanceId, exported), + warnings: exported.warnings.map((message): UpstreamTransferWarning => ({ + code: "local_company_export_warning", + severity: "warning", + message, + })), + featureFlags: ["cloud_sync"], + maxEntitiesPerChunk: DEFAULT_MAX_ENTITIES_PER_CHUNK, + }); + } +} + +async function fetchDiscovery(remoteUrl: string): Promise> { + const parsed = new URL(remoteUrl); + if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") { + throw badRequest("Cloud upstream targets require HTTPS except localhost development"); + } + const stackId = firstPathSegment(parsed.pathname); + const discoveryUrl = new URL("/.well-known/paperclip-upstream", parsed.origin); + if (stackId) { + discoveryUrl.searchParams.set("stackId", stackId); + } + const response = await fetchWithTimeout(discoveryUrl, undefined, DISCOVERY_FETCH_TIMEOUT_MS); + if (!response.ok) { + throw badRequest(`Cloud upstream discovery failed: ${response.status}`); + } + return await response.json() as Record; +} + +export async function reconcileCloudUpstreamRunsOnStartup(db: Db, now = new Date()): Promise<{ reconciled: number }> { + const runningRows = await db + .select() + .from(cloudUpstreamRuns) + .where(eq(cloudUpstreamRuns.status, "running")); + if (runningRows.length === 0) return { reconciled: 0 }; + + for (const row of runningRows) { + const report = asRecord(row.report); + await db + .update(cloudUpstreamRuns) + .set({ + status: "failed", + activeStep: row.activeStep, + progressPercent: 100, + completedAt: now, + updatedAt: now, + events: [ + ...safeRunEvents(row.events), + event( + now.toISOString(), + cloudUpstreamStep(row.activeStep), + "failed", + "Marked failed on server startup because the previous process stopped while the cloud upstream run was in progress.", + ), + ], + report: { + ...report, + error: optionalString(report.error) ?? "orphaned_running_run", + errorMessage: optionalString(report.errorMessage) + ?? "The server restarted while this cloud upstream run was running, so Paperclip marked it failed instead of leaving it stuck.", + reconciledAt: now.toISOString(), + }, + }) + .where(eq(cloudUpstreamRuns.id, row.id)); + } + + return { reconciled: runningRows.length }; +} + +function firstPathSegment(pathname: string): string | null { + const segment = pathname.split("/").find(Boolean); + return segment && segment.toLowerCase() !== "dashboard" ? segment : null; +} + +function targetFromDiscovery(discovery: Record): CloudUpstreamTarget { + const stack = objectField(discovery, "stack"); + const transfer = objectField(discovery, "transfer"); + const schema = optionalObject(transfer.schema); + const origin = stringField(stack, "origin"); + return { + stackId: stringField(stack, "id"), + stackSlug: optionalString(stack.slug), + stackDisplayName: optionalString(stack.displayName), + companyId: stringField(stack, "companyId"), + primaryHost: optionalString(stack.primaryHost) ?? new URL(origin).host, + origin, + product: optionalString(discovery.product) ?? "Paperclip Cloud", + schemaMajor: optionalNumber(schema?.major) ?? numberField(transfer, "supportedSchemaMajor"), + maxChunkBytes: optionalNumber(transfer.maxChunkBytes) ?? 8 * 1024 * 1024, + }; +} + +function targetFromConnectionRow(row: ConnectionRow): CloudUpstreamTarget { + return { + stackId: row.targetStackId, + stackSlug: row.targetStackSlug, + stackDisplayName: row.targetStackDisplayName, + companyId: row.targetCompanyId, + primaryHost: row.targetPrimaryHost, + origin: row.targetOrigin, + product: row.targetProduct, + schemaMajor: row.targetSchemaMajor, + maxChunkBytes: row.targetMaxChunkBytes, + }; +} + +function connectionFromRow(row: ConnectionRow): CloudUpstreamConnection { + return { + id: row.id, + companyId: row.companyId, + remoteUrl: row.remoteUrl, + target: targetFromConnectionRow(row), + tokenStatus: cloudUpstreamTokenStatus(row.tokenStatus), + scopes: row.scopes, + authorizedGlobalUserId: row.authorizedGlobalUserId, + expiresAt: row.tokenExpiresAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + lastRunId: row.lastRunId, + }; +} + +function runFromRow(row: RunRow): CloudUpstreamRun { + return { + id: row.id, + connectionId: row.connectionId, + companyId: row.companyId, + status: cloudUpstreamRunStatus(row.status), + activeStep: cloudUpstreamStep(row.activeStep), + progressPercent: row.progressPercent, + dryRun: row.dryRun, + summary: row.summary, + warnings: row.warnings, + conflicts: row.conflicts, + events: row.events, + targetUrl: row.targetUrl, + report: row.report, + retryOfRunId: row.retryOfRunId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, + }; +} + +function scopesFromDiscovery(discovery: Record): string[] { + const auth = objectField(discovery, "auth"); + const scopes = Array.isArray(auth.scopes) ? auth.scopes.map(String).filter(Boolean) : []; + return scopes.length > 0 ? scopes : [...DEFAULT_SCOPES]; +} + +function consentUrlFromDiscovery(discovery: Record): string { + const pkce = objectField(objectField(discovery, "auth"), "pkce"); + return optionalString(pkce.consentUrl) ?? stringField(pkce, "authorizeUrl"); +} + +function tokenUrlFromDiscovery(discovery: Record): string { + return stringField(objectField(objectField(discovery, "auth"), "pkce"), "tokenUrl"); +} + +function buildWarnings(schemaMajor: number): CloudUpstreamWarning[] { + const warnings: CloudUpstreamWarning[] = [ + { + code: "imported_automations_paused", + severity: "warning", + title: "Automations stay paused", + detail: "Imported agents, routines, and monitors require explicit activation after the push.", + }, + { + code: "unmatched_users_import_as_historical_authors", + severity: "warning", + title: "Unmatched users become historical authors", + detail: "Invite now remains a secondary action after the transfer is complete.", + }, + { + code: "secret_values_redacted", + severity: "warning", + title: "Secret values are not transferred", + detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.", + }, + ]; + if (schemaMajor !== TRANSFER_SCHEMA.major) { + warnings.unshift({ + code: "schema_mismatch", + severity: "blocker", + title: "Cloud stack upgrade required", + detail: `This local build uses upstream schema ${TRANSFER_SCHEMA.major}, but the cloud stack reports schema ${schemaMajor}.`, + }); + } + return warnings; +} + +type LocalUpstreamExportEntityInput = { + key: SourceEntityKey; + body: Record; + dependencies?: SourceEntityKey[]; + warnings?: UpstreamTransferWarning[]; + conflictKeys?: string[]; +}; + +function buildEntitiesFromPortableExport( + localCompanyId: string, + sourceInstanceId: string, + exported: CompanyPortabilityExportResult, +): LocalUpstreamExportEntityInput[] { + const companyKey: SourceEntityKey = { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company", + sourceEntityId: localCompanyId, + sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId, + }; + const entities: LocalUpstreamExportEntityInput[] = [ + { + key: companyKey, + body: { + kind: "paperclip_company_portability_manifest", + manifest: exported.manifest, + rootPath: exported.rootPath, + paperclipExtensionPath: exported.paperclipExtensionPath, + fileCount: Object.keys(exported.files).length, + }, + conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`], + }, + ]; + + for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) { + entities.push({ + key: { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company_setting", + sourceEntityId: shortHash(filePath), + sourceNaturalKey: filePath, + }, + body: { + kind: "paperclip_portable_file", + path: filePath, + entry: normalizePortableFileEntry(entry), + }, + dependencies: [companyKey], + conflictKeys: [`portable_file:${filePath}`], + }); + } + return entities; +} + +function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record { + if (typeof entry === "string") { + return { encoding: "utf8", data: entry }; + } + return { ...entry }; +} + +function buildLocalUpstreamExportBundle(input: { + source: UpstreamTransferManifest["source"]; + target: UpstreamTransferManifest["target"]; + runId: string; + idempotencyKey: string; + entities: LocalUpstreamExportEntityInput[]; + warnings?: UpstreamTransferWarning[]; + featureFlags?: string[]; + maxEntitiesPerChunk?: number; +}): LocalUpstreamExportBundle { + const entities = input.entities.map((entity) => ({ + record: { + key: entity.key, + contentHash: normalizedContentHash(entity.body), + dependencies: entity.dependencies ?? [], + warnings: entity.warnings ?? [], + }, + body: entity.body, + conflictKeys: entity.conflictKeys, + })); + const chunksWithoutManifestHash = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? DEFAULT_MAX_ENTITIES_PER_CHUNK); + const manifestWithoutHash = { + schema: TRANSFER_SCHEMA, + source: input.source, + target: input.target, + runId: input.runId, + idempotencyKey: input.idempotencyKey, + generatedAt: new Date(0).toISOString(), + entityCount: entities.length, + entities: entities.map((entity) => entity.record), + chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => chunk), + warnings: input.warnings ?? [], + featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(), + }; + const manifestHash = normalizedContentHash(manifestWithoutHash); + return { + manifest: { + ...manifestWithoutHash, + chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => ({ ...chunk, manifestHash })), + manifestHash, + }, + entities, + chunks: chunksWithoutManifestHash, + }; +} + +function buildLocalChunks(entities: LocalUpstreamExportEntity[], maxEntitiesPerChunk: number): LocalUpstreamExportChunk[] { + if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) { + throw new Error("maxEntitiesPerChunk must be a positive integer"); + } + if (entities.length === 0) return []; + + const groups: LocalUpstreamExportEntity[][] = []; + for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) { + groups.push(entities.slice(index, index + maxEntitiesPerChunk)); + } + + return groups.map((group, index) => { + const payload = { + entityKeys: group.map((entity) => entity.record.key), + }; + return { + chunkIndex: index, + totalChunks: groups.length, + byteLength: Buffer.byteLength(canonicalJson(payload)), + sha256: normalizedContentHash(payload), + payload, + }; + }); +} + +async function remoteGet(connection: ConnectionRow, path: string): Promise { + const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, { + method: "GET", + headers: await proofHeaders(connection, "GET", path), + }, REMOTE_FETCH_TIMEOUT_MS); + return parseRemoteResponse(response); +} + +async function remotePost(connection: ConnectionRow, path: string, body: unknown): Promise { + const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...await proofHeaders(connection, "POST", path), + }, + body: JSON.stringify(body), + }, REMOTE_FETCH_TIMEOUT_MS); + return parseRemoteResponse(response); +} + +async function proofHeaders(connection: ConnectionRow, method: string, pathAndSearch: string): Promise> { + if (!connection.accessToken || !connection.tokenId) { + throw badRequest("Cloud upstream connection is missing an import token"); + } + const accessToken = await unsealCloudUpstreamCredential(connection.accessToken); + const privateKeyPem = await unsealCloudUpstreamCredential(connection.privateKeyPem); + const timestamp = new Date().toISOString(); + const nonce = crypto.randomUUID(); + const payload = [ + method, + new URL(connection.targetOrigin).host.toLowerCase(), + pathAndSearch, + connection.tokenId, + connection.sourceInstanceId, + timestamp, + nonce, + ].join("\n"); + return { + Authorization: `Bearer ${accessToken}`, + "X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId, + "X-Paperclip-Upstream-Proof-Timestamp": timestamp, + "X-Paperclip-Upstream-Proof-Nonce": nonce, + "X-Paperclip-Upstream-Proof-Signature": sign( + null, + Buffer.from(payload, "utf8"), + privateKeyPem, + ).toString("base64url"), + }; +} + +async function parseRemoteResponse(response: Response): Promise { + const text = await response.text(); + const parsed = text.trim() ? safeParseJson(text) : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Cloud upstream request failed: ${response.status}`; + throw badRequest(message, parsed); + } + return parsed; +} + +async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit | undefined, timeoutMs: number): Promise { + return fetch(input, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }); +} + +export async function sealCloudUpstreamCredential(value: string): Promise { + const prepared = await localEncryptedProvider.createSecret({ value }); + return `${CLOUD_CREDENTIAL_PREFIX}${JSON.stringify(prepared.material)}`; +} + +export async function unsealCloudUpstreamCredential(value: string): Promise { + if (!value.startsWith(CLOUD_CREDENTIAL_PREFIX)) return value; + const encoded = value.slice(CLOUD_CREDENTIAL_PREFIX.length); + const parsed = safeParseJson(encoded); + const material = optionalObject(parsed); + if (!material) { + throw badRequest("Invalid encrypted cloud upstream credential material"); + } + return localEncryptedProvider.resolveVersion({ + material, + externalRef: null, + }); +} + +export function cloudUpstreamRemoteFailureReport(error: unknown): { + error: string; + errorMessage?: string; + details?: unknown; +} { + const fallback = error instanceof Error ? error.message : String(error); + if (!(error instanceof HttpError)) { + return { error: fallback }; + } + const remote = remoteErrorBody(error.details); + return { + error: remote.error ?? error.message, + ...(remote.message ? { errorMessage: remote.message } : {}), + ...(error.details !== undefined ? { details: error.details } : {}), + }; +} + +function remoteErrorBody(details: unknown): { error?: string; message?: string } { + const record = optionalObject(details); + if (!record) return {}; + return { + error: optionalString(record.error) ?? undefined, + message: optionalString(record.message) ?? undefined, + }; +} + +async function postJson(url: string, body: unknown): Promise { + const response = await fetchWithTimeout(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, DISCOVERY_FETCH_TIMEOUT_MS); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw badRequest((payload as { error?: string } | null)?.error ?? `Cloud upstream request failed: ${response.status}`); + } + return payload as T; +} + +function remoteRunIdFromResponse(value: unknown): string { + const record = asRecord(value); + const run = asRecord(record.run); + const id = optionalString(run.id); + if (!id) throw badRequest("Remote upstream importer did not return a run id"); + return id; +} + +function warningsFromRemote(value: unknown): CloudUpstreamWarning[] { + const record = asRecord(value); + const warnings = Array.isArray(record.warnings) ? record.warnings : []; + return warnings.map((warning, index): CloudUpstreamWarning => { + const item = asRecord(warning); + const code = optionalString(item.code) ?? `remote_warning_${index}`; + const severity = item.severity === "blocker" ? "blocker" : "warning"; + const message = optionalString(item.message) ?? optionalString(item.detail) ?? "Remote importer warning."; + return { + code, + severity, + title: titleFromCode(code), + detail: message, + }; + }); +} + +function conflictsFromRemote(value: unknown): CloudUpstreamConflict[] { + const record = asRecord(value); + const conflicts = Array.isArray(record.conflicts) ? record.conflicts : []; + return conflicts.map((conflict, index): CloudUpstreamConflict => { + const item = asRecord(conflict); + const source = asRecord(item.source); + return { + id: optionalString(item.id) ?? `remote-conflict-${index}`, + entityType: optionalString(item.entityType) ?? optionalString(source.sourceEntityType) ?? "entity", + sourceLabel: optionalString(item.sourceLabel) ?? optionalString(source.sourceNaturalKey) ?? optionalString(source.sourceEntityId) ?? "Source entity", + targetLabel: optionalString(item.targetLabel) ?? optionalString(item.targetEntityId) ?? "Cloud entity", + plannedAction: "blocked", + reason: optionalString(item.reason) ?? "Remote importer reported a conflict.", + }; + }); +} + +function eventsFromRemote(value: unknown): CloudUpstreamRunEvent[] { + const record = asRecord(value); + const events = Array.isArray(record.events) ? record.events : []; + return events.slice(-25).map((remote, index) => { + const item = asRecord(remote); + const action = optionalString(item.action) ?? "updated"; + return event( + optionalString(item.createdAt) ?? new Date().toISOString(), + "verify", + action.includes("created") ? "created" : "updated", + `Cloud importer ${action.replace(/_/g, " ")}${index >= 0 ? "." : "."}`, + ); + }); +} + +function safeRunEvents(value: unknown): CloudUpstreamRunEvent[] { + return Array.isArray(value) ? value as CloudUpstreamRunEvent[] : []; +} + +function assertActivationEntityType(value: string): asserts value is CloudUpstreamActivationEntityType { + if (value !== "agents" && value !== "routines" && value !== "monitors") { + throw badRequest("entityType must be agents, routines, or monitors"); + } +} + +function summaryCount(summary: unknown, key: CloudUpstreamActivationEntityType): number { + if (!Array.isArray(summary)) return 0; + const item = summary.find((entry) => asRecord(entry).key === key); + const count = asRecord(item).count; + return typeof count === "number" && Number.isFinite(count) ? count : 0; +} + +function activationChecklistFromReport(report: Record): Record { + const value = asRecord(report.activationChecklist); + const decisions: Record = {}; + for (const [key, decision] of Object.entries(value)) { + if (key !== "agents" && key !== "routines" && key !== "monitors") continue; + const item = asRecord(decision); + decisions[key] = { + entityType: key, + count: typeof item.count === "number" && Number.isFinite(item.count) ? item.count : 0, + status: item.status === "activated" ? "activated" : "paused", + activatedAt: optionalString(item.activatedAt), + }; + } + return decisions; +} + +function activationEntityLabel(entityType: CloudUpstreamActivationEntityType, count: number): string { + const singular = entityType === "agents" ? "agent" : entityType === "routines" ? "routine" : "monitor"; + return `${singular}${count === 1 ? "" : "s"}`; +} + +function mergeWarnings(base: CloudUpstreamWarning[], extra: CloudUpstreamWarning[]): CloudUpstreamWarning[] { + const byCode = new Map(); + for (const warning of [...base, ...extra]) byCode.set(warning.code, warning); + return [...byCode.values()]; +} + +function event( + at: string, + phase: CloudUpstreamRunEvent["phase"], + type: CloudUpstreamRunEvent["type"], + message: string, +): CloudUpstreamRunEvent { + return { + id: crypto.randomUUID(), + at, + phase, + type, + message, + }; +} + +function normalizedContentHash(value: unknown): NormalizedSha256 { + return `sha256:${crypto.createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]), + ); +} + +function shortHash(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function sourceEntityKeyString(key: SourceEntityKey): string { + return [key.sourceInstanceId, key.sourceCompanyId, key.sourceEntityType, key.sourceEntityId] + .map((part) => encodeURIComponent(part)) + .join("/"); +} + +function titleFromCode(code: string): string { + return code + .replace(/_/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function objectField(value: Record, key: string): Record { + const field = value[key]; + if (!field || typeof field !== "object" || Array.isArray(field)) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field as Record; +} + +function stringField(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string" || field.length === 0) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field; +} + +function numberField(value: Record, key: string): number { + const field = value[key]; + if (typeof field !== "number" || !Number.isFinite(field)) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function optionalNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function optionalObject(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function cloudUpstreamTokenStatus(value: string): CloudUpstreamConnection["tokenStatus"] { + return value === "connected" || value === "expired" || value === "revoked" ? value : "pending"; +} + +function cloudUpstreamRunStatus(value: string): CloudUpstreamRun["status"] { + return value === "previewed" || value === "running" || value === "succeeded" || value === "failed" || value === "cancelled" + ? value + : "failed"; +} + +function cloudUpstreamStep(value: string): CloudUpstreamRun["activeStep"] { + return value === "connect" || value === "scan" || value === "preview" || value === "push" || value === "verify" || value === "activate" + ? value + : "push"; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 17905f11ee0..8704b986cd2 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -265,7 +265,17 @@ export function companyService(db: Db) { remove: (id: string) => db.transaction(async (tx) => { // Delete from child tables in dependency order + const companyRunIds = await tx + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, id)); + await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id)); + if (companyRunIds.length > 0) { + await tx + .delete(heartbeatRunEvents) + .where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id))); + } await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id)); await tx.delete(activityLog).where(eq(activityLog.companyId, id)); await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id)); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index a8570d2d27f..18857b7ca93 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -59,6 +59,7 @@ export type { } from "./authorization.js"; export { boardAuthService } from "./board-auth.js"; export { instanceSettingsService } from "./instance-settings.js"; +export { cloudUpstreamService, reconcileCloudUpstreamRunsOnStartup } from "./cloud-upstreams.js"; export { companyPortabilityService } from "./company-portability.js"; export { environmentService } from "./environments.js"; export { executionWorkspaceService } from "./execution-workspaces.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index c447a920efd..8b02f57c39d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -15,9 +15,11 @@ import { import { eq } from "drizzle-orm"; const DEFAULT_SINGLETON_KEY = "default"; +const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip(); +const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip(); function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { - const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {}); + const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {}); if (parsed.success) { return { censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false, @@ -35,12 +37,13 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { }; } -function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings { - const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {}); +export function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings { + const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {}); if (parsed.success) { return { enableEnvironments: parsed.data.enableEnvironments ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, + enableCloudSync: parsed.data.enableCloudSync ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -51,6 +54,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin return { enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/ui/src/App.tsx b/ui/src/App.tsx index abc17fc2487..905804a9738 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,6 +32,8 @@ import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; +import { CloudUpstream } from "./pages/CloudUpstream"; +import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { Secrets } from "./pages/Secrets"; @@ -72,6 +74,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -279,6 +282,7 @@ export function App() { } /> } /> } /> + } /> }> } /> diff --git a/ui/src/api/cloudUpstreams.ts b/ui/src/api/cloudUpstreams.ts new file mode 100644 index 00000000000..48ddff5ced6 --- /dev/null +++ b/ui/src/api/cloudUpstreams.ts @@ -0,0 +1,40 @@ +import type { + CloudUpstreamActivationEntityType, + CloudUpstreamConnectStartResponse, + CloudUpstreamConnection, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamsState, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const cloudUpstreamsApi = { + list: (companyId: string) => + api.get(`/cloud-upstreams?companyId=${encodeURIComponent(companyId)}`), + startConnect: (input: { companyId: string; remoteUrl: string; redirectUri: string }) => + api.post("/cloud-upstreams/connect/start", input), + finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) => + api.post("/cloud-upstreams/connect/finish", input), + preview: (connectionId: string, input: { companyId: string }) => + api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, input), + createRun: (connectionId: string, input: { companyId: string; retryOfRunId?: string | null }) => + api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}), + getRun: (connectionId: string, runId: string, companyId: string) => + api.get( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}?companyId=${encodeURIComponent(companyId)}`, + ), + cancelRun: (connectionId: string, runId: string, input: { companyId: string }) => + api.post( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`, + input, + ), + activateEntities: ( + connectionId: string, + runId: string, + input: { companyId: string; entityType: CloudUpstreamActivationEntityType }, + ) => + api.post( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/activation`, + input, + ), +}; diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 6514cd4a99b..cdff2d8bce6 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -11,6 +11,9 @@ const mockSidebarBadgesApi = vi.hoisted(() => ({ get: vi.fn(), })); const mockUsePluginSlots = vi.hoisted(() => vi.fn()); +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); vi.mock("@/lib/router", () => ({ Link: ({ @@ -66,6 +69,10 @@ vi.mock("@/plugins/slots", () => ({ usePluginSlots: mockUsePluginSlots, })); +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -93,6 +100,9 @@ describe("CompanySettingsSidebar", () => { isLoading: false, errorMessage: null, }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: false, + }); }); afterEach(() => { @@ -121,6 +131,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); expect(container.textContent).toContain("Members"); + expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Secrets"); expect(sidebarNavItemMock).toHaveBeenCalledWith( @@ -210,4 +221,36 @@ describe("CompanySettingsSidebar", () => { root.unmount(); }); }); + + it("shows cloud upstream only when cloud sync is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: true, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Cloud upstream"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/cloud-upstream", + label: "Cloud upstream", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index ed84e9baed3..ad05f4b1d9c 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react"; +import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; +import { instanceSettingsApi } from "@/api/instanceSettings"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; @@ -35,6 +36,11 @@ export function CompanySettingsSidebar() { retry: false, refetchInterval: 15_000, }); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const showCloudUpstream = experimentalSettings?.enableCloudSync === true; return (