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] [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 f955e677..7a3d1f7b 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 2753512b..e1810f75 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 6a7039ca..95b632d4 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 c8080e56..d1bd1034 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 00000000..86483751 --- /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 0cb737bb..ee87e6cb 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 00000000..4d05e171 --- /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 00000000..6e78c7e5 --- /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 a47bae7c..7aaa96d3 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 9c18f610..8a2e44c0 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 bcff32c2..5fc99281 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 ea3cb491..95ce4c14 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 c1f73d98..b9a02d5c 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 8b85b0b7..3264afbc 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 92db6d69..2ca0fa9a 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 24bc871b..48075b2f 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 de34e8ae..299af7c6 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 bcbb7f4e..456f4a9e 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 8f90af70..e2ce8f63 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 b8216836..8923803e 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 9bfae39e..a50b6d8f 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 00000000..8cded9c8 --- /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 f8e0a38e..b7f15781 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 9c295eae..b0c92acb 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 a851acf0..9afa40f4 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 18aea077..14c227dd 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 6f962912..f9330a48 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 1984aa6b..210c8845 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 cbc900cf..0a857b44 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 00000000..9db328d4 --- /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 f9ac3e64..a0daac80 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 b33fd21d..198efd9c 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 ac5c9313..1df94d90 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 c29fca24..f35ef610 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 0eeebe52..d0549059 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 218d653f..28a49a69 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 84537a9b..1001f694 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 3063fb70..40730c05 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 2f25d3ab..f6e6ac1c 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 00000000..933c460e --- /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 dd672d67..2e2821d7 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 df1d8e7f..c048052a 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 00000000..6e54689f --- /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 fae007e0..6834f763 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 6413ba21..7503bdf6 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 2dc70626..bd196a60 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 ecb3db9b..ad4ee770 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 1b0db770..0b7392d2 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 818c5d7d..785dbf18 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 167037fb..42539b21 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 00000000..2d536591 --- /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 00000000..87737816 --- /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 0c198488..52fe1c84 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 25104161..bb66dae7 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 4f578fda..626ed448 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 c288b6e6..f1023a91 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 421a72a4..a7c9a4ed 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 3c84b9f5..6eb20249 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 4c87b732..c4784406 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 c039f758..bb89a46e 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 03f6ef58..07c2dd6d 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 96a004ac..2de26afb 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 9b2b9da7..3e2549b3 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 6abb7c56..741fa151 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 faac70da..ad58db87 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 a0379c92..97cd13bf 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 0d4762cc..faf63723 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 00000000..bb1d18b9 --- /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 6e8513ee..593770ca 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 b1a4ede3..1b9dfc85 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 2e7c6bcd..a8570d2d 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 e400ab88..f8b47866 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 fef165dc..7c2a9156 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 6e1d7689..81f21a91 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 00000000..cb1e86be --- /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 0da91b9a..abc17fc2 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 429d0812..6514cd4a 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 fb8870c9..ed84e9ba 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 4dad91b1..27f93a0b 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 b25eb39f..a08c58e5 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 75675a0f..41ec495a 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 be3daa81..7d795ee8 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 82fb2245..ded7a63d 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 74f94256..6c001e2f 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 783537e0..376a92ad 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 a29d4b67..82128ed9 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 00000000..5971bd8c --- /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 00000000..a82ffe6d --- /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 a803b8f7..81ab1cf8 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 eefb7d73..73f9e3f4 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 3f31fe1e..9c43d5ae 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 03937030..27961dbc 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 0d52bf31..f20ee636 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 00000000..12c5a8bb --- /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 00000000..e4961a36 --- /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 0fe56c2d..a800bd6f 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 7dcef629..cb394505 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 941a4875..9a356d0a 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 2784a954..e674029a 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 2019830b..c208df83 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,