From 38c185fb8b50f909d29c0b62783731a3b326d021 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 22 May 2026 08:12:52 -0500
Subject: [PATCH 1/4] [codex] Add agent permissions and controls plan (#6386)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies by keeping
task ownership, approvals, and operator control inside one control
plane.
> - Agent permissions and plugin-hosted company settings sit on the
boundary between autonomy and governance.
> - V1 needs scoped task assignment rules, plugin extension points, and
clearer company access surfaces without weakening company boundaries.
> - The branch builds the core authorization service, plugin SDK/host
APIs, and UI simplifications needed to support those controls.
> - Paperclip EE plugin surfaces were intentionally moved out of this
core PR per review direction, so this PR now carries only the public
core/plugin infrastructure work.
> - The latest updates preserve the PAP-9937 branch changes that belong
in this PR, remove the `design/` artifacts, and exclude the experimental
`plugin-briefs` package.
> - Greptile feedback was applied through the authorization/audit paths
and the final cleanup commit was re-reviewed at 5/5 with no unresolved
Greptile threads.
> - The benefit is safer assignment control with extension hooks for
richer permission products while preserving simple defaults for normal
operators.
## What Changed
- Added scoped task-assignment authorization decisions and routed
issue/agent assignment mutations through the authorization service.
- Added plugin SDK and host APIs for company settings slots,
authorization policy/grant management, assignment previews, and bridge
invocation scope propagation.
- Simplified core company access UI and moved advanced controls behind
plugin-provided settings surfaces.
- Added retry-now affordances for blocked issue next-step notices.
- Added protected-assignment enforcement for persisted
agent/project/issue policies, including explicit-grant fallback
behavior.
- Added incremental principal-access compatibility backfill for active
agent memberships and role-default human permission grants.
- Added the Markdown code block wrap action fix from the latest branch
changes.
- Removed `design/` artifacts from the PR and removed
`packages/plugins/plugin-briefs` from the final diff.
- Addressed Greptile feedback for plugin actor sanitization, legacy
membership handling, audit pagination, unknown grant-scope metadata, and
startup test mocks.
## Verification
- `pnpm exec vitest run server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54
tests passed.
- `pnpm exec vitest run
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62
tests passed.
- `pnpm exec vitest run
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/plugin-access-authorization-host-services.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files
passed, 28 tests passed.
- `pnpm --filter @paperclipai/server typecheck` -> passed.
- `git diff --check` -> passed.
- `node ./scripts/check-docker-deps-stage.mjs` -> passed.
- `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed
with no lockfile update.
- `pnpm exec vitest run
ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed.
- `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0.
- GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`.
- Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0
comments/annotations added, 0 unresolved review threads.
- Confirmed the PR diff contains no `design/`,
`packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or
`.github/workflows` changes.
## Risks
- Medium: task assignment authorization paths are behaviorally stricter
for protected/private policy data, so existing plugin-authored policies
may block assignment until explicit grants or approval flows are
configured.
- Medium: plugin-host authorization APIs expand the surface area
available to trusted plugins and need careful review for company
scoping.
- Low: startup now performs a principal-access compatibility backfill,
but the migration and runtime backfill use conflict-tolerant inserts.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell,
git, and GitHub CLI access.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
doc/PRODUCT.md | 1 +
doc/SPEC-implementation.md | 55 +-
doc/SPEC.md | 2 +
doc/plugins/PLUGIN_SPEC.md | 15 +-
.../codex-local/src/server/codex-home.test.ts | 57 ++
.../codex-local/src/server/codex-home.ts | 30 +-
...fill_environment_manage_human_defaults.sql | 29 +
...ackfill_principal_access_compatibility.sql | 75 ++
packages/db/src/migrations/meta/_journal.json | 14 +
.../src/constants.ts | 2 +
.../src/manifest.ts | 7 +
.../src/ui/index.tsx | 28 +
packages/plugins/sdk/README.md | 13 +-
.../plugins/sdk/src/host-client-factory.ts | 191 +++-
packages/plugins/sdk/src/index.ts | 26 +
packages/plugins/sdk/src/protocol.ts | 156 ++++
packages/plugins/sdk/src/testing.ts | 197 ++++-
packages/plugins/sdk/src/types.ts | 181 ++++
packages/plugins/sdk/src/ui/index.ts | 1 +
packages/plugins/sdk/src/ui/types.ts | 12 +
packages/plugins/sdk/src/worker-rpc-host.ts | 130 ++-
.../sdk/tests/host-client-factory.test.ts | 166 ++++
.../plugins/sdk/tests/worker-rpc-host.test.ts | 159 +++-
packages/shared/src/constants.ts | 25 +
packages/shared/src/index.ts | 2 +
packages/shared/src/types/agent.ts | 2 +-
packages/shared/src/types/plugin.ts | 5 +-
packages/shared/src/validators/plugin.test.ts | 57 ++
packages/shared/src/validators/plugin.ts | 23 +-
.../access-routes-permissions-upgrade.test.ts | 167 ++++
server/src/__tests__/access-service.test.ts | 287 +++++-
.../agent-adapter-validation-routes.test.ts | 6 +
.../agent-cross-tenant-authz-routes.test.ts | 12 +
.../agent-instructions-routes.test.ts | 6 +
.../__tests__/agent-live-run-routes.test.ts | 11 +-
.../agent-permissions-routes.test.ts | 28 +
.../src/__tests__/agent-skills-routes.test.ts | 6 +
.../agent-test-environment-routes.test.ts | 6 +
server/src/__tests__/app-hmr-port.test.ts | 14 +-
.../__tests__/authorization-service.test.ts | 547 ++++++++++++
server/src/__tests__/better-auth.test.ts | 43 +-
.../src/__tests__/company-portability.test.ts | 1 +
.../plugin-worker-invocation-scope.cjs | 100 +++
.../src/__tests__/invite-join-grants.test.ts | 11 +
...ue-agent-mutation-ownership-routes.test.ts | 53 +-
...e-assigned-backlog-contract-routes.test.ts | 6 +
.../issue-comment-reopen-routes.test.ts | 11 +
.../issue-execution-policy-routes.test.ts | 12 +
.../issue-thread-interaction-routes.test.ts | 6 +
...issue-update-comment-wakeup-routes.test.ts | 12 +
...ermissions-upgrade-boundary-routes.test.ts | 348 ++++++++
...access-authorization-host-services.test.ts | 322 +++++++
.../src/__tests__/plugin-routes-authz.test.ts | 22 +
.../src/__tests__/plugin-sdk-testing.test.ts | 25 +
.../__tests__/plugin-worker-manager.test.ts | 89 ++
.../server-startup-feedback-export.test.ts | 4 +
server/src/app.ts | 9 +-
server/src/auth/better-auth.ts | 14 +-
server/src/board-claim.ts | 12 +
server/src/index.ts | 5 +
server/src/routes/agents.ts | 86 +-
server/src/routes/companies.ts | 9 +-
server/src/routes/issues.ts | 136 ++-
server/src/routes/plugins.ts | 12 +-
server/src/services/access.ts | 65 +-
server/src/services/agent-permissions.ts | 2 +
server/src/services/agents.ts | 2 +-
server/src/services/authorization.ts | 823 ++++++++++++++++++
server/src/services/company-member-roles.ts | 2 +
server/src/services/company-portability.ts | 9 +-
server/src/services/index.ts | 13 +
.../services/plugin-capability-validator.ts | 1 +
server/src/services/plugin-host-services.ts | 559 +++++++++++-
server/src/services/plugin-worker-manager.ts | 122 ++-
.../principal-access-compatibility.ts | 141 +++
ui/src/App.tsx | 7 +-
.../CompanySettingsSidebar.test.tsx | 62 +-
ui/src/components/CompanySettingsSidebar.tsx | 25 +-
ui/src/components/IssueBlockedNotice.test.tsx | 104 ++-
ui/src/components/IssueBlockedNotice.tsx | 87 +-
ui/src/components/IssueChatThread.tsx | 7 +
.../MarkdownBody.interaction.test.tsx | 95 ++
ui/src/components/MarkdownBody.tsx | 65 +-
.../access/CompanySettingsNav.test.tsx | 14 +-
.../components/access/CompanySettingsNav.tsx | 6 +-
.../transcript/useLiveRunTranscripts.ts | 6 +-
ui/src/context/LiveUpdatesProvider.tsx | 6 +-
ui/src/index.css | 31 +-
ui/src/lib/websocket-url.test.ts | 51 ++
ui/src/lib/websocket-url.ts | 20 +
ui/src/pages/AgentDetail.tsx | 10 +-
ui/src/pages/CompanyAccess.test.tsx | 116 ++-
ui/src/pages/CompanyAccess.tsx | 185 ++--
ui/src/pages/CompanyInvites.test.tsx | 3 +-
ui/src/pages/CompanyInvites.tsx | 8 +-
.../pages/CompanySettingsPluginPage.test.tsx | 140 +++
ui/src/pages/CompanySettingsPluginPage.tsx | 88 ++
ui/src/pages/InviteLanding.test.tsx | 10 +-
ui/src/pages/InviteLanding.tsx | 6 +-
ui/src/pages/InviteUxLab.tsx | 14 +-
ui/src/pages/IssueDetail.tsx | 5 +
ui/src/plugins/bridge-init.ts | 130 ++-
102 files changed, 6744 insertions(+), 395 deletions(-)
create mode 100644 packages/adapters/codex-local/src/server/codex-home.test.ts
create mode 100644 packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql
create mode 100644 packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql
create mode 100644 packages/plugins/sdk/tests/host-client-factory.test.ts
create mode 100644 server/src/__tests__/access-routes-permissions-upgrade.test.ts
create mode 100644 server/src/__tests__/authorization-service.test.ts
create mode 100644 server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
create mode 100644 server/src/__tests__/permissions-upgrade-boundary-routes.test.ts
create mode 100644 server/src/__tests__/plugin-access-authorization-host-services.test.ts
create mode 100644 server/src/services/authorization.ts
create mode 100644 server/src/services/principal-access-compatibility.ts
create mode 100644 ui/src/components/MarkdownBody.interaction.test.tsx
create mode 100644 ui/src/lib/websocket-url.test.ts
create mode 100644 ui/src/lib/websocket-url.ts
create mode 100644 ui/src/pages/CompanySettingsPluginPage.test.tsx
create mode 100644 ui/src/pages/CompanySettingsPluginPage.tsx
diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md
index f955e67792a..7a3d1f7b401 100644
--- a/doc/PRODUCT.md
+++ b/doc/PRODUCT.md
@@ -118,6 +118,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
+- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 2753512bd51..e1810f755f1 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -34,7 +34,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Company model | Company is first-order; all business entities are company-scoped |
| Board | Single human board operator per deployment |
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
-| Visibility | Full visibility to board and all agents in same company |
+| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
@@ -487,6 +487,59 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
| Report cost | yes | yes |
| Set company budget | yes | no |
| Set subordinate budget | yes | yes (manager subtree only) |
+| Set work-object visibility (issue/project) | no | no (pro gate) |
+
+## 9.4 Permission Terminology and Default Visibility Rule
+
+Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
+
+The approved term set is:
+
+- **Agent profile visibility**: identity-level facts needed for delegation and governance (name, role, capabilities, reporting lines).
+- **Agent config visibility**: adapter/runtime config metadata and secret-access policy.
+- **Assignment/invocation permission**: who may modify or execute a task.
+- **Work-object visibility**: who can read/write issues, comments, projects, and attachments.
+- **Tool/secret policy**: what tools and secret-backed credentials an agent can use and what appears in logs.
+- **Escalation authority**: where refusal/blocked decisions route (manager, then board).
+
+## 9.5 Core V1 Rule: what “private” means
+
+- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
+- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
+- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
+
+## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
+
+| Permission area | Free / V1 default | Pro / Enterprise |
+|---|---|---|
+| Company boundary | Hard boundary only (`company_id`) | Multi-company policy overlays (`membership`, `project`, and `task` scopes) |
+| Simple roles | Board + agent roles with existing approval/budget gates | Additional role aliases + scoped approver roles |
+| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
+| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
+| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
+| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
+| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
+| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows |
+
+## 9.7 Recommended first-slice implementation order
+
+1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates.
+2. Treat profile privacy as external-facing signal only; do not use it to hide company-visible work objects.
+3. Enforce assignment/invocation coupling (`assignee`/`agent` checks, checkout semantics, invocation checks).
+4. Standardize read-path redaction for secrets and secret references, including logs and activity.
+5. Standardize escalation paths (`blocked` and refusal) so non-board agents hand off by manager/board with immutable audit.
+
+## 9.8 Scoped Task Assignment Grants
+
+`tasks:assign` remains the broad assignment permission. Existing unscoped grants preserve compatibility and allow the principal to assign any visible company task within normal company-boundary checks.
+
+`tasks:assign_scope` is the constrained assignment permission. Its `principal_permission_grants.scope` JSON must include at least one recognized constraint:
+
+- Project scope: `projectId`, `projectIds`, or `allow: ["project:"]`.
+- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:"]`.
+- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:"]`.
+
+When multiple constraint families are present, assignment must satisfy all of them. Denials return `403` with a generic scope explanation and do not disclose details about hidden or unrelated resources.
## 10. API Contract (REST)
diff --git a/doc/SPEC.md b/doc/SPEC.md
index 6a7039ca67f..95b632d492f 100644
--- a/doc/SPEC.md
+++ b/doc/SPEC.md
@@ -141,6 +141,8 @@ Hierarchical reporting structure. CEO at top, reports cascade down.
**Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control.
+Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
+
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
### Cross-Team Work
diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md
index c8080e56b9c..d1bd103486c 100644
--- a/doc/plugins/PLUGIN_SPEC.md
+++ b/doc/plugins/PLUGIN_SPEC.md
@@ -364,13 +364,16 @@ export interface PaperclipPluginManifestV1 {
| "contextMenuItem"
| "commentAnnotation"
| "commentContextMenuItem"
- | "settingsPage";
+ | "settingsPage"
+ | "companySettingsPage";
id: string;
displayName: string;
/** Which export name in the UI bundle provides this component */
exportName: string;
/** For detailTab: which entity types this tab appears on */
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
+ /** For page and companySettingsPage: single route segment */
+ routePath?: string;
}>;
};
}
@@ -1206,6 +1209,8 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th
Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards.
+For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations.
+
## 20. Local Tooling
Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations.
@@ -1455,6 +1460,14 @@ Each plugin may expose a company-context main page:
This page is where board users do most day-to-day work.
+## 24.4 Company Settings Plugin Page
+
+Each ready plugin may expose a company settings page:
+
+- `/:companyPrefix/company/settings/:routePath`
+
+The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages.
+
## 25. Uninstall And Data Lifecycle
When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts
new file mode 100644
index 00000000000..864837510b3
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/codex-home.test.ts
@@ -0,0 +1,57 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { prepareManagedCodexHome } from "./codex-home.js";
+
+describe("codex managed home", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("treats a concurrently-created expected auth symlink as success", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
+ const sharedCodexHome = path.join(root, "shared-codex-home");
+ const paperclipHome = path.join(root, "paperclip-home");
+ const managedCodexHome = path.join(
+ paperclipHome,
+ "instances",
+ "default",
+ "companies",
+ "company-1",
+ "codex-home",
+ );
+ const sharedAuth = path.join(sharedCodexHome, "auth.json");
+ const managedAuth = path.join(managedCodexHome, "auth.json");
+
+ await fs.mkdir(sharedCodexHome, { recursive: true });
+ await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8");
+
+ const originalSymlink = fs.symlink.bind(fs);
+ vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => {
+ await originalSymlink(source, target, type);
+ const error = new Error("file already exists") as NodeJS.ErrnoException;
+ error.code = "EEXIST";
+ throw error;
+ });
+
+ try {
+ await expect(
+ prepareManagedCodexHome(
+ {
+ CODEX_HOME: sharedCodexHome,
+ PAPERCLIP_HOME: paperclipHome,
+ PAPERCLIP_INSTANCE_ID: "default",
+ },
+ async () => {},
+ "company-1",
+ ),
+ ).resolves.toBe(managedCodexHome);
+
+ expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
+ expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth));
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts
index 0cb737bb28f..ee87e6cbf5e 100644
--- a/packages/adapters/codex-local/src/server/codex-home.ts
+++ b/packages/adapters/codex-local/src/server/codex-home.ts
@@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise {
await fs.mkdir(path.dirname(target), { recursive: true });
}
+async function isExpectedSymlink(target: string, source: string): Promise {
+ const existing = await fs.lstat(target).catch(() => null);
+ if (!existing?.isSymbolicLink()) return false;
+
+ const linkedPath = await fs.readlink(target).catch(() => null);
+ if (!linkedPath) return false;
+
+ return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
+}
+
+async function createExpectedSymlink(target: string, source: string): Promise {
+ try {
+ await fs.symlink(source, target);
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (code === "EEXIST" && await isExpectedSymlink(target, source)) return;
+ throw error;
+ }
+}
+
async function ensureSymlink(target: string, source: string): Promise {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
- await fs.symlink(source, target);
+ await createExpectedSymlink(target, source);
return;
}
@@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise {
return;
}
- const linkedPath = await fs.readlink(target).catch(() => null);
- if (!linkedPath) return;
-
- const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
- if (resolvedLinkedPath === source) return;
+ if (await isExpectedSymlink(target, source)) return;
await fs.unlink(target);
- await fs.symlink(source, target);
+ await createExpectedSymlink(target, source);
}
async function ensureCopiedFile(target: string, source: string): Promise {
diff --git a/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql
new file mode 100644
index 00000000000..4d05e171bfc
--- /dev/null
+++ b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql
@@ -0,0 +1,29 @@
+INSERT INTO "principal_permission_grants" (
+ "company_id",
+ "principal_type",
+ "principal_id",
+ "permission_key",
+ "scope",
+ "granted_by_user_id",
+ "created_at",
+ "updated_at"
+)
+SELECT
+ "company_id",
+ 'user',
+ "principal_id",
+ 'environments:manage',
+ NULL,
+ NULL,
+ now(),
+ now()
+FROM "company_memberships"
+WHERE "principal_type" = 'user'
+ AND "status" = 'active'
+ AND "membership_role" IN ('owner', 'admin')
+ON CONFLICT (
+ "company_id",
+ "principal_type",
+ "principal_id",
+ "permission_key"
+) DO NOTHING;
diff --git a/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql
new file mode 100644
index 00000000000..6e78c7e5083
--- /dev/null
+++ b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql
@@ -0,0 +1,75 @@
+INSERT INTO "company_memberships" (
+ "company_id",
+ "principal_type",
+ "principal_id",
+ "status",
+ "membership_role",
+ "created_at",
+ "updated_at"
+)
+SELECT
+ "company_id",
+ 'agent',
+ "id",
+ 'active',
+ 'member',
+ now(),
+ now()
+FROM "agents"
+WHERE "status" NOT IN ('pending_approval', 'terminated')
+ON CONFLICT (
+ "company_id",
+ "principal_type",
+ "principal_id"
+) DO NOTHING;
+
+INSERT INTO "principal_permission_grants" (
+ "company_id",
+ "principal_type",
+ "principal_id",
+ "permission_key",
+ "scope",
+ "granted_by_user_id",
+ "created_at",
+ "updated_at"
+)
+SELECT
+ memberships."company_id",
+ 'user',
+ memberships."principal_id",
+ role_defaults."permission_key",
+ NULL,
+ NULL,
+ now(),
+ now()
+FROM "company_memberships" memberships
+JOIN (
+ VALUES
+ ('owner', 'agents:create'),
+ ('owner', 'environments:manage'),
+ ('owner', 'users:invite'),
+ ('owner', 'users:manage_permissions'),
+ ('owner', 'tasks:assign'),
+ ('owner', 'joins:approve'),
+ ('admin', 'agents:create'),
+ ('admin', 'environments:manage'),
+ ('admin', 'users:invite'),
+ ('admin', 'tasks:assign'),
+ ('admin', 'joins:approve'),
+ ('operator', 'tasks:assign')
+) AS role_defaults("membership_role", "permission_key")
+ ON role_defaults."membership_role" = CASE
+ WHEN memberships."membership_role" = 'owner' THEN 'owner'
+ WHEN memberships."membership_role" = 'admin' THEN 'admin'
+ WHEN memberships."membership_role" = 'viewer' THEN 'viewer'
+ WHEN memberships."membership_role" = 'member' THEN 'operator'
+ ELSE 'operator'
+ END
+WHERE memberships."principal_type" = 'user'
+ AND memberships."status" = 'active'
+ON CONFLICT (
+ "company_id",
+ "principal_type",
+ "principal_id",
+ "permission_key"
+) DO NOTHING;
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index a47bae7c2d1..7aaa96d3da3 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -610,6 +610,20 @@
"when": 1778976000000,
"tag": "0086_routine_env_runtime_contract",
"breakpoints": true
+ },
+ {
+ "idx": 87,
+ "version": "7",
+ "when": 1779360000000,
+ "tag": "0087_backfill_environment_manage_human_defaults",
+ "breakpoints": true
+ },
+ {
+ "idx": 88,
+ "version": "7",
+ "when": 1779446400000,
+ "tag": "0088_backfill_principal_access_compatibility",
+ "breakpoints": true
}
]
}
diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts
index 9c18f610b37..8a2e44c00aa 100644
--- a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts
+++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts
@@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink";
export const SLOT_IDS = {
page: "kitchen-sink-page",
settingsPage: "kitchen-sink-settings-page",
+ companySettingsPage: "kitchen-sink-company-settings-page",
dashboardWidget: "kitchen-sink-dashboard-widget",
sidebar: "kitchen-sink-sidebar-link",
sidebarPanel: "kitchen-sink-sidebar-panel",
@@ -23,6 +24,7 @@ export const SLOT_IDS = {
export const EXPORT_NAMES = {
page: "KitchenSinkPage",
settingsPage: "KitchenSinkSettingsPage",
+ companySettingsPage: "KitchenSinkCompanySettingsPage",
dashboardWidget: "KitchenSinkDashboardWidget",
sidebar: "KitchenSinkSidebarLink",
sidebarPanel: "KitchenSinkSidebarPanel",
diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts
index bcff32c2eed..5fc99281868 100644
--- a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts
+++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts
@@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = {
displayName: "Kitchen Sink Settings",
exportName: EXPORT_NAMES.settingsPage,
},
+ {
+ type: "companySettingsPage",
+ id: SLOT_IDS.companySettingsPage,
+ displayName: "Kitchen Sink",
+ exportName: EXPORT_NAMES.companySettingsPage,
+ routePath: "kitchen-sink",
+ },
{
type: "dashboardWidget",
id: SLOT_IDS.dashboardWidget,
diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx
index ea3cb491e31..95ce4c14c1c 100644
--- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx
+++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx
@@ -10,6 +10,7 @@ import {
usePluginToast,
type PluginCommentAnnotationProps,
type PluginCommentContextMenuItemProps,
+ type PluginCompanySettingsPageProps,
type PluginDetailTabProps,
type PluginPageProps,
type PluginProjectSidebarItemProps,
@@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
);
}
+export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) {
+ const hostNavigation = useHostNavigation();
+ const overview = usePluginOverview(context.companyId);
+ const href = hostNavigation.resolveHref("/company/settings/kitchen-sink");
+
+ return (
+
+
+
+
+
Mounted inside company settings
+
+ This fixture proves a ready plugin can add a settings sidebar item and render with company context.
+
+
+
+
+
+
+ );
+}
+
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md
index c1f73d98ba2..b9a02d5c149 100644
--- a/packages/plugins/sdk/README.md
+++ b/packages/plugins/sdk/README.md
@@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url);
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
-**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
+**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
@@ -134,7 +134,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
-**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
+**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope.
## Scheduled (recurring) jobs
@@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `activity.read` |
| | `costs.read` |
| | `issues.orchestration.read` |
+| | `access.members.read` |
+| | `access.invites.read` |
+| | `authorization.grants.read` |
+| | `authorization.policies.read` |
+| | `authorization.audit.read` |
| | `database.namespace.read` |
| | `issues.create` |
| | `issues.update` |
@@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `local.folders` |
| **Agent** | `agent.tools.register` |
| | `agents.invoke` |
+| | `access.members.write` |
+| | `access.invites.write` |
+| | `authorization.grants.write` |
+| | `authorization.policies.write` |
| | `agent.sessions.create` |
| | `agent.sessions.list` |
| | `agent.sessions.send` |
diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts
index 8b85b0b7e1f..3264afbccb9 100644
--- a/packages/plugins/sdk/src/host-client-factory.ts
+++ b/packages/plugins/sdk/src/host-client-factory.ts
@@ -49,7 +49,7 @@
*/
import type { PluginCapability } from "@paperclipai/shared";
-import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
+import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
// ---------------------------------------------------------------------------
@@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error {
}
}
+/**
+ * Thrown when a worker→host call asks for company-scoped data outside the
+ * company authorized for the current top-level plugin invocation.
+ */
+export class InvocationScopeDeniedError extends Error {
+ override readonly name = "InvocationScopeDeniedError";
+ readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
+
+ constructor(pluginId: string, method: string, message: string) {
+ super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
+ }
+}
+
// ---------------------------------------------------------------------------
// Host service interfaces
// ---------------------------------------------------------------------------
@@ -257,6 +270,28 @@ export interface HostServices {
create(params: WorkerToHostMethods["goals.create"][0]): Promise;
update(params: WorkerToHostMethods["goals.update"][0]): Promise;
};
+
+ /** Provides `access.members.*` and `access.invites.*`. */
+ access: {
+ listMembers(params: WorkerToHostMethods["access.members.list"][0]): Promise;
+ getMember(params: WorkerToHostMethods["access.members.get"][0]): Promise;
+ updateMember(params: WorkerToHostMethods["access.members.update"][0]): Promise;
+ listInvites(params: WorkerToHostMethods["access.invites.list"][0]): Promise;
+ createInvite(params: WorkerToHostMethods["access.invites.create"][0]): Promise;
+ revokeInvite(params: WorkerToHostMethods["access.invites.revoke"][0]): Promise;
+ };
+
+ /** Provides authorization grant, policy, preview, and audit helpers. */
+ authorization: {
+ listGrants(params: WorkerToHostMethods["authorization.grants.list"][0]): Promise;
+ setGrants(params: WorkerToHostMethods["authorization.grants.set"][0]): Promise;
+ policySummary(params: WorkerToHostMethods["authorization.policies.summary"][0]): Promise;
+ getPolicy(params: WorkerToHostMethods["authorization.policies.get"][0]): Promise;
+ updatePolicy(params: WorkerToHostMethods["authorization.policies.update"][0]): Promise;
+ previewAssignment(params: WorkerToHostMethods["authorization.policies.previewAssignment"][0]): Promise;
+ explainAssignment(params: WorkerToHostMethods["authorization.policies.explainAssignment"][0]): Promise;
+ searchAudit(params: WorkerToHostMethods["authorization.audit.search"][0]): Promise;
+ };
}
// ---------------------------------------------------------------------------
@@ -292,6 +327,7 @@ export interface HostClientFactoryOptions {
*/
type HostHandler = (
params: WorkerToHostMethods[M][0],
+ context?: WorkerHostCallContext,
) => Promise;
/**
@@ -431,6 +467,24 @@ const METHOD_CAPABILITY_MAP: Record(options.capabilities);
+ type CompanyScopeRequest =
+ | { kind: "none" }
+ | { kind: "single"; companyId: string }
+ | { kind: "all" };
+
+ const noCompanyScope: CompanyScopeRequest = { kind: "none" };
+
+ function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+ }
+
+ function readNonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+ }
+
+ function requestedCompanyScope(
+ method: WorkerToHostMethodName,
+ params: unknown,
+ ): CompanyScopeRequest {
+ if (method === "companies.list") return { kind: "all" };
+ if (!isRecord(params)) return noCompanyScope;
+
+ const companyId = readNonEmptyString(params.companyId);
+ if (companyId) return { kind: "single", companyId };
+
+ if (params.scopeKind === "company") {
+ const scopeId = readNonEmptyString(params.scopeId);
+ return scopeId ? { kind: "single", companyId: scopeId } : { kind: "all" };
+ }
+
+ if (method === "events.subscribe" && isRecord(params.filter)) {
+ const filterCompanyId = readNonEmptyString(params.filter.companyId);
+ if (filterCompanyId) return { kind: "single", companyId: filterCompanyId };
+ }
+
+ return noCompanyScope;
+ }
+
+ function requireInvocationCompanyScope(
+ method: WorkerToHostMethodName,
+ params: unknown,
+ context?: WorkerHostCallContext,
+ ): void {
+ const requested = requestedCompanyScope(method, params);
+ if (requested.kind === "none") return;
+
+ if (context?.invalidInvocationScope) {
+ throw new InvocationScopeDeniedError(
+ pluginId,
+ method,
+ "the worker referenced a missing, expired, or unknown invocation scope",
+ );
+ }
+
+ const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
+ if (!allowedCompanyId) return;
+
+ if (requested.kind === "all") {
+ if (method === "companies.list") return;
+ throw new InvocationScopeDeniedError(
+ pluginId,
+ method,
+ `the current invocation is scoped to company "${allowedCompanyId}"`,
+ );
+ }
+
+ if (requested.companyId !== allowedCompanyId) {
+ throw new InvocationScopeDeniedError(
+ pluginId,
+ method,
+ `requested company "${requested.companyId}" but the current invocation is scoped to company "${allowedCompanyId}"`,
+ );
+ }
+ }
+
/**
* Assert that the plugin has the required capability for a method.
* Throws `CapabilityDeniedError` if the capability is missing.
@@ -485,9 +614,10 @@ export function createHostClientHandlers(
method: M,
handler: HostHandler,
): HostHandler {
- return async (params: WorkerToHostMethods[M][0]) => {
+ return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => {
requireCapability(method);
- return handler(params);
+ requireInvocationCompanyScope(method, params, context);
+ return handler(params, context);
};
}
@@ -591,8 +721,13 @@ export function createHostClientHandlers(
}),
// Companies
- "companies.list": gated("companies.list", async (params) => {
- return services.companies.list(params);
+ "companies.list": gated("companies.list", async (params, context) => {
+ const rows = await services.companies.list(params);
+ const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
+ if (!allowedCompanyId) return rows;
+ return rows.filter((company) =>
+ isRecord(company) && company.id === allowedCompanyId,
+ ) as WorkerToHostMethods["companies.list"][1];
}),
"companies.get": gated("companies.get", async (params) => {
return services.companies.get(params);
@@ -772,6 +907,52 @@ export function createHostClientHandlers(
"goals.update": gated("goals.update", async (params) => {
return services.goals.update(params);
}),
+
+ // Access
+ "access.members.list": gated("access.members.list", async (params) => {
+ return services.access.listMembers(params);
+ }),
+ "access.members.get": gated("access.members.get", async (params) => {
+ return services.access.getMember(params);
+ }),
+ "access.members.update": gated("access.members.update", async (params) => {
+ return services.access.updateMember(params);
+ }),
+ "access.invites.list": gated("access.invites.list", async (params) => {
+ return services.access.listInvites(params);
+ }),
+ "access.invites.create": gated("access.invites.create", async (params) => {
+ return services.access.createInvite(params);
+ }),
+ "access.invites.revoke": gated("access.invites.revoke", async (params) => {
+ return services.access.revokeInvite(params);
+ }),
+
+ // Authorization
+ "authorization.grants.list": gated("authorization.grants.list", async (params) => {
+ return services.authorization.listGrants(params);
+ }),
+ "authorization.grants.set": gated("authorization.grants.set", async (params) => {
+ return services.authorization.setGrants(params);
+ }),
+ "authorization.policies.summary": gated("authorization.policies.summary", async (params) => {
+ return services.authorization.policySummary(params);
+ }),
+ "authorization.policies.get": gated("authorization.policies.get", async (params) => {
+ return services.authorization.getPolicy(params);
+ }),
+ "authorization.policies.update": gated("authorization.policies.update", async (params) => {
+ return services.authorization.updatePolicy(params);
+ }),
+ "authorization.policies.previewAssignment": gated("authorization.policies.previewAssignment", async (params) => {
+ return services.authorization.previewAssignment(params);
+ }),
+ "authorization.policies.explainAssignment": gated("authorization.policies.explainAssignment", async (params) => {
+ return services.authorization.explainAssignment(params);
+ }),
+ "authorization.audit.search": gated("authorization.audit.search", async (params) => {
+ return services.authorization.searchAudit(params);
+ }),
};
}
diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts
index 92db6d696cf..2ca0fa9a7c3 100644
--- a/packages/plugins/sdk/src/index.ts
+++ b/packages/plugins/sdk/src/index.ts
@@ -58,6 +58,7 @@ export {
createHostClientHandlers,
getRequiredCapability,
CapabilityDeniedError,
+ InvocationScopeDeniedError,
} from "./host-client-factory.js";
// JSON-RPC protocol helpers and constants
@@ -137,6 +138,9 @@ export type {
JsonRpcMessage,
JsonRpcErrorCode,
PluginRpcErrorCode,
+ PluginInvocationScope,
+ PluginInvocationContext,
+ WorkerHostCallContext,
InitializeParams,
InitializeResult,
ConfigChangedParams,
@@ -218,6 +222,17 @@ export type {
PluginIssueSubtree,
PluginIssueSummariesClient,
PluginAgentsClient,
+ PluginAccessClient,
+ PluginAccessMembersClient,
+ PluginAccessInvitesClient,
+ PluginAccessMember,
+ PluginAccessInvite,
+ PluginAuthorizationClient,
+ PluginAuthorizationPolicySummary,
+ PluginAuthorizationPolicyRecord,
+ PluginAssignmentPreviewInput,
+ PluginAuthorizationDecisionResult,
+ PluginAuthorizationAuditEntry,
PluginAgentSessionsClient,
AgentSession,
AgentSessionEvent,
@@ -253,7 +268,12 @@ export type {
IssueDocumentSummary,
Agent,
Goal,
+ PermissionKey,
+ PrincipalPermissionGrant,
+ PrincipalType,
PluginDatabaseClient,
+ HumanCompanyMembershipRole,
+ MembershipStatus,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
@@ -353,6 +373,7 @@ export {
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
+ PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_JOB_STATUSES,
PLUGIN_JOB_RUN_STATUSES,
@@ -360,4 +381,9 @@ export {
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
+ PERMISSION_KEYS,
+ HUMAN_COMPANY_MEMBERSHIP_ROLES,
+ HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
+ MEMBERSHIP_STATUSES,
+ PRINCIPAL_TYPES,
} from "@paperclipai/shared";
diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts
index 24bc871b369..48075b2f6b9 100644
--- a/packages/plugins/sdk/src/protocol.ts
+++ b/packages/plugins/sdk/src/protocol.ts
@@ -39,6 +39,7 @@ import type {
Agent,
Goal,
PluginLocalFolderDeclaration,
+ PrincipalPermissionGrant,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@@ -57,6 +58,13 @@ import type {
ToolResult,
PluginLocalFolderListing,
PluginLocalFolderStatus,
+ PluginAccessInvite,
+ PluginAccessMember,
+ PluginAssignmentPreviewInput,
+ PluginAuthorizationAuditEntry,
+ PluginAuthorizationDecisionResult,
+ PluginAuthorizationPolicyRecord,
+ PluginAuthorizationPolicySummary,
} from "./types.js";
import type {
PluginHealthDiagnostics,
@@ -96,6 +104,14 @@ export interface JsonRpcRequest<
readonly method: TMethod;
/** Structured parameters for the method call. */
readonly params: TParams;
+ /**
+ * Host-issued metadata for the top-level plugin invocation that is currently
+ * executing. The worker treats this as opaque and echoes only the id on
+ * worker→host calls made from the same async execution context.
+ */
+ readonly paperclipInvocation?: PluginInvocationContext;
+ /** Opaque top-level invocation id echoed by worker→host requests. */
+ readonly paperclipInvocationId?: string;
}
/**
@@ -156,6 +172,13 @@ export interface JsonRpcNotification<
readonly method: TMethod;
/** Structured parameters for the notification. */
readonly params: TParams;
+ /**
+ * Host-issued metadata for host→worker push notifications such as events.
+ * Worker→host notifications echo only `paperclipInvocationId`.
+ */
+ readonly paperclipInvocation?: PluginInvocationContext;
+ /** Opaque top-level invocation id echoed by worker→host notifications. */
+ readonly paperclipInvocationId?: string;
}
/**
@@ -217,6 +240,36 @@ export const PLUGIN_RPC_ERROR_CODES = {
export type PluginRpcErrorCode =
(typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES];
+// ---------------------------------------------------------------------------
+// Invocation scope metadata
+// ---------------------------------------------------------------------------
+
+/**
+ * Company scope attached by the host to one top-level plugin invocation.
+ * Absence of this metadata means the invocation is instance/global scoped.
+ */
+export interface PluginInvocationScope {
+ companyId: string;
+}
+
+/**
+ * Opaque invocation metadata generated by the host. Workers must not derive or
+ * mutate this. They only echo the id on nested worker→host RPC calls.
+ */
+export interface PluginInvocationContext {
+ id: string;
+ scope: PluginInvocationScope;
+}
+
+/**
+ * Context provided to host-side worker→host handlers after the worker echoes a
+ * host-issued invocation id.
+ */
+export interface WorkerHostCallContext {
+ invocationScope?: PluginInvocationScope | null;
+ invalidInvocationScope?: boolean;
+}
+
// ---------------------------------------------------------------------------
// Host → Worker Method Signatures (§13 Host-Worker Protocol)
// ---------------------------------------------------------------------------
@@ -302,6 +355,8 @@ export interface RunJobParams {
export interface GetDataParams {
/** Plugin-defined data key (e.g. `"sync-health"`). */
key: string;
+ /** Host-authorized active company scope, when this bridge call is company-scoped. */
+ companyId?: string | null;
/** Context and query parameters from the UI. */
params: Record;
/** Optional launcher/container metadata from the host render environment. */
@@ -316,6 +371,8 @@ export interface GetDataParams {
export interface PerformActionParams {
/** Plugin-defined action key (e.g. `"resync"`). */
key: string;
+ /** Host-authorized active company scope, when this bridge call is company-scoped. */
+ companyId?: string | null;
/** Action parameters from the UI. */
params: Record;
/** Optional launcher/container metadata from the host render environment. */
@@ -1128,6 +1185,105 @@ export interface WorkerToHostMethods {
},
result: Goal,
];
+
+ // Access
+ "access.members.list": [
+ params: { companyId: string; includeArchived?: boolean },
+ result: PluginAccessMember[],
+ ];
+ "access.members.get": [
+ params: { memberId: string; companyId: string },
+ result: PluginAccessMember | null,
+ ];
+ "access.members.update": [
+ params: {
+ memberId: string;
+ companyId: string;
+ patch: {
+ membershipRole?: string | null;
+ status?: "pending" | "active" | "suspended";
+ };
+ },
+ result: PluginAccessMember,
+ ];
+ "access.invites.list": [
+ params: {
+ companyId: string;
+ state?: "active" | "revoked" | "accepted" | "expired";
+ limit?: number;
+ offset?: number;
+ },
+ result: { invites: PluginAccessInvite[]; nextOffset: number | null },
+ ];
+ "access.invites.create": [
+ params: {
+ companyId: string;
+ allowedJoinTypes?: "human" | "agent" | "both";
+ humanRole?: string | null;
+ defaultsPayload?: Record | null;
+ agentMessage?: string | null;
+ },
+ result: PluginAccessInvite & { token: string },
+ ];
+ "access.invites.revoke": [
+ params: { inviteId: string; companyId: string },
+ result: PluginAccessInvite,
+ ];
+
+ // Authorization
+ "authorization.grants.list": [
+ params: { companyId: string; principalType?: string; principalId?: string },
+ result: PrincipalPermissionGrant[],
+ ];
+ "authorization.grants.set": [
+ params: {
+ companyId: string;
+ principalType: string;
+ principalId: string;
+ grants: Array<{ permissionKey: string; scope?: Record | null }>;
+ grantedByUserId?: string | null;
+ },
+ result: PrincipalPermissionGrant[],
+ ];
+ "authorization.policies.summary": [
+ params: { companyId: string },
+ result: PluginAuthorizationPolicySummary,
+ ];
+ "authorization.policies.get": [
+ params: { companyId: string; resourceType: "company" | "agent" | "project" | "issue"; resourceId: string },
+ result: PluginAuthorizationPolicyRecord | null,
+ ];
+ "authorization.policies.update": [
+ params: {
+ companyId: string;
+ resourceType: "company" | "agent" | "project" | "issue";
+ resourceId: string;
+ policy: Record | null;
+ },
+ result: PluginAuthorizationPolicyRecord,
+ ];
+ "authorization.policies.previewAssignment": [
+ params: PluginAssignmentPreviewInput,
+ result: PluginAuthorizationDecisionResult,
+ ];
+ "authorization.policies.explainAssignment": [
+ params: PluginAssignmentPreviewInput,
+ result: PluginAuthorizationDecisionResult,
+ ];
+ "authorization.audit.search": [
+ params: {
+ companyId: string;
+ action?: string;
+ actorType?: string;
+ actorId?: string;
+ entityType?: string;
+ entityId?: string;
+ decision?: string;
+ limit?: number;
+ offset?: number;
+ },
+ result: PluginAuthorizationAuditEntry[],
+ ];
}
/** Union of all worker→host method names. */
diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts
index de34e8ae112..299af7c6866 100644
--- a/packages/plugins/sdk/src/testing.ts
+++ b/packages/plugins/sdk/src/testing.ts
@@ -38,6 +38,10 @@ import type {
AgentSessionEvent,
PluginLocalFolderEntry,
PluginLocalFolderStatus,
+ PluginAccessMember,
+ PrincipalPermissionGrant,
+ PermissionKey,
+ PrincipalType,
} from "./types.js";
import type {
PluginEnvironmentValidateConfigParams,
@@ -73,7 +77,7 @@ export interface TestHarnessLogEntry {
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
- /** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
+ /** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
seed(input: {
companies?: Company[];
projects?: Project[];
@@ -83,6 +87,8 @@ export interface TestHarness {
goals?: Goal[];
projectWorkspaces?: PluginWorkspace[];
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
+ accessMembers?: PluginAccessMember[];
+ principalGrants?: PrincipalPermissionGrant[];
}): void;
setConfig(config: Record): void;
/** Dispatch a host or plugin event to registered handlers. */
@@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const issueDocuments = new Map();
const agents = new Map();
const goals = new Map();
+ const accessMembers = new Map();
+ const principalGrants = new Map();
+
+ function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) {
+ return `${companyId}:${principalType}:${principalId}`;
+ }
+ function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) {
+ return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? [];
+ }
+ function setPrincipalGrants(
+ companyId: string,
+ principalType: PrincipalType,
+ principalId: string,
+ grants: Array<{ permissionKey: PermissionKey; scope?: Record | null }>,
+ ) {
+ const stamped = grants.map((grant) => ({
+ principalType,
+ principalId,
+ permissionKey: grant.permissionKey,
+ scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null,
+ })) as PrincipalPermissionGrant[];
+ principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped);
+ const member = [...accessMembers.values()].find(
+ (entry) =>
+ entry.companyId === companyId
+ && entry.principalType === principalType
+ && entry.principalId === principalId,
+ );
+ if (member) {
+ accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() });
+ }
+ return stamped;
+ }
const projectWorkspaces = new Map();
const executionWorkspaces = new Map();
const localFolderStatuses = new Map();
@@ -1983,6 +2022,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return updated;
},
},
+ access: {
+ members: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "access.members.read");
+ const cid = requireCompanyId(input.companyId);
+ const includeArchived = input.includeArchived === true;
+ return [...accessMembers.values()]
+ .filter((member) => member.companyId === cid)
+ .filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
+ .map((member) => ({
+ ...member,
+ grants: getPrincipalGrants(cid, member.principalType, member.principalId),
+ }));
+ },
+ async get(memberId, companyId) {
+ requireCapability(manifest, capabilitySet, "access.members.read");
+ const cid = requireCompanyId(companyId);
+ const member = accessMembers.get(memberId);
+ if (!member || member.companyId !== cid) return null;
+ return {
+ ...member,
+ grants: getPrincipalGrants(cid, member.principalType, member.principalId),
+ };
+ },
+ async update(memberId, patch, companyId) {
+ requireCapability(manifest, capabilitySet, "access.members.write");
+ const cid = requireCompanyId(companyId);
+ const member = accessMembers.get(memberId);
+ if (!member || member.companyId !== cid) {
+ throw new Error(`Membership not found: ${memberId}`);
+ }
+ const updated: PluginAccessMember = {
+ ...member,
+ membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
+ status: patch.status === undefined ? member.status : patch.status,
+ updatedAt: new Date().toISOString(),
+ };
+ accessMembers.set(memberId, updated);
+ return {
+ ...updated,
+ grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
+ };
+ },
+ },
+ invites: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "access.invites.read");
+ requireCompanyId(input.companyId);
+ return { invites: [], nextOffset: null };
+ },
+ async create(input) {
+ requireCapability(manifest, capabilitySet, "access.invites.write");
+ requireCompanyId(input.companyId);
+ throw new Error("Invite creation is not implemented in the plugin test harness");
+ },
+ async revoke(inviteId, companyId) {
+ requireCapability(manifest, capabilitySet, "access.invites.write");
+ requireCompanyId(companyId);
+ throw new Error(`Invite not found: ${inviteId}`);
+ },
+ },
+ },
+ authorization: {
+ grants: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "authorization.grants.read");
+ const cid = requireCompanyId(input.companyId);
+ if (input.principalType && input.principalId) {
+ return getPrincipalGrants(cid, input.principalType, input.principalId);
+ }
+ const out: PrincipalPermissionGrant[] = [];
+ for (const [key, grants] of principalGrants.entries()) {
+ if (!key.startsWith(`${cid}:`)) continue;
+ for (const grant of grants) {
+ if (input.principalType && grant.principalType !== input.principalType) continue;
+ if (input.principalId && grant.principalId !== input.principalId) continue;
+ out.push(grant);
+ }
+ }
+ return out;
+ },
+ async set(input) {
+ requireCapability(manifest, capabilitySet, "authorization.grants.write");
+ const cid = requireCompanyId(input.companyId);
+ return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
+ },
+ },
+ policies: {
+ async summary(companyId) {
+ requireCapability(manifest, capabilitySet, "authorization.policies.read");
+ const cid = requireCompanyId(companyId);
+ const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
+ let grantCount = 0;
+ for (const [key, grants] of principalGrants.entries()) {
+ if (key.startsWith(`${cid}:`)) grantCount += grants.length;
+ }
+ return {
+ companyId: cid,
+ permissionsMode: "simple",
+ memberCount: members.length,
+ activeMemberCount: members.filter((member) => member.status === "active").length,
+ grantCount,
+ advancedPolicyAvailable: false,
+ };
+ },
+ async get(input) {
+ requireCapability(manifest, capabilitySet, "authorization.policies.read");
+ requireCompanyId(input.companyId);
+ return null;
+ },
+ async update(input) {
+ requireCapability(manifest, capabilitySet, "authorization.policies.write");
+ const cid = requireCompanyId(input.companyId);
+ return {
+ companyId: cid,
+ resourceType: input.resourceType,
+ resourceId: input.resourceId,
+ policy: input.policy,
+ updatedAt: new Date().toISOString(),
+ };
+ },
+ async previewAssignment(input) {
+ requireCapability(manifest, capabilitySet, "authorization.policies.read");
+ requireCompanyId(input.companyId);
+ return {
+ allowed: true,
+ action: "issue.assign",
+ explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
+ reason: "simple_mode",
+ };
+ },
+ async explainAssignment(input) {
+ requireCapability(manifest, capabilitySet, "authorization.policies.read");
+ requireCompanyId(input.companyId);
+ return {
+ allowed: true,
+ action: "issue.assign",
+ explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
+ reason: "simple_mode",
+ };
+ },
+ },
+ audit: {
+ async search(input) {
+ requireCapability(manifest, capabilitySet, "authorization.audit.read");
+ requireCompanyId(input.companyId);
+ return [];
+ },
+ },
+ },
data: {
register(key, handler) {
dataHandlers.set(key, handler);
@@ -2065,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
projectWorkspaces.set(row.projectId, list);
}
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
+ for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
+ for (const row of input.principalGrants ?? []) {
+ const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
+ list.push(row);
+ principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
+ }
},
setConfig(config) {
currentConfig = { ...config };
diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts
index bcbb7f4e71d..456f4a9e7c0 100644
--- a/packages/plugins/sdk/src/types.ts
+++ b/packages/plugins/sdk/src/types.ts
@@ -39,6 +39,12 @@ import type {
RoutineRun,
Agent,
Goal,
+ HumanCompanyMembershipRole,
+ InviteJoinType,
+ MembershipStatus,
+ PermissionKey,
+ PrincipalPermissionGrant,
+ PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -120,6 +126,12 @@ export type {
IssueSurfaceVisibility,
Agent,
Goal,
+ HumanCompanyMembershipRole,
+ InviteJoinType,
+ MembershipStatus,
+ PermissionKey,
+ PrincipalPermissionGrant,
+ PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -1576,6 +1588,169 @@ export interface PluginGoalsClient {
): Promise;
}
+// ---------------------------------------------------------------------------
+// Access and Authorization
+// ---------------------------------------------------------------------------
+
+export interface PluginAccessMember {
+ id: string;
+ companyId: string;
+ principalType: PrincipalType;
+ principalId: string;
+ status: MembershipStatus;
+ membershipRole: string | null;
+ grants: PrincipalPermissionGrant[];
+ createdAt: Date | string;
+ updatedAt: Date | string;
+}
+
+export interface PluginAccessInvite {
+ id: string;
+ companyId: string | null;
+ inviteType: string;
+ allowedJoinTypes: InviteJoinType;
+ defaultsPayload: Record | null;
+ expiresAt: Date | string;
+ invitedByUserId: string | null;
+ revokedAt: Date | string | null;
+ acceptedAt: Date | string | null;
+ createdAt: Date | string;
+ updatedAt: Date | string;
+ state: "active" | "revoked" | "accepted" | "expired";
+}
+
+export interface PluginAccessMembersClient {
+ list(input: { companyId: string; includeArchived?: boolean }): Promise;
+ get(memberId: string, companyId: string): Promise;
+ update(
+ memberId: string,
+ patch: {
+ membershipRole?: HumanCompanyMembershipRole | null;
+ status?: Extract;
+ },
+ companyId: string,
+ ): Promise;
+}
+
+export interface PluginAccessInvitesClient {
+ list(input: {
+ companyId: string;
+ state?: PluginAccessInvite["state"];
+ limit?: number;
+ offset?: number;
+ }): Promise<{ invites: PluginAccessInvite[]; nextOffset: number | null }>;
+ create(input: {
+ companyId: string;
+ allowedJoinTypes?: InviteJoinType;
+ humanRole?: HumanCompanyMembershipRole | null;
+ defaultsPayload?: Record | null;
+ agentMessage?: string | null;
+ }): Promise;
+ revoke(inviteId: string, companyId: string): Promise;
+}
+
+export interface PluginAccessClient {
+ /** Read and update company memberships. Requires `access.members.*`. */
+ members: PluginAccessMembersClient;
+ /** Read, create, and revoke company invites. Requires `access.invites.*`. */
+ invites: PluginAccessInvitesClient;
+}
+
+export interface PluginAuthorizationPolicySummary {
+ companyId: string;
+ permissionsMode: "simple";
+ memberCount: number;
+ activeMemberCount: number;
+ grantCount: number;
+ advancedPolicyAvailable: false;
+}
+
+export interface PluginAuthorizationPolicyRecord {
+ resourceType: "company" | "agent" | "project" | "issue";
+ resourceId: string;
+ companyId: string;
+ policy: Record | null;
+ updatedAt: Date | string | null;
+}
+
+export interface PluginAssignmentPreviewInput {
+ companyId: string;
+ actor:
+ | { type: "board"; userId?: string | null; companyIds?: string[]; isInstanceAdmin?: boolean }
+ | { type: "agent"; agentId: string; companyId: string };
+ target: {
+ issueId?: string | null;
+ projectId?: string | null;
+ parentIssueId?: string | null;
+ assigneeAgentId?: string | null;
+ assigneeUserId?: string | null;
+ status?: string | null;
+ };
+}
+
+export interface PluginAuthorizationDecisionResult {
+ allowed: boolean;
+ action: string;
+ explanation: string;
+ reason: string;
+ grant?: {
+ principalType: PrincipalType;
+ principalId: string;
+ permissionKey: PermissionKey;
+ scope: Record | null;
+ };
+}
+
+export interface PluginAuthorizationAuditEntry {
+ id: string;
+ companyId: string;
+ actorType: string;
+ actorId: string;
+ action: string;
+ entityType: string;
+ entityId: string;
+ details: Record | null;
+ createdAt: Date | string;
+}
+
+export interface PluginAuthorizationClient {
+ grants: {
+ list(input: { companyId: string; principalType?: PrincipalType; principalId?: string }): Promise;
+ set(input: {
+ companyId: string;
+ principalType: PrincipalType;
+ principalId: string;
+ grants: Array<{ permissionKey: PermissionKey; scope?: Record | null }>;
+ grantedByUserId?: string | null;
+ }): Promise;
+ };
+ policies: {
+ summary(companyId: string): Promise;
+ get(input: { companyId: string; resourceType: PluginAuthorizationPolicyRecord["resourceType"]; resourceId: string }): Promise;
+ update(input: {
+ companyId: string;
+ resourceType: PluginAuthorizationPolicyRecord["resourceType"];
+ resourceId: string;
+ policy: Record | null;
+ }): Promise;
+ previewAssignment(input: PluginAssignmentPreviewInput): Promise;
+ explainAssignment(input: PluginAssignmentPreviewInput): Promise;
+ };
+ audit: {
+ search(input: {
+ companyId: string;
+ action?: string;
+ actorType?: string;
+ actorId?: string;
+ entityType?: string;
+ entityId?: string;
+ decision?: string;
+ limit?: number;
+ offset?: number;
+ }): Promise;
+ };
+}
+
// ---------------------------------------------------------------------------
// Streaming (worker → UI push channel)
// ---------------------------------------------------------------------------
@@ -1716,6 +1891,12 @@ export interface PluginContext {
/** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */
goals: PluginGoalsClient;
+ /** Read and manage access memberships and invites. Requires `access.*` capabilities. */
+ access: PluginAccessClient;
+
+ /** Read and manage authorization grants, policy summaries, previews, and audit entries. Requires `authorization.*` capabilities. */
+ authorization: PluginAuthorizationClient;
+
/** Register getData handlers for the plugin's UI components. */
data: PluginDataClient;
diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts
index 8f90af70a3a..e2ce8f63aec 100644
--- a/packages/plugins/sdk/src/ui/index.ts
+++ b/packages/plugins/sdk/src/ui/index.ts
@@ -146,6 +146,7 @@ export type {
// Slot component prop interfaces
export type {
PluginPageProps,
+ PluginCompanySettingsPageProps,
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts
index b8216836157..8923803e4e5 100644
--- a/packages/plugins/sdk/src/ui/types.ts
+++ b/packages/plugins/sdk/src/ui/types.ts
@@ -229,6 +229,18 @@ export interface PluginPageProps {
context: PluginHostContext;
}
+/**
+ * Props passed to a plugin company settings page component.
+ *
+ * A company settings page is mounted at
+ * `/:companyPrefix/company/settings/:routePath` and always receives the active
+ * company id and prefix when available.
+ */
+export interface PluginCompanySettingsPageProps {
+ /** The current host context, including company id and prefix. */
+ context: PluginHostContext;
+}
+
/**
* Props passed to a plugin dashboard widget component.
*
diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts
index 9bfae39e675..a50b6d8f14c 100644
--- a/packages/plugins/sdk/src/worker-rpc-host.ts
+++ b/packages/plugins/sdk/src/worker-rpc-host.ts
@@ -35,6 +35,7 @@
*/
import fs from "node:fs";
+import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { fileURLToPath } from "node:url";
@@ -66,6 +67,7 @@ import type {
} from "./types.js";
import type {
JsonRpcId,
+ JsonRpcNotification,
JsonRpcRequest,
JsonRpcResponse,
InitializeParams,
@@ -85,6 +87,7 @@ import type {
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentProbeParams,
+ PluginInvocationContext,
WorkerToHostMethodName,
WorkerToHostMethods,
} from "./protocol.js";
@@ -279,6 +282,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let manifest: PaperclipPluginManifestV1 | null = null;
let currentConfig: Record = {};
let databaseNamespace: string | null = null;
+ const invocationContextStorage = new AsyncLocalStorage();
// Plugin handler registrations (populated during setup())
const eventHandlers: EventRegistration[] = [];
@@ -365,7 +369,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
try {
- const request = createRequest(method, params, id);
+ const activeInvocation = invocationContextStorage.getStore();
+ const request = {
+ ...createRequest(method, params, id),
+ ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
+ };
sendMessage(request);
} catch (err) {
settle(reject, err instanceof Error ? err : new Error(String(err)));
@@ -378,7 +386,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
*/
function notifyHost(method: string, params: unknown): void {
try {
- sendMessage(createNotification(method, params));
+ const activeInvocation = invocationContextStorage.getStore();
+ sendMessage({
+ ...createNotification(method, params),
+ ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
+ });
} catch {
// Swallow — the host may have closed stdin
}
@@ -1086,6 +1098,85 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
+ access: {
+ members: {
+ async list(input) {
+ return callHost("access.members.list", {
+ companyId: input.companyId,
+ includeArchived: input.includeArchived,
+ });
+ },
+
+ async get(memberId: string, companyId: string) {
+ return callHost("access.members.get", { memberId, companyId });
+ },
+
+ async update(memberId: string, patch, companyId: string) {
+ return callHost("access.members.update", { memberId, patch, companyId });
+ },
+ },
+
+ invites: {
+ async list(input) {
+ return callHost("access.invites.list", {
+ companyId: input.companyId,
+ state: input.state,
+ limit: input.limit,
+ offset: input.offset,
+ });
+ },
+
+ async create(input) {
+ return callHost("access.invites.create", {
+ companyId: input.companyId,
+ allowedJoinTypes: input.allowedJoinTypes,
+ humanRole: input.humanRole,
+ defaultsPayload: input.defaultsPayload,
+ agentMessage: input.agentMessage,
+ });
+ },
+
+ async revoke(inviteId: string, companyId: string) {
+ return callHost("access.invites.revoke", { inviteId, companyId });
+ },
+ },
+ },
+
+ authorization: {
+ grants: {
+ async list(input) {
+ return callHost("authorization.grants.list", input);
+ },
+ async set(input) {
+ return callHost("authorization.grants.set", input);
+ },
+ },
+
+ policies: {
+ async summary(companyId: string) {
+ return callHost("authorization.policies.summary", { companyId });
+ },
+ async get(input) {
+ return callHost("authorization.policies.get", input);
+ },
+ async update(input) {
+ return callHost("authorization.policies.update", input);
+ },
+ async previewAssignment(input) {
+ return callHost("authorization.policies.previewAssignment", input);
+ },
+ async explainAssignment(input) {
+ return callHost("authorization.policies.explainAssignment", input);
+ },
+ },
+
+ audit: {
+ async search(input) {
+ return callHost("authorization.audit.search", input);
+ },
+ },
+ },
+
data: {
register(key: string, handler: (params: Record) => Promise): void {
dataHandlers.set(key, handler);
@@ -1175,7 +1266,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const { id, method, params } = request;
try {
- const result = await dispatchMethod(method, params);
+ const invoke = () => dispatchMethod(method, params);
+ const result = request.paperclipInvocation
+ ? await invocationContextStorage.run(request.paperclipInvocation, invoke)
+ : await invoke();
sendMessage(createSuccessResponse(id, result ?? null));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -1413,11 +1507,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No data handler registered for key "${params.key}"`);
}
- return handler(
- params.renderEnvironment === undefined
- ? params.params
- : { ...params.params, renderEnvironment: params.renderEnvironment },
- );
+ return handler({
+ ...params.params,
+ ...(params.companyId === undefined ? {} : { companyId: params.companyId }),
+ ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
+ });
}
async function handlePerformAction(params: PerformActionParams): Promise {
@@ -1425,11 +1519,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`);
}
- return handler(
- params.renderEnvironment === undefined
- ? params.params
- : { ...params.params, renderEnvironment: params.renderEnvironment },
- );
+ return handler({
+ ...params.params,
+ ...(params.companyId === undefined ? {} : { companyId: params.companyId }),
+ ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
+ });
}
async function handleExecuteTool(params: ExecuteToolParams): Promise {
@@ -1597,14 +1691,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
} else if (isJsonRpcNotification(message)) {
// Dispatch host→worker push notifications
- const notif = message as { method: string; params?: unknown };
+ const notif = message as JsonRpcNotification & { method: string; params?: unknown };
+ const runNotification = (fn: () => void | Promise) => {
+ if (notif.paperclipInvocation) {
+ return invocationContextStorage.run(notif.paperclipInvocation, fn);
+ }
+ return fn();
+ };
if (notif.method === "agents.sessions.event" && notif.params) {
const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event);
} else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers
- handleOnEvent(notif.params as OnEventParams).catch((err) => {
+ Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
notifyHost("log", {
level: "error",
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,
diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts
new file mode 100644
index 00000000000..8cded9c8a15
--- /dev/null
+++ b/packages/plugins/sdk/tests/host-client-factory.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { HostServices } from "../src/host-client-factory.js";
+import {
+ CapabilityDeniedError,
+ createHostClientHandlers,
+ InvocationScopeDeniedError,
+} from "../src/host-client-factory.js";
+
+describe("createHostClientHandlers invocation company scope", () => {
+ it("rejects company-scoped host calls outside the current invocation company", async () => {
+ const projectsList = vi.fn(async () => []);
+ const services = {
+ projects: {
+ list: projectsList,
+ },
+ } as unknown as HostServices;
+
+ const handlers = createHostClientHandlers({
+ pluginId: "paperclip.test",
+ capabilities: ["projects.read"],
+ services,
+ });
+
+ await expect(
+ handlers["projects.list"](
+ { companyId: "company-b" },
+ { invocationScope: { companyId: "company-a" } },
+ ),
+ ).rejects.toBeInstanceOf(InvocationScopeDeniedError);
+ expect(projectsList).not.toHaveBeenCalled();
+ });
+
+ it("filters companies.list to the current invocation company", async () => {
+ const services = {
+ companies: {
+ list: vi.fn(async () => [
+ { id: "company-a", name: "Company A" },
+ { id: "company-b", name: "Company B" },
+ ]),
+ },
+ } as unknown as HostServices;
+
+ const handlers = createHostClientHandlers({
+ pluginId: "paperclip.test",
+ capabilities: ["companies.read"],
+ services,
+ });
+
+ await expect(
+ handlers["companies.list"](
+ {},
+ { invocationScope: { companyId: "company-a" } },
+ ),
+ ).resolves.toEqual([{ id: "company-a", name: "Company A" }]);
+ });
+
+ it("rejects company-scope store access for a different company", async () => {
+ const stateGet = vi.fn(async () => null);
+ const services = {
+ state: {
+ get: stateGet,
+ },
+ } as unknown as HostServices;
+
+ const handlers = createHostClientHandlers({
+ pluginId: "paperclip.test",
+ capabilities: ["plugin.state.read"],
+ services,
+ });
+
+ await expect(
+ handlers["state.get"](
+ { scopeKind: "company", scopeId: "company-b", stateKey: "settings" },
+ { invocationScope: { companyId: "company-a" } },
+ ),
+ ).rejects.toBeInstanceOf(InvocationScopeDeniedError);
+ expect(stateGet).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [
+ "access.members.list",
+ "access.members.read",
+ { companyId: "company-a" },
+ (services: HostServices) => vi.mocked(services.access.listMembers),
+ ],
+ [
+ "access.members.update",
+ "access.members.write",
+ { companyId: "company-a", memberId: "member-a", patch: { status: "active" } },
+ (services: HostServices) => vi.mocked(services.access.updateMember),
+ ],
+ [
+ "authorization.grants.set",
+ "authorization.grants.write",
+ { companyId: "company-a", principalType: "agent", principalId: "agent-a", grants: [] },
+ (services: HostServices) => vi.mocked(services.authorization.setGrants),
+ ],
+ [
+ "authorization.policies.update",
+ "authorization.policies.write",
+ { companyId: "company-a", resourceType: "agent", resourceId: "agent-a", policy: null },
+ (services: HostServices) => vi.mocked(services.authorization.updatePolicy),
+ ],
+ [
+ "authorization.audit.search",
+ "authorization.audit.read",
+ { companyId: "company-a" },
+ (services: HostServices) => vi.mocked(services.authorization.searchAudit),
+ ],
+ ] as const)(
+ "rejects %s when the plugin lacks %s",
+ async (method, capability, params, getDelegate) => {
+ const services = {
+ access: {
+ listMembers: vi.fn(async () => []),
+ updateMember: vi.fn(async () => ({ id: "member-a" })),
+ },
+ authorization: {
+ setGrants: vi.fn(async () => []),
+ updatePolicy: vi.fn(async () => ({ policy: null })),
+ searchAudit: vi.fn(async () => []),
+ },
+ } as unknown as HostServices;
+ const handlers = createHostClientHandlers({
+ pluginId: "paperclip.test",
+ capabilities: [],
+ services,
+ });
+
+ await expect(
+ (handlers as Record Promise>)[method](params),
+ ).rejects.toMatchObject({
+ name: "CapabilityDeniedError",
+ message: expect.stringContaining(capability),
+ });
+ await expect(
+ (handlers as Record Promise>)[method](params),
+ ).rejects.toBeInstanceOf(CapabilityDeniedError);
+ expect(getDelegate(services)).not.toHaveBeenCalled();
+ },
+ );
+
+ it("checks invocation company scope before exposing authorization data", async () => {
+ const searchAudit = vi.fn(async () => []);
+ const services = {
+ authorization: {
+ searchAudit,
+ },
+ } as unknown as HostServices;
+ const handlers = createHostClientHandlers({
+ pluginId: "paperclip.test",
+ capabilities: ["authorization.audit.read"],
+ services,
+ });
+
+ await expect(
+ handlers["authorization.audit.search"](
+ { companyId: "company-b" },
+ { invocationScope: { companyId: "company-a" } },
+ ),
+ ).rejects.toBeInstanceOf(InvocationScopeDeniedError);
+ expect(searchAudit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts
index f8e0a38e5dc..b7f15781b5f 100644
--- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts
+++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts
@@ -1,11 +1,26 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
+import { createInterface } from "node:readline";
+import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
-import { isWorkerEntrypoint } from "../src/worker-rpc-host.js";
+import { definePlugin } from "../src/define-plugin.js";
+import {
+ createRequest,
+ createErrorResponse,
+ createSuccessResponse,
+ isJsonRpcRequest,
+ isJsonRpcResponse,
+ parseMessage,
+ PLUGIN_RPC_ERROR_CODES,
+ serializeMessage,
+ type JsonRpcResponse,
+ type PluginInvocationContext,
+} from "../src/protocol.js";
+import { isWorkerEntrypoint, startWorkerRpcHost } from "../src/worker-rpc-host.js";
describe("isWorkerEntrypoint", () => {
const tempRoots: string[] = [];
@@ -55,3 +70,145 @@ describe("isWorkerEntrypoint", () => {
).toBe(false);
});
});
+
+describe("worker invocation scope propagation", () => {
+ it("keeps overlapping company scopes local to each getData invocation", async () => {
+ const hostToWorker = new PassThrough();
+ const workerToHost = new PassThrough();
+ const hostReadline = createInterface({ input: workerToHost });
+ const pending = new Map void>();
+ const nestedInvocationIds: string[] = [];
+ const invocationCompanies = new Map([
+ ["invocation-a", "company-a"],
+ ["invocation-b", "company-b"],
+ ]);
+ let releaseCompanyA: (() => void) | null = null;
+ let nextRequestId = 1;
+
+ const plugin = definePlugin({
+ async setup(ctx) {
+ ctx.data.register("probe", async (params) => {
+ if (params.label === "a") {
+ await new Promise((resolve) => {
+ releaseCompanyA = resolve;
+ });
+ }
+ const company = await ctx.companies.get(String(params.requestedCompanyId));
+ return { label: params.label, company };
+ });
+ },
+ });
+
+ const worker = startWorkerRpcHost({
+ plugin,
+ stdin: hostToWorker,
+ stdout: workerToHost,
+ });
+
+ function callWorker(method: string, params: unknown, invocation?: PluginInvocationContext) {
+ const id = `host-${nextRequestId++}`;
+ const request = {
+ ...createRequest(method, params, id),
+ ...(invocation ? { paperclipInvocation: invocation } : {}),
+ };
+ const result = new Promise((resolve, reject) => {
+ pending.set(id, (response) => {
+ if ("error" in response && response.error) {
+ reject(new Error(response.error.message));
+ return;
+ }
+ resolve((response as { result?: unknown }).result);
+ });
+ });
+ hostToWorker.write(serializeMessage(request));
+ return result;
+ }
+
+ hostReadline.on("line", (line) => {
+ const message = parseMessage(line);
+ if (isJsonRpcResponse(message)) {
+ pending.get(String(message.id))?.(message);
+ pending.delete(String(message.id));
+ return;
+ }
+
+ if (!isJsonRpcRequest(message)) return;
+ if (message.method !== "companies.get") return;
+
+ const invocationId = (message as { paperclipInvocationId?: string }).paperclipInvocationId ?? "";
+ const requestedCompanyId = (message.params as { companyId?: string }).companyId;
+ const allowedCompanyId = invocationCompanies.get(invocationId);
+ nestedInvocationIds.push(invocationId);
+ if (requestedCompanyId !== allowedCompanyId) {
+ hostToWorker.write(serializeMessage(createErrorResponse(
+ message.id,
+ PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
+ `requested company "${requestedCompanyId}" but invocation "${invocationId}" is scoped to "${allowedCompanyId}"`,
+ )));
+ return;
+ }
+
+ hostToWorker.write(serializeMessage(createSuccessResponse(message.id, {
+ id: requestedCompanyId,
+ })));
+
+ if (invocationId === "invocation-b") {
+ releaseCompanyA?.();
+ }
+ });
+
+ try {
+ await callWorker("initialize", {
+ manifest: {
+ id: "paperclip.scope-test",
+ apiVersion: 1,
+ version: "1.0.0",
+ displayName: "Scope test",
+ description: "Scope test",
+ author: "Paperclip",
+ categories: ["automation"],
+ capabilities: ["companies.read"],
+ entrypoints: { worker: "dist/worker.js" },
+ },
+ config: {},
+ instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
+ apiVersion: 1,
+ });
+
+ const companyARequest = callWorker(
+ "getData",
+ {
+ key: "probe",
+ companyId: "company-a",
+ params: { label: "a", requestedCompanyId: "company-b" },
+ },
+ { id: "invocation-a", scope: { companyId: "company-a" } },
+ );
+ const companyAExpectation = expect(companyARequest).rejects.toThrow(
+ /requested company "company-b"/,
+ );
+ const companyBRequest = callWorker(
+ "getData",
+ {
+ key: "probe",
+ companyId: "company-b",
+ params: { label: "b", requestedCompanyId: "company-b" },
+ },
+ { id: "invocation-b", scope: { companyId: "company-b" } },
+ );
+
+ await expect(companyBRequest).resolves.toEqual({
+ label: "b",
+ company: { id: "company-b" },
+ });
+ await companyAExpectation;
+
+ expect(nestedInvocationIds).toEqual(["invocation-b", "invocation-a"]);
+ } finally {
+ worker.stop();
+ hostReadline.close();
+ hostToWorker.destroy();
+ workerToHost.destroy();
+ }
+ });
+});
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 9c295eae956..b0c92acb006 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -739,6 +739,11 @@ export const PLUGIN_CAPABILITIES = [
"activity.read",
"costs.read",
"issues.orchestration.read",
+ "access.members.read",
+ "access.invites.read",
+ "authorization.grants.read",
+ "authorization.policies.read",
+ "authorization.audit.read",
"database.namespace.read",
// Data Write
"issues.create",
@@ -756,6 +761,10 @@ export const PLUGIN_CAPABILITIES = [
"agents.resume",
"agents.invoke",
"agents.managed",
+ "access.members.write",
+ "access.invites.write",
+ "authorization.grants.write",
+ "authorization.policies.write",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
@@ -857,6 +866,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"commentAnnotation",
"commentContextMenuItem",
"settingsPage",
+ "companySettingsPage",
] as const;
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
@@ -887,6 +897,21 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
export type PluginReservedCompanyRouteSegment =
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number];
+/**
+ * Reserved route segments under `/:companyPrefix/company/settings/...` that
+ * plugin company settings pages may not claim.
+ */
+export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [
+ "general",
+ "environments",
+ "access",
+ "members",
+ "invites",
+ "secrets",
+] as const;
+export type PluginReservedCompanySettingsRouteSegment =
+ (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number];
+
/**
* Launcher placement zones describe where a plugin-owned launcher can appear
* in the host UI. These are intentionally aligned with current slot surfaces
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index a851acf0aca..9afa40f46a3 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -111,6 +111,7 @@ export {
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
+ PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS,
@@ -226,6 +227,7 @@ export {
type PluginCapability,
type PluginUiSlotType,
type PluginUiSlotEntityType,
+ type PluginReservedCompanySettingsRouteSegment,
type PluginLauncherPlacementZone,
type PluginLauncherAction,
type PluginLauncherBounds,
diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts
index 18aea077210..14c227dd6ce 100644
--- a/packages/shared/src/types/agent.ts
+++ b/packages/shared/src/types/agent.ts
@@ -58,7 +58,7 @@ export interface AgentInstructionsBundle {
export interface AgentAccessState {
canAssignTasks: boolean;
- taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none";
+ taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none";
membership: CompanyMembership | null;
grants: PrincipalPermissionGrant[];
}
diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts
index 6f9629122cc..f9330a48005 100644
--- a/packages/shared/src/types/plugin.ts
+++ b/packages/shared/src/types/plugin.ts
@@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration {
*/
entityTypes?: PluginUiSlotEntityType[];
/**
- * Optional company-scoped route segment for page and routeSidebar slots.
+ * Optional company-scoped route segment for page, routeSidebar, and
+ * companySettingsPage slots.
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
+ * For companySettingsPage, `permissions` becomes
+ * `/:companyPrefix/company/settings/permissions`.
*/
routePath?: string;
/**
diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts
index 1984aa6b209..210c8845096 100644
--- a/packages/shared/src/validators/plugin.test.ts
+++ b/packages/shared/src/validators/plugin.test.ts
@@ -8,6 +8,37 @@ describe("plugin capability constants", () => {
});
});
+describe("plugin manifest validators", () => {
+ it("accepts existing-style plugins that do not request access or authorization capabilities", () => {
+ const parsed = pluginManifestV1Schema.parse({
+ id: "paperclip.compat-dashboard",
+ apiVersion: 1,
+ version: "0.1.0",
+ displayName: "Compat Dashboard",
+ description: "Dashboard-only plugin without access or authorization host APIs.",
+ author: "Paperclip",
+ categories: ["ui"],
+ capabilities: ["ui.dashboardWidget.register"],
+ entrypoints: {
+ worker: "./dist/worker.js",
+ ui: "./dist/ui.js",
+ },
+ ui: {
+ slots: [
+ {
+ type: "dashboardWidget",
+ id: "compat-dashboard",
+ displayName: "Compat Dashboard",
+ exportName: "CompatDashboard",
+ },
+ ],
+ },
+ });
+
+ expect(parsed.capabilities).toEqual(["ui.dashboardWidget.register"]);
+ });
+});
+
describe("plugin managed routine validators", () => {
it("accepts core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.parse({
@@ -128,4 +159,30 @@ describe("plugin UI slot validators", () => {
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
});
+
+ it("accepts company settings page slots with a non-core settings route", () => {
+ const parsed = pluginUiSlotDeclarationSchema.parse({
+ type: "companySettingsPage",
+ id: "permissions-settings",
+ displayName: "Permissions",
+ exportName: "PermissionsSettingsPage",
+ routePath: "permissions",
+ });
+
+ expect(parsed.routePath).toBe("permissions");
+ });
+
+ it("prevents company settings page slots from shadowing core settings routes", () => {
+ const parsed = pluginUiSlotDeclarationSchema.safeParse({
+ type: "companySettingsPage",
+ id: "access-settings",
+ displayName: "Access",
+ exportName: "AccessSettingsPage",
+ routePath: "access",
+ });
+
+ expect(parsed.success).toBe(false);
+ if (parsed.success) return;
+ expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
+ });
});
diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts
index cbc900cf36e..0a857b44d82 100644
--- a/packages/shared/src/validators/plugin.ts
+++ b/packages/shared/src/validators/plugin.ts
@@ -6,6 +6,7 @@ import {
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
+ PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS,
@@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["entityTypes"],
});
}
- if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
+ if (value.routePath && value.type !== "page" && value.type !== "routeSidebar" && value.type !== "companySettingsPage") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "routePath is only supported for page and routeSidebar slots",
+ message: "routePath is only supported for page, routeSidebar, and companySettingsPage slots",
path: ["routePath"],
});
}
@@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"],
});
}
+ if (value.type === "companySettingsPage" && !value.routePath) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "companySettingsPage slots require routePath",
+ path: ["routePath"],
+ });
+ }
if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"],
});
}
+ if (
+ value.type === "companySettingsPage"
+ && value.routePath
+ && PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number])
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `company settings routePath "${value.routePath}" is reserved by the host`,
+ path: ["routePath"],
+ });
+ }
});
export type PluginUiSlotDeclarationInput = z.infer;
diff --git a/server/src/__tests__/access-routes-permissions-upgrade.test.ts b/server/src/__tests__/access-routes-permissions-upgrade.test.ts
new file mode 100644
index 00000000000..9db328d4519
--- /dev/null
+++ b/server/src/__tests__/access-routes-permissions-upgrade.test.ts
@@ -0,0 +1,167 @@
+import { randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { and, eq } from "drizzle-orm";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ activityLog,
+ companies,
+ companyMemberships,
+ createDb,
+ principalPermissionGrants,
+} from "@paperclipai/db";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+
+vi.hoisted(() => {
+ process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
+ process.env.PAPERCLIP_INSTANCE_ID = "vitest";
+ process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
+ process.env.PAPERCLIP_IN_WORKTREE = "false";
+});
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+type Db = ReturnType;
+
+async function createApp(db: Db, companyId: string, userId: string) {
+ process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
+ process.env.PAPERCLIP_IN_WORKTREE = "false";
+ const { accessRoutes } = await import("../routes/access.js");
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.actor = {
+ type: "board",
+ userId,
+ source: "local_implicit",
+ companyIds: [companyId],
+ memberships: [{ companyId, membershipRole: "owner", status: "active" }],
+ isInstanceAdmin: true,
+ };
+ next();
+ });
+ app.use("/api", accessRoutes(db, {
+ deploymentMode: "authenticated",
+ deploymentExposure: "private",
+ bindHost: "127.0.0.1",
+ allowedHostnames: [],
+ }));
+ app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
+ });
+ return app;
+}
+
+async function createCompanyWithOwner(db: Db) {
+ const company = await db
+ .insert(companies)
+ .values({
+ name: `Access Routes ${randomUUID()}`,
+ issuePrefix: `AR${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ const owner = await db
+ .insert(companyMemberships)
+ .values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: `owner-${randomUUID()}`,
+ status: "active",
+ membershipRole: "owner",
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ return { company, owner };
+}
+
+describeEmbeddedPostgres("access routes permissions upgrade compatibility", () => {
+ let db!: Db;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-routes-permissions-upgrade-");
+ db = createDb(tempDb.connectionString);
+ }, 20_000);
+
+ afterEach(async () => {
+ await db.delete(activityLog);
+ await db.delete(principalPermissionGrants);
+ await db.delete(companyMemberships);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ it("rejects owner self-lockout through the member route after the permissions upgrade", async () => {
+ const { company, owner } = await createCompanyWithOwner(db);
+
+ const res = await request(await createApp(db, company.id, owner.principalId))
+ .patch(`/api/companies/${company.id}/members/${owner.id}`)
+ .send({ membershipRole: "admin" });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(403);
+ expect(res.body.error).toContain("You cannot remove yourself");
+
+ const unchanged = await db
+ .select()
+ .from(companyMemberships)
+ .where(eq(companyMemberships.id, owner.id))
+ .then((rows) => rows[0]!);
+ expect(unchanged.membershipRole).toBe("owner");
+ });
+
+ it("keeps custom grants when the role-only member route changes a member role", async () => {
+ const { company, owner } = await createCompanyWithOwner(db);
+ const member = await db
+ .insert(companyMemberships)
+ .values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: `admin-${randomUUID()}`,
+ status: "active",
+ membershipRole: "admin",
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ const customScope = { projectIds: ["project-1"] };
+ await db.insert(principalPermissionGrants).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: member.principalId,
+ permissionKey: "tasks:assign_scope",
+ scope: customScope,
+ grantedByUserId: owner.principalId,
+ });
+
+ const res = await request(await createApp(db, company.id, owner.principalId))
+ .patch(`/api/companies/${company.id}/members/${member.id}`)
+ .send({ membershipRole: "operator" });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(res.body.membershipRole).toBe("operator");
+
+ const grants = await db
+ .select()
+ .from(principalPermissionGrants)
+ .where(
+ and(
+ eq(principalPermissionGrants.companyId, company.id),
+ eq(principalPermissionGrants.principalType, "user"),
+ eq(principalPermissionGrants.principalId, member.principalId),
+ ),
+ );
+ expect(grants).toHaveLength(1);
+ expect(grants[0]).toMatchObject({
+ permissionKey: "tasks:assign_scope",
+ scope: customScope,
+ grantedByUserId: owner.principalId,
+ });
+ });
+});
diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts
index f9ac3e64c9b..a0daac807aa 100644
--- a/server/src/__tests__/access-service.test.ts
+++ b/server/src/__tests__/access-service.test.ts
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
-import { eq } from "drizzle-orm";
+import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
+ agents,
companies,
companyMemberships,
createDb,
@@ -14,6 +15,8 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { accessService } from "../services/access.js";
+import { grantsForHumanRole } from "../services/company-member-roles.js";
+import { backfillPrincipalAccessCompatibility } from "../services/principal-access-compatibility.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -56,6 +59,7 @@ describeEmbeddedPostgres("access service", () => {
await db.delete(issues);
await db.delete(principalPermissionGrants);
await db.delete(instanceUserRoles);
+ await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companies);
});
@@ -221,4 +225,285 @@ describeEmbeddedPostgres("access service", () => {
access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }),
).rejects.toThrow("Instance admins cannot be removed from company access");
});
+
+ it("allows owner and admin role-default grants to manage environments", async () => {
+ const { company, owner } = await createCompanyWithOwner(db);
+ const access = accessService(db);
+ const roles = ["admin", "operator", "viewer"] as const;
+ const members = await db
+ .insert(companyMemberships)
+ .values(
+ roles.map((role) => ({
+ companyId: company.id,
+ principalType: "user" as const,
+ principalId: `${role}-${randomUUID()}`,
+ status: "active" as const,
+ membershipRole: role,
+ })),
+ )
+ .returning();
+
+ await access.setPrincipalGrants(
+ company.id,
+ "user",
+ owner.principalId,
+ grantsForHumanRole("owner"),
+ owner.principalId,
+ );
+ for (const member of members) {
+ await access.setPrincipalGrants(
+ company.id,
+ "user",
+ member.principalId,
+ grantsForHumanRole(member.membershipRole as "admin" | "operator" | "viewer"),
+ owner.principalId,
+ );
+ }
+
+ const admin = members.find((member) => member.membershipRole === "admin")!;
+ const operator = members.find((member) => member.membershipRole === "operator")!;
+ const viewer = members.find((member) => member.membershipRole === "viewer")!;
+
+ await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true);
+ await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
+ await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false);
+ await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false);
+ });
+
+ it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => {
+ const { company, owner } = await createCompanyWithOwner(db);
+ const scopedEnvironmentGrant = { environmentId: "env-1" };
+ const humanRows = await db
+ .insert(companyMemberships)
+ .values([
+ {
+ companyId: company.id,
+ principalType: "user",
+ principalId: `admin-${randomUUID()}`,
+ status: "active",
+ membershipRole: "admin",
+ },
+ {
+ companyId: company.id,
+ principalType: "user",
+ principalId: `operator-${randomUUID()}`,
+ status: "active",
+ membershipRole: "operator",
+ },
+ {
+ companyId: company.id,
+ principalType: "user",
+ principalId: `viewer-${randomUUID()}`,
+ status: "active",
+ membershipRole: "viewer",
+ },
+ {
+ companyId: company.id,
+ principalType: "user",
+ principalId: `legacy-${randomUUID()}`,
+ status: "active",
+ membershipRole: null,
+ },
+ ])
+ .returning();
+ const admin = humanRows[0]!;
+ const operator = humanRows[1]!;
+ const viewer = humanRows[2]!;
+ const legacyMember = humanRows[3]!;
+
+ await db.insert(principalPermissionGrants).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: owner.principalId,
+ permissionKey: "environments:manage",
+ scope: scopedEnvironmentGrant,
+ grantedByUserId: "custom-author",
+ });
+
+ const first = await backfillPrincipalAccessCompatibility(db);
+ const second = await backfillPrincipalAccessCompatibility(db);
+
+ expect(first.humanGrantsInserted).toBeGreaterThan(0);
+ expect(second.humanGrantsInserted).toBe(0);
+ await expect(accessService(db).canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
+ await expect(accessService(db).canUser(company.id, operator.principalId, "tasks:assign")).resolves.toBe(true);
+ await expect(accessService(db).canUser(company.id, legacyMember.principalId, "tasks:assign")).resolves.toBe(true);
+ await expect(accessService(db).canUser(company.id, viewer.principalId, "tasks:assign")).resolves.toBe(false);
+
+ const ownerEnvironmentGrants = await db
+ .select()
+ .from(principalPermissionGrants)
+ .where(
+ and(
+ eq(principalPermissionGrants.companyId, company.id),
+ eq(principalPermissionGrants.principalId, owner.principalId),
+ eq(principalPermissionGrants.permissionKey, "environments:manage"),
+ ),
+ );
+ expect(ownerEnvironmentGrants).toHaveLength(1);
+ expect(ownerEnvironmentGrants[0]?.scope).toEqual(scopedEnvironmentGrant);
+ expect(ownerEnvironmentGrants[0]?.grantedByUserId).toBe("custom-author");
+ });
+
+ it("backfills non-terminal agents as active company members without reviving pending or terminated agents", async () => {
+ const { company } = await createCompanyWithOwner(db);
+ const agentRows = await db
+ .insert(agents)
+ .values([
+ {
+ companyId: company.id,
+ name: `Idle ${randomUUID()}`,
+ role: "engineer",
+ status: "idle",
+ adapterType: "process",
+ adapterConfig: {},
+ runtimeConfig: {},
+ },
+ {
+ companyId: company.id,
+ name: `Running ${randomUUID()}`,
+ role: "engineer",
+ status: "running",
+ adapterType: "process",
+ adapterConfig: {},
+ runtimeConfig: {},
+ },
+ {
+ companyId: company.id,
+ name: `Pending ${randomUUID()}`,
+ role: "engineer",
+ status: "pending_approval",
+ adapterType: "process",
+ adapterConfig: {},
+ runtimeConfig: {},
+ },
+ {
+ companyId: company.id,
+ name: `Terminated ${randomUUID()}`,
+ role: "engineer",
+ status: "terminated",
+ adapterType: "process",
+ adapterConfig: {},
+ runtimeConfig: {},
+ },
+ ])
+ .returning();
+ const idleAgent = agentRows[0]!;
+ const runningAgent = agentRows[1]!;
+ const pendingAgent = agentRows[2]!;
+ const terminatedAgent = agentRows[3]!;
+
+ const first = await backfillPrincipalAccessCompatibility(db);
+ const second = await backfillPrincipalAccessCompatibility(db);
+
+ expect(first.agentMembershipsInserted).toBe(2);
+ expect(second.agentMembershipsInserted).toBe(0);
+ const memberships = await db
+ .select()
+ .from(companyMemberships)
+ .where(eq(companyMemberships.principalType, "agent"));
+ expect(memberships.map((membership) => membership.principalId).sort()).toEqual([
+ idleAgent.id,
+ runningAgent.id,
+ ].sort());
+ expect(memberships.every((membership) => membership.status === "active")).toBe(true);
+ expect(memberships.every((membership) => membership.membershipRole === "member")).toBe(true);
+ expect(memberships.some((membership) => membership.principalId === pendingAgent.id)).toBe(false);
+ expect(memberships.some((membership) => membership.principalId === terminatedAgent.id)).toBe(false);
+ });
+
+ it("copies active user memberships with role-default grants for safe company imports", async () => {
+ const source = await createCompanyWithOwner(db);
+ const target = await createCompanyWithOwner(db);
+ const admin = await db
+ .insert(companyMemberships)
+ .values({
+ companyId: source.company.id,
+ principalType: "user",
+ principalId: `admin-${randomUUID()}`,
+ status: "active",
+ membershipRole: "admin",
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+
+ const access = accessService(db);
+ await access.copyActiveUserMemberships(source.company.id, target.company.id);
+
+ const copiedOwnerGrants = await access.listPrincipalGrants(
+ target.company.id,
+ "user",
+ source.owner.principalId,
+ );
+ const copiedAdminGrants = await access.listPrincipalGrants(
+ target.company.id,
+ "user",
+ admin.principalId,
+ );
+ expect(copiedOwnerGrants.map((grant) => grant.permissionKey)).toEqual(
+ grantsForHumanRole("owner").map((grant) => grant.permissionKey).sort(),
+ );
+ expect(copiedAdminGrants.map((grant) => grant.permissionKey)).toEqual(
+ grantsForHumanRole("admin").map((grant) => grant.permissionKey).sort(),
+ );
+ });
+
+ it("preserves explicit scoped environment grants when backfilling owner and admin defaults", async () => {
+ const { company, owner } = await createCompanyWithOwner(db);
+ const scopedGrant = { environmentId: "env-1" };
+ await db.insert(principalPermissionGrants).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: owner.principalId,
+ permissionKey: "environments:manage",
+ scope: scopedGrant,
+ grantedByUserId: "custom-grant-author",
+ });
+
+ await db.execute(sql.raw(`
+ INSERT INTO "principal_permission_grants" (
+ "company_id",
+ "principal_type",
+ "principal_id",
+ "permission_key",
+ "scope",
+ "granted_by_user_id",
+ "created_at",
+ "updated_at"
+ )
+ SELECT
+ "company_id",
+ 'user',
+ "principal_id",
+ 'environments:manage',
+ NULL,
+ NULL,
+ now(),
+ now()
+ FROM "company_memberships"
+ WHERE "principal_type" = 'user'
+ AND "status" = 'active'
+ AND "membership_role" IN ('owner', 'admin')
+ ON CONFLICT (
+ "company_id",
+ "principal_type",
+ "principal_id",
+ "permission_key"
+ ) DO NOTHING
+ `));
+
+ const grants = await db
+ .select()
+ .from(principalPermissionGrants)
+ .where(
+ and(
+ eq(principalPermissionGrants.companyId, company.id),
+ eq(principalPermissionGrants.principalId, owner.principalId),
+ eq(principalPermissionGrants.permissionKey, "environments:manage"),
+ ),
+ );
+ expect(grants).toHaveLength(1);
+ expect(grants[0]?.scope).toEqual(scopedGrant);
+ expect(grants[0]?.grantedByUserId).toBe("custom-grant-author");
+ });
});
diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts
index b33fd21d017..198efd9cc2b 100644
--- a/server/src/__tests__/agent-adapter-validation-routes.test.ts
+++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts
@@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
@@ -192,6 +193,11 @@ describe("agent routes adapter validation", () => {
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
+ mockAccessService.decide.mockResolvedValue({
+ allowed: true,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant",
+ });
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
index ac5c9313aee..1df94d90352 100644
--- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
+++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
@@ -60,6 +60,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
@@ -293,6 +294,17 @@ function resetMockDefaults() {
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
}));
mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser);
+ mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
+ const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
+ ? true
+ : currentAccessCanUser;
+ return {
+ allowed,
+ action: input.action,
+ reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
+ explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
+ };
+ });
mockAccessService.hasPermission.mockImplementation(async () => false);
mockAccessService.getMembership.mockImplementation(async () => null);
mockAccessService.listPrincipalGrants.mockImplementation(async () => []);
diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts
index c29fca24374..f35ef610605 100644
--- a/server/src/__tests__/agent-instructions-routes.test.ts
+++ b/server/src/__tests__/agent-instructions-routes.test.ts
@@ -21,6 +21,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -175,6 +176,11 @@ describe("agent instructions bundle routes", () => {
vi.clearAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
+ mockAccessService.decide.mockResolvedValue({
+ allowed: true,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant",
+ });
mockAgentService.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({
...makeAgent(),
diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts
index 0eeebe524f1..d0549059a30 100644
--- a/server/src/__tests__/agent-live-run-routes.test.ts
+++ b/server/src/__tests__/agent-live-run-routes.test.ts
@@ -51,7 +51,16 @@ function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
- accessService: () => ({}),
+ accessService: () => ({
+ canUser: vi.fn(async () => true),
+ decide: vi.fn(async (input: { action?: string }) => ({
+ allowed: true,
+ action: input.action,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant.",
+ })),
+ hasPermission: vi.fn(async () => true),
+ }),
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts
index 218d653f8bc..28a49a69e21 100644
--- a/server/src/__tests__/agent-permissions-routes.test.ts
+++ b/server/src/__tests__/agent-permissions-routes.test.ts
@@ -51,6 +51,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
@@ -302,6 +303,7 @@ describe.sequential("agent permission routes", () => {
mockAgentService.getChainOfCommand.mockReset();
mockAgentService.resolveByReference.mockReset();
mockAccessService.canUser.mockReset();
+ mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset();
mockAccessService.getMembership.mockReset();
mockAccessService.ensureMembership.mockReset();
@@ -342,6 +344,14 @@ describe.sequential("agent permission routes", () => {
mockAgentService.update.mockResolvedValue(baseAgent);
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
mockAccessService.canUser.mockResolvedValue(true);
+ mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
+ const allowed = Boolean(await mockAccessService.canUser());
+ return {
+ allowed,
+ reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
+ explanation: allowed ? "Allowed by test grant" : `Missing test grant for ${input.action ?? "action"}`,
+ };
+ });
mockAccessService.hasPermission.mockResolvedValue(false);
mockAccessService.getMembership.mockResolvedValue({
id: "membership-1",
@@ -1342,6 +1352,24 @@ describe.sequential("agent permission routes", () => {
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
}, 15_000);
+ it("reports simple-mode task assignment as enabled for active company agent members", async () => {
+ mockAccessService.listPrincipalGrants.mockResolvedValue([]);
+
+ const app = await createApp({
+ type: "board",
+ userId: "board-user",
+ source: "local_implicit",
+ isInstanceAdmin: true,
+ companyIds: [companyId],
+ });
+
+ const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
+
+ expect(res.status).toBe(200);
+ expect(res.body.access.canAssignTasks).toBe(true);
+ expect(res.body.access.taskAssignSource).toBe("simple_default");
+ }, 15_000);
+
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
mockAgentService.updatePermissions.mockResolvedValue({
...baseAgent,
diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts
index 84537a9b107..1001f69409f 100644
--- a/server/src/__tests__/agent-skills-routes.test.ts
+++ b/server/src/__tests__/agent-skills-routes.test.ts
@@ -11,6 +11,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
@@ -315,6 +316,11 @@ describe.sequential("agent skill routes", () => {
);
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
+ mockAccessService.decide.mockResolvedValue({
+ allowed: true,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant",
+ });
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.getMembership.mockResolvedValue(null);
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts
index 3063fb70fa2..40730c051b1 100644
--- a/server/src/__tests__/agent-test-environment-routes.test.ts
+++ b/server/src/__tests__/agent-test-environment-routes.test.ts
@@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(async () => null),
listPrincipalGrants: vi.fn(async () => []),
@@ -120,6 +121,11 @@ describe("agent test-environment route", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
+ mockAccessService.decide.mockResolvedValue({
+ allowed: true,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant",
+ });
mockEnvironmentService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
diff --git a/server/src/__tests__/app-hmr-port.test.ts b/server/src/__tests__/app-hmr-port.test.ts
index 2f25d3ab8f3..f6e6ac1cf6d 100644
--- a/server/src/__tests__/app-hmr-port.test.ts
+++ b/server/src/__tests__/app-hmr-port.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { resolveViteHmrPort } from "../app.ts";
+import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts";
describe("resolveViteHmrPort", () => {
it("uses serverPort + 10000 when the result stays in range", () => {
@@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => {
expect(resolveViteHmrPort(9_000)).toBe(19_000);
});
});
+
+describe("resolveViteHmrHost", () => {
+ it("omits wildcard bind hosts so Vite uses the browser hostname", () => {
+ expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined();
+ expect(resolveViteHmrHost("::")).toBeUndefined();
+ });
+
+ it("keeps concrete bind hosts", () => {
+ expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1");
+ expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev");
+ });
+});
diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts
new file mode 100644
index 00000000000..933c460e23f
--- /dev/null
+++ b/server/src/__tests__/authorization-service.test.ts
@@ -0,0 +1,547 @@
+import { randomUUID } from "node:crypto";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import {
+ agents,
+ companies,
+ companyMemberships,
+ createDb,
+ instanceUserRoles,
+ principalPermissionGrants,
+ projects,
+} from "@paperclipai/db";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+import { authorizationService } from "../services/authorization.js";
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+async function createCompany(db: ReturnType, label: string) {
+ return db
+ .insert(companies)
+ .values({
+ name: `Authorization ${label} ${randomUUID()}`,
+ issuePrefix: `AZ${randomUUID().slice(0, 6).toUpperCase()}`,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+}
+
+async function createAgent(
+ db: ReturnType,
+ companyId: string,
+ input: { role?: string; reportsTo?: string | null; permissions?: Record } = {},
+) {
+ return db
+ .insert(agents)
+ .values({
+ companyId,
+ name: `Agent ${randomUUID()}`,
+ role: input.role ?? "engineer",
+ reportsTo: input.reportsTo ?? null,
+ permissions: input.permissions ?? {},
+ adapterType: "process",
+ adapterConfig: {},
+ runtimeConfig: {},
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+}
+
+async function createProject(db: ReturnType, companyId: string, label: string) {
+ return db
+ .insert(projects)
+ .values({
+ companyId,
+ name: `Project ${label} ${randomUUID()}`,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+}
+
+async function grantAgentPermission(
+ db: ReturnType,
+ companyId: string,
+ agentId: string,
+ permissionKey: "tasks:assign" | "tasks:assign_scope",
+ scope: Record | null = null,
+) {
+ await db.insert(companyMemberships).values({
+ companyId,
+ principalType: "agent",
+ principalId: agentId,
+ status: "active",
+ membershipRole: "member",
+ });
+ await db.insert(principalPermissionGrants).values({
+ companyId,
+ principalType: "agent",
+ principalId: agentId,
+ permissionKey,
+ scope,
+ grantedByUserId: null,
+ });
+}
+
+describeEmbeddedPostgres("authorization service", () => {
+ let db!: ReturnType;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-");
+ db = createDb(tempDb.connectionString);
+ }, 20_000);
+
+ afterEach(async () => {
+ await db.delete(principalPermissionGrants);
+ await db.delete(companyMemberships);
+ await db.delete(instanceUserRoles);
+ await db.delete(agents);
+ await db.delete(projects);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ it("allows active user role grants and explains the grant source", async () => {
+ const company = await createCompany(db, "UserGrant");
+ const userId = `user-${randomUUID()}`;
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: userId,
+ status: "active",
+ membershipRole: "operator",
+ });
+ await db.insert(principalPermissionGrants).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: userId,
+ permissionKey: "tasks:assign",
+ grantedByUserId: "owner",
+ });
+
+ const decision = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "user",
+ principalId: userId,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign",
+ });
+
+ expect(decision).toMatchObject({
+ allowed: true,
+ reason: "allow_explicit_grant",
+ grant: {
+ principalType: "user",
+ principalId: userId,
+ permissionKey: "tasks:assign",
+ },
+ });
+ expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign");
+ });
+
+ it("allows agent grants for agent configuration decisions", async () => {
+ const company = await createCompany(db, "AgentGrant");
+ const actorAgent = await createAgent(db, company.id);
+ const targetAgent = await createAgent(db, company.id);
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ status: "active",
+ membershipRole: "member",
+ });
+ await db.insert(principalPermissionGrants).values({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ permissionKey: "agents:create",
+ grantedByUserId: null,
+ });
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
+ action: "agent_config:read",
+ resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
+ });
+
+ expect(decision.allowed).toBe(true);
+ expect(decision.grant?.permissionKey).toBe("agents:create");
+ });
+
+ it("denies cross-company agent decisions before grant evaluation", async () => {
+ const sourceCompany = await createCompany(db, "Source");
+ const targetCompany = await createCompany(db, "Target");
+ const actorAgent = await createAgent(db, sourceCompany.id);
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_jwt" },
+ action: "tasks:assign",
+ resource: { type: "company", companyId: targetCompany.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: false,
+ reason: "deny_company_boundary",
+ });
+ expect(decision.explanation).toContain("Agent key cannot access another company");
+ });
+
+ it("allows simple-mode task assignment between same-company agents without explicit grants", async () => {
+ const company = await createCompany(db, "AssignmentDefault");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const targetAgent = await createAgent(db, company.id, { role: "engineer" });
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ status: "active",
+ membershipRole: "member",
+ });
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: true,
+ reason: "allow_simple_company_member",
+ });
+ expect(decision.explanation).toContain("simple mode");
+ });
+
+ it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => {
+ const company = await createCompany(db, "ProtectedAssignment");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const targetAgent = await createAgent(db, company.id, {
+ role: "engineer",
+ permissions: {
+ authorizationPolicy: {
+ assignmentPolicy: {
+ mode: "protected",
+ protectedAgentRequiresApproval: true,
+ },
+ protectedAgent: {
+ requiresApproval: true,
+ approvalReason: "Production deployment authority",
+ },
+ managedBy: "permissions-extension",
+ },
+ },
+ });
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: false,
+ reason: "deny_policy_restricted",
+ });
+ expect(decision.explanation).toContain("requires approval");
+ });
+
+ it("requires an explicit grant before assigning to a private target agent", async () => {
+ const company = await createCompany(db, "PrivateAssignment");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const targetAgent = await createAgent(db, company.id, {
+ role: "engineer",
+ permissions: {
+ authorizationPolicy: {
+ agentVisibility: {
+ mode: "private",
+ hiddenFromDefaultDirectory: true,
+ },
+ assignmentPolicy: {
+ mode: "company_default",
+ protectedAgentRequiresApproval: false,
+ },
+ protectedAgent: {
+ requiresApproval: false,
+ },
+ managedBy: "permissions-extension",
+ },
+ },
+ });
+
+ const denied = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
+ assigneeAgentId: targetAgent.id,
+ });
+
+ const allowed = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(denied).toMatchObject({
+ allowed: false,
+ reason: "deny_policy_restricted",
+ });
+ expect(denied.explanation).toContain("private");
+ expect(allowed).toMatchObject({
+ allowed: true,
+ reason: "allow_explicit_grant",
+ grant: { permissionKey: "tasks:assign_scope" },
+ });
+ });
+
+ it("allows simple-mode task assignment for active same-company board operators without explicit grants", async () => {
+ const company = await createCompany(db, "BoardAssignmentDefault");
+ const userId = `user-${randomUUID()}`;
+ const targetAgent = await createAgent(db, company.id, { role: "engineer" });
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: userId,
+ status: "active",
+ membershipRole: "operator",
+ });
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "board", userId, source: "session" },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: true,
+ reason: "allow_simple_company_member",
+ });
+ });
+
+ it("denies legacy board assignment context for viewers", async () => {
+ const company = await createCompany(db, "BoardViewerAssignment");
+ const userId = `user-${randomUUID()}`;
+ const targetAgent = await createAgent(db, company.id, { role: "engineer" });
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: userId,
+ status: "active",
+ membershipRole: "viewer",
+ });
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "board", userId, companyIds: [company.id], source: "session" },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: false,
+ reason: "deny_missing_grant",
+ });
+ });
+
+ it("denies simple-mode assignment to a target agent from another company", async () => {
+ const sourceCompany = await createCompany(db, "AssignmentSource");
+ const targetCompany = await createCompany(db, "AssignmentTarget");
+ const actorAgent = await createAgent(db, sourceCompany.id, { role: "engineer" });
+ const targetAgent = await createAgent(db, targetCompany.id, { role: "engineer" });
+ await db.insert(companyMemberships).values({
+ companyId: sourceCompany.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ status: "active",
+ membershipRole: "member",
+ });
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_key" },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: sourceCompany.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: false,
+ reason: "deny_company_boundary",
+ });
+ });
+
+ it("preserves legacy CEO agent creator authority", async () => {
+ const company = await createCompany(db, "Legacy");
+ const actorAgent = await createAgent(db, company.id, { role: "ceo" });
+
+ const decision = await authorizationService(db).decide({
+ actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" },
+ action: "agents:create",
+ resource: { type: "company", companyId: company.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: true,
+ reason: "allow_legacy_agent_creator",
+ });
+ });
+
+ it("allows scoped assignment inside a granted project and denies other projects", async () => {
+ const company = await createCompany(db, "ProjectScope");
+ const project = await createProject(db, company.id, "Allowed");
+ const otherProject = await createProject(db, company.id, "Denied");
+ const actorAgent = await createAgent(db, company.id);
+ const targetAgent = await createAgent(db, company.id);
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
+ projectIds: [project.id],
+ });
+
+ const allowed = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign_scope",
+ scope: { projectId: project.id, assigneeAgentId: targetAgent.id },
+ });
+ const denied = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign_scope",
+ scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id },
+ });
+
+ expect(allowed).toMatchObject({
+ allowed: true,
+ grant: { permissionKey: "tasks:assign_scope" },
+ });
+ expect(denied).toMatchObject({
+ allowed: false,
+ reason: "deny_scope",
+ });
+ expect(denied.explanation).toContain("does not cover the requested scope");
+ });
+
+ it("treats unknown grant scope metadata as unconstrained", async () => {
+ const company = await createCompany(db, "UnknownScopeMetadata");
+ const actorAgent = await createAgent(db, company.id);
+ const targetAgent = await createAgent(db, company.id);
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
+ note: "CEO-approved",
+ });
+
+ const decision = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign_scope",
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: true,
+ grant: { permissionKey: "tasks:assign_scope" },
+ });
+ });
+
+ it("allows scoped assignment to agents inside a managed subtree only", async () => {
+ const company = await createCompany(db, "SubtreeScope");
+ const actorAgent = await createAgent(db, company.id);
+ const managerAgent = await createAgent(db, company.id);
+ const childAgent = await createAgent(db, company.id, { reportsTo: managerAgent.id });
+ const grandchildAgent = await createAgent(db, company.id, { reportsTo: childAgent.id });
+ const outsideAgent = await createAgent(db, company.id);
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
+ managedSubtreeAgentIds: [managerAgent.id],
+ });
+
+ const allowed = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign_scope",
+ scope: { assigneeAgentId: grandchildAgent.id },
+ });
+ const denied = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign_scope",
+ scope: { assigneeAgentId: outsideAgent.id },
+ });
+
+ expect(allowed.allowed).toBe(true);
+ expect(allowed.grant?.permissionKey).toBe("tasks:assign_scope");
+ expect(denied).toMatchObject({
+ allowed: false,
+ reason: "deny_scope",
+ });
+ });
+
+ it("allows scoped assignment to an explicit target-agent allowlist only", async () => {
+ const company = await createCompany(db, "AllowlistScope");
+ const actorAgent = await createAgent(db, company.id);
+ const allowedTarget = await createAgent(db, company.id);
+ const deniedTarget = await createAgent(db, company.id);
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
+ assigneeAgentIds: [allowedTarget.id],
+ });
+
+ const allowed = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign_scope",
+ scope: { assigneeAgentId: allowedTarget.id },
+ });
+ const denied = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign_scope",
+ scope: { assigneeAgentId: deniedTarget.id },
+ });
+
+ expect(allowed.allowed).toBe(true);
+ expect(denied.allowed).toBe(false);
+ });
+
+ it("preserves unscoped tasks:assign compatibility for assignment decisions", async () => {
+ const company = await createCompany(db, "BroadAssign");
+ const actorAgent = await createAgent(db, company.id);
+ const targetAgent = await createAgent(db, company.id);
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign");
+
+ const decision = await authorizationService(db).decidePrincipalGrant({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ action: "tasks:assign",
+ permissionKey: "tasks:assign",
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: true,
+ grant: { permissionKey: "tasks:assign" },
+ });
+ });
+});
diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts
index dd672d67e44..2e2821d7ec9 100644
--- a/server/src/__tests__/better-auth.test.ts
+++ b/server/src/__tests__/better-auth.test.ts
@@ -5,13 +5,17 @@ import {
buildBetterAuthAdvancedOptions,
deriveAuthCookiePrefix,
deriveAuthTrustedOrigins,
+ shouldDisableSecureAuthCookies,
} from "../auth/better-auth.js";
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
+const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL;
afterEach(() => {
if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID;
+ if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.PAPERCLIP_PUBLIC_URL;
+ else process.env.PAPERCLIP_PUBLIC_URL = ORIGINAL_PUBLIC_URL;
});
describe("Better Auth cookie scoping", () => {
@@ -28,8 +32,8 @@ describe("Better Auth cookie scoping", () => {
expect(advanced).toEqual({
cookiePrefix: "paperclip-sat-worktree",
});
- expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe(
- "paperclip-sat-worktree.session_token",
+ expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch(
+ /paperclip-sat-worktree\.session_token$/,
);
});
@@ -42,6 +46,41 @@ describe("Better Auth cookie scoping", () => {
});
});
+ it("disables secure cookies when no canonical public auth URL is configured", () => {
+ delete process.env.PAPERCLIP_PUBLIC_URL;
+
+ expect(shouldDisableSecureAuthCookies({
+ deploymentMode: "authenticated",
+ authBaseUrlMode: "auto",
+ authPublicBaseUrl: undefined,
+ } as Parameters[0])).toBe(true);
+ });
+
+ it("derives secure cookie behavior from the configured public auth URL", () => {
+ delete process.env.PAPERCLIP_PUBLIC_URL;
+
+ expect(shouldDisableSecureAuthCookies({
+ deploymentMode: "authenticated",
+ authBaseUrlMode: "explicit",
+ authPublicBaseUrl: "http://paperclip-dev:46259",
+ } as Parameters[0])).toBe(true);
+ expect(shouldDisableSecureAuthCookies({
+ deploymentMode: "authenticated",
+ authBaseUrlMode: "explicit",
+ authPublicBaseUrl: "https://paperclip.example.test",
+ } as Parameters[0])).toBe(false);
+ });
+
+ it("lets PAPERCLIP_PUBLIC_URL override the auth base URL for cookie security", () => {
+ process.env.PAPERCLIP_PUBLIC_URL = "http://paperclip-dev:46259";
+
+ expect(shouldDisableSecureAuthCookies({
+ deploymentMode: "authenticated",
+ authBaseUrlMode: "explicit",
+ authPublicBaseUrl: "https://paperclip.example.test",
+ } as Parameters[0])).toBe(true);
+ });
+
it("adds hostname port variants for authenticated mode on non-default ports", () => {
const trustedOrigins = deriveAuthTrustedOrigins({
deploymentMode: "authenticated",
diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts
index df1d8e7fda0..c048052a106 100644
--- a/server/src/__tests__/company-portability.test.ts
+++ b/server/src/__tests__/company-portability.test.ts
@@ -20,6 +20,7 @@ const agentSvc = {
const accessSvc = {
ensureMembership: vi.fn(),
+ ensureRoleDefaultGrants: vi.fn(),
listActiveUserMemberships: vi.fn(),
copyActiveUserMemberships: vi.fn(),
setPrincipalPermission: vi.fn(),
diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
new file mode 100644
index 00000000000..6e54689fc3e
--- /dev/null
+++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
@@ -0,0 +1,100 @@
+const readline = require("node:readline");
+
+let nextRequestId = 1;
+const pendingNested = new Map();
+
+function send(message) {
+ process.stdout.write(`${JSON.stringify(message)}\n`);
+}
+
+function sendNestedHostRequest(originalRequest, invocationId) {
+ const nestedId = `nested-${nextRequestId++}`;
+ const params = originalRequest.params?.params ?? {};
+ const mode = params.mode;
+ const requestedCompanyId = params.requestedCompanyId;
+ const nestedRequest = {
+ jsonrpc: "2.0",
+ id: nestedId,
+ method: "companies.get",
+ params: {
+ companyId: requestedCompanyId,
+ },
+ };
+
+ if (mode === "echo") {
+ nestedRequest.paperclipInvocationId = invocationId;
+ } else if (mode === "unknown") {
+ nestedRequest.paperclipInvocationId = "unknown-invocation";
+ }
+
+ pendingNested.set(nestedId, originalRequest.id);
+ send(nestedRequest);
+}
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ crlfDelay: Infinity,
+});
+
+rl.on("line", (line) => {
+ if (!line.trim()) return;
+ const message = JSON.parse(line);
+
+ if (message.id && pendingNested.has(message.id)) {
+ const originalId = pendingNested.get(message.id);
+ pendingNested.delete(message.id);
+ if (message.error) {
+ send({
+ jsonrpc: "2.0",
+ id: originalId,
+ error: message.error,
+ });
+ return;
+ }
+
+ send({
+ jsonrpc: "2.0",
+ id: originalId,
+ result: message.result,
+ });
+ return;
+ }
+
+ const method = message && typeof message.method === "string" ? message.method : null;
+
+ if (method === "initialize") {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {
+ ok: true,
+ supportedMethods: ["getData"],
+ },
+ });
+ return;
+ }
+
+ if (method === "getData") {
+ sendNestedHostRequest(message, message.paperclipInvocation?.id);
+ return;
+ }
+
+ if (method === "shutdown") {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {},
+ });
+ setImmediate(() => process.exit(0));
+ return;
+ }
+
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ error: {
+ code: -32601,
+ message: `Unhandled method: ${method}`,
+ },
+ });
+});
diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts
index fae007e059d..6834f763e2c 100644
--- a/server/src/__tests__/invite-join-grants.test.ts
+++ b/server/src/__tests__/invite-join-grants.test.ts
@@ -68,6 +68,7 @@ describe("human invite roles", () => {
it("maps owner to the full management grant set", () => {
expect(grantsForHumanRole("owner")).toEqual([
{ permissionKey: "agents:create", scope: null },
+ { permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
@@ -75,6 +76,16 @@ describe("human invite roles", () => {
]);
});
+ it("maps admin to management grants including environment management", () => {
+ expect(grantsForHumanRole("admin")).toEqual([
+ { permissionKey: "agents:create", scope: null },
+ { permissionKey: "environments:manage", scope: null },
+ { permissionKey: "users:invite", scope: null },
+ { permissionKey: "tasks:assign", scope: null },
+ { permissionKey: "joins:approve", scope: null },
+ ]);
+ });
+
it("defaults legacy or missing roles to operator", () => {
expect(normalizeHumanRole("member")).toBe("operator");
expect(resolveHumanInviteRole(null)).toBe("operator");
diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
index 6413ba21625..7503bdf6379 100644
--- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
+++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
@@ -30,6 +30,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -275,6 +276,13 @@ describe("agent issue mutation checkout ownership", () => {
registerRouteMocks();
vi.clearAllMocks();
mockAccessService.canUser.mockReset();
+ mockAccessService.decide.mockReset();
+ mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
+ allowed: input.action === "tasks:assign",
+ action: input.action,
+ reason: input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant",
+ explanation: input.action === "tasks:assign" ? "Allowed by test assignment default." : "Missing permission.",
+ }));
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
@@ -682,12 +690,12 @@ describe("agent issue mutation checkout ownership", () => {
});
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
- mockAccessService.hasPermission.mockImplementation(async (
- _companyId: string,
- _principalType: string,
- principalId: string,
- permissionKey: string,
- ) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts");
+ mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
+ allowed: input.action === "tasks:manage_active_checkouts",
+ action: input.action,
+ reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant",
+ explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.",
+ }));
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
@@ -828,4 +836,37 @@ describe("agent issue mutation checkout ownership", () => {
}),
);
});
+
+ it("uses the authorization decision path for assignment changes", async () => {
+ const decide = vi.fn(async () => ({
+ allowed: false,
+ action: "tasks:assign",
+ reason: "deny_policy_restricted",
+ explanation: "Target agent requires approval before task assignment.",
+ }));
+ (mockAccessService as any).decide = decide;
+ mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
+ mockAgentService.resolveByReference.mockResolvedValue({
+ ambiguous: false,
+ agent: makeAgent(peerAgentId),
+ });
+
+ const app = await createApp(ownerActor());
+ const res = await request(app)
+ .patch(`/api/issues/${issueId}`)
+ .send({ assigneeAgentId: peerAgentId });
+
+ expect(res.status).toBe(403);
+ expect(res.body.error).toContain("requires approval");
+ expect(decide).toHaveBeenCalledWith(expect.objectContaining({
+ action: "tasks:assign",
+ resource: expect.objectContaining({
+ type: "issue",
+ companyId,
+ issueId,
+ assigneeAgentId: peerAgentId,
+ }),
+ }));
+ expect(mockIssueService.update).not.toHaveBeenCalled();
+ });
});
diff --git a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts
index 2dc70626322..bd196a60a03 100644
--- a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts
+++ b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts
@@ -22,6 +22,12 @@ const mockIssueService = vi.hoisted(() => ({
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
+ decide: vi.fn(async (input: { action?: string }) => ({
+ allowed: true,
+ action: input.action,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant.",
+ })),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts
index ecb3db9bf8f..ad4ee770498 100644
--- a/server/src/__tests__/issue-comment-reopen-routes.test.ts
+++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts
@@ -16,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
+ decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -229,6 +230,7 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockAccessService.canUser.mockReset();
+ mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset();
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.reportRunActivity.mockReset();
@@ -307,6 +309,15 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockAccessService.canUser.mockResolvedValue(false);
+ mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
+ const allowed = input.action !== "tasks:manage_active_checkouts";
+ return {
+ allowed,
+ action: input.action,
+ reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
+ explanation: allowed ? "Allowed by test grant." : "Missing active checkout override.",
+ };
+ });
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.list.mockResolvedValue([
diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts
index 1b0db770a19..0b7392d2e2e 100644
--- a/server/src/__tests__/issue-execution-policy-routes.test.ts
+++ b/server/src/__tests__/issue-execution-policy-routes.test.ts
@@ -26,6 +26,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false),
+ decide: vi.fn(),
hasPermission: vi.fn(async () => false),
}));
@@ -160,6 +161,17 @@ describe("issue execution policy routes", () => {
parentBlockerAdded: false,
});
mockAccessService.canUser.mockResolvedValue(false);
+ mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
+ const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
+ ? true
+ : Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission());
+ return {
+ allowed,
+ action: input.action,
+ reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
+ explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
+ };
+ });
mockAccessService.hasPermission.mockResolvedValue(false);
});
diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts
index 818c5d7d9ac..785dbf182cf 100644
--- a/server/src/__tests__/issue-thread-interaction-routes.test.ts
+++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts
@@ -43,6 +43,12 @@ function registerModuleMocks() {
}),
accessService: () => ({
canUser: vi.fn(async () => true),
+ decide: vi.fn(async (input: { action?: string }) => ({
+ allowed: true,
+ action: input.action,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant.",
+ })),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts
index 167037fbafd..42539b218bc 100644
--- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts
+++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts
@@ -33,6 +33,12 @@ vi.mock("../services/index.js", () => ({
}),
accessService: () => ({
canUser: vi.fn(async () => true),
+ decide: vi.fn(async (input: { action?: string }) => ({
+ allowed: true,
+ action: input.action,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant.",
+ })),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -95,6 +101,12 @@ function registerModuleMocks() {
}),
accessService: () => ({
canUser: vi.fn(async () => true),
+ decide: vi.fn(async (input: { action?: string }) => ({
+ allowed: true,
+ action: input.action,
+ reason: "allow_explicit_grant",
+ explanation: "Allowed by test grant.",
+ })),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
diff --git a/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts
new file mode 100644
index 00000000000..2d53659115a
--- /dev/null
+++ b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts
@@ -0,0 +1,348 @@
+import { randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ activityLog,
+ agents,
+ assets,
+ companies,
+ companyMemberships,
+ createDb,
+ documents,
+ heartbeatRuns,
+ issueAttachments,
+ issueComments,
+ issueDocuments,
+ issues,
+ issueWorkProducts,
+ principalPermissionGrants,
+} from "@paperclipai/db";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+
+vi.hoisted(() => {
+ process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
+ process.env.PAPERCLIP_INSTANCE_ID = "vitest";
+ process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
+ process.env.PAPERCLIP_IN_WORKTREE = "false";
+});
+
+vi.mock("../services/issue-assignment-wakeup.js", () => ({
+ queueIssueAssignmentWakeup: vi.fn(),
+}));
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+type Db = ReturnType;
+
+function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
+ return {
+ type: "agent",
+ agentId,
+ companyId,
+ runId: null,
+ source: "agent_jwt",
+ };
+}
+
+async function createApp(db: Db, actor: Express.Request["actor"]) {
+ process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
+ process.env.PAPERCLIP_IN_WORKTREE = "false";
+ const [{ activityRoutes }, { issueRoutes }] = await Promise.all([
+ import("../routes/activity.js"),
+ import("../routes/issues.js"),
+ ]);
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.actor = actor;
+ next();
+ });
+ app.use("/api", issueRoutes(db, {} as any));
+ app.use("/api", activityRoutes(db));
+ app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
+ });
+ return app;
+}
+
+async function seedCompany(db: Db, label: string) {
+ return db
+ .insert(companies)
+ .values({
+ name: `Permissions Boundary ${label}`,
+ issuePrefix: `PB${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+}
+
+async function seedAgent(
+ db: Db,
+ companyId: string,
+ input: { role?: string; permissions?: Record; status?: "active" | "idle" } = {},
+) {
+ return db
+ .insert(agents)
+ .values({
+ companyId,
+ name: `Agent ${randomUUID()}`,
+ role: input.role ?? "engineer",
+ status: input.status ?? "active",
+ adapterType: "process",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: input.permissions ?? {},
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+}
+
+describeEmbeddedPostgres("permissions upgrade visibility and route boundaries", () => {
+ let db!: Db;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-permissions-boundary-routes-");
+ db = createDb(tempDb.connectionString);
+ }, 20_000);
+
+ afterEach(async () => {
+ await db.delete(issueAttachments);
+ await db.delete(assets);
+ await db.delete(issueDocuments);
+ await db.delete(documents);
+ await db.delete(issueWorkProducts);
+ await db.delete(issueComments);
+ await db.delete(activityLog);
+ await db.delete(principalPermissionGrants);
+ await db.delete(companyMemberships);
+ await db.delete(heartbeatRuns);
+ await db.delete(issues);
+ await db.delete(agents);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ it("keeps V1 private agent visibility from becoming issue, comment, document, attachment, activity, or work product privacy", async () => {
+ const company = await seedCompany(db, "Visibility");
+ const readerAgent = await seedAgent(db, company.id);
+ const privateTargetAgent = await seedAgent(db, company.id, {
+ permissions: {
+ authorizationPolicy: {
+ agentVisibility: {
+ mode: "private",
+ hiddenFromDefaultDirectory: true,
+ },
+ assignmentPolicy: { mode: "protected" },
+ protectedAgent: { requiresApproval: false },
+ managedBy: "permissions-extension",
+ },
+ },
+ });
+ const issue = await db
+ .insert(issues)
+ .values({
+ companyId: company.id,
+ identifier: `${company.issuePrefix}-1`,
+ title: "Visible work for a private target agent",
+ status: "todo",
+ priority: "medium",
+ assigneeAgentId: privateTargetAgent.id,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ const comment = await db
+ .insert(issueComments)
+ .values({
+ companyId: company.id,
+ issueId: issue.id,
+ authorAgentId: privateTargetAgent.id,
+ body: "Private target agent status is still company-visible.",
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ const doc = await db
+ .insert(documents)
+ .values({
+ companyId: company.id,
+ title: "Plan",
+ latestBody: "Shared plan body",
+ createdByAgentId: privateTargetAgent.id,
+ updatedByAgentId: privateTargetAgent.id,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ await db.insert(issueDocuments).values({
+ companyId: company.id,
+ issueId: issue.id,
+ documentId: doc.id,
+ key: "plan",
+ });
+ const asset = await db
+ .insert(assets)
+ .values({
+ companyId: company.id,
+ provider: "local_disk",
+ objectKey: `attachments/${randomUUID()}.txt`,
+ contentType: "text/plain",
+ byteSize: 12,
+ sha256: "abc123",
+ originalFilename: "note.txt",
+ createdByAgentId: privateTargetAgent.id,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ await db.insert(issueAttachments).values({
+ companyId: company.id,
+ issueId: issue.id,
+ issueCommentId: comment.id,
+ assetId: asset.id,
+ });
+ await db.insert(issueWorkProducts).values({
+ companyId: company.id,
+ issueId: issue.id,
+ type: "url",
+ provider: "test",
+ title: "Preview",
+ url: "https://example.test/preview",
+ status: "ready",
+ });
+ await db.insert(activityLog).values({
+ companyId: company.id,
+ actorType: "agent",
+ actorId: privateTargetAgent.id,
+ agentId: privateTargetAgent.id,
+ action: "issue.updated",
+ entityType: "issue",
+ entityId: issue.id,
+ details: { source: "test" },
+ });
+
+ const app = await createApp(db, agentActor(company.id, readerAgent.id));
+
+ const [issueList, comments, docs, docDetail, attachments, activity, workProducts] = await Promise.all([
+ request(app).get(`/api/companies/${company.id}/issues`),
+ request(app).get(`/api/issues/${issue.id}/comments`),
+ request(app).get(`/api/issues/${issue.id}/documents`),
+ request(app).get(`/api/issues/${issue.id}/documents/plan`),
+ request(app).get(`/api/issues/${issue.id}/attachments`),
+ request(app).get(`/api/issues/${issue.id}/activity`),
+ request(app).get(`/api/issues/${issue.id}/work-products`),
+ ]);
+
+ expect(issueList.status, JSON.stringify(issueList.body)).toBe(200);
+ expect(issueList.body.items ?? issueList.body).toEqual(
+ expect.arrayContaining([expect.objectContaining({ id: issue.id })]),
+ );
+ expect(comments.status, JSON.stringify(comments.body)).toBe(200);
+ expect(comments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: comment.id })]));
+ expect(docs.status, JSON.stringify(docs.body)).toBe(200);
+ expect(docs.body).toEqual(expect.arrayContaining([expect.objectContaining({ key: "plan" })]));
+ expect(docDetail.status, JSON.stringify(docDetail.body)).toBe(200);
+ expect(docDetail.body.body ?? docDetail.body.latestBody).toContain("Shared plan body");
+ expect(attachments.status, JSON.stringify(attachments.body)).toBe(200);
+ expect(attachments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]));
+ expect(activity.status, JSON.stringify(activity.body)).toBe(200);
+ expect(activity.body).toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.updated" })]));
+ expect(workProducts.status, JSON.stringify(workProducts.body)).toBe(200);
+ expect(workProducts.body).toEqual(expect.arrayContaining([expect.objectContaining({ title: "Preview" })]));
+ });
+
+ it("denies cross-company issue reads before private-agent grant evaluation can matter", async () => {
+ const sourceCompany = await seedCompany(db, "Source");
+ const targetCompany = await seedCompany(db, "Target");
+ const sourceAgent = await seedAgent(db, sourceCompany.id);
+ const privateTargetAgent = await seedAgent(db, targetCompany.id, {
+ permissions: {
+ authorizationPolicy: {
+ agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
+ assignmentPolicy: { mode: "company_default" },
+ protectedAgent: { requiresApproval: false },
+ },
+ },
+ });
+ const issue = await db
+ .insert(issues)
+ .values({
+ companyId: targetCompany.id,
+ title: "Other company work",
+ status: "todo",
+ priority: "medium",
+ assigneeAgentId: privateTargetAgent.id,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+
+ const res = await request(await createApp(db, agentActor(sourceCompany.id, sourceAgent.id)))
+ .get(`/api/issues/${issue.id}`);
+
+ expect(res.status).toBe(403);
+ expect(res.body.error).toContain("Agent key cannot access another company");
+ });
+
+ it("allows same-company route assignment after upgrade but keeps private target assignment grant constrained", async () => {
+ const company = await seedCompany(db, "Assignment");
+ const actorAgent = await seedAgent(db, company.id);
+ const openTargetAgent = await seedAgent(db, company.id);
+ const privateTargetAgent = await seedAgent(db, company.id, {
+ permissions: {
+ authorizationPolicy: {
+ agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
+ assignmentPolicy: { mode: "company_default" },
+ protectedAgent: { requiresApproval: false },
+ managedBy: "permissions-extension",
+ },
+ },
+ });
+ const app = await createApp(db, agentActor(company.id, actorAgent.id));
+
+ const openAssignment = await request(app)
+ .post(`/api/companies/${company.id}/issues`)
+ .send({ title: "Assignable after upgrade", assigneeAgentId: openTargetAgent.id });
+ expect(openAssignment.status, JSON.stringify(openAssignment.body)).toBe(201);
+
+ const deniedPrivateAssignment = await request(app)
+ .post(`/api/companies/${company.id}/issues`)
+ .send({ title: "Private target needs scope", assigneeAgentId: privateTargetAgent.id });
+ expect(deniedPrivateAssignment.status).toBe(403);
+ expect(deniedPrivateAssignment.body.error).toContain("private");
+
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ status: "active",
+ membershipRole: "member",
+ });
+ await db.insert(principalPermissionGrants).values({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent.id,
+ permissionKey: "tasks:assign_scope",
+ scope: { assigneeAgentIds: [privateTargetAgent.id] },
+ grantedByUserId: null,
+ });
+
+ const allowedPrivateAssignment = await request(app)
+ .post(`/api/companies/${company.id}/issues`)
+ .send({ title: "Private target has explicit scope", assigneeAgentId: privateTargetAgent.id });
+ expect(allowedPrivateAssignment.status, JSON.stringify(allowedPrivateAssignment.body)).toBe(201);
+
+ const otherPrivateTargetAgent = await seedAgent(db, company.id, {
+ permissions: privateTargetAgent.permissions as Record,
+ });
+ const deniedOutsideScope = await request(app)
+ .post(`/api/companies/${company.id}/issues`)
+ .send({ title: "Different private target stays denied", assigneeAgentId: otherPrivateTargetAgent.id });
+ expect(deniedOutsideScope.status).toBe(403);
+ expect(deniedOutsideScope.body.error).toContain("private");
+ });
+});
diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts
new file mode 100644
index 00000000000..87737816a34
--- /dev/null
+++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts
@@ -0,0 +1,322 @@
+import { randomUUID } from "node:crypto";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ activityLog,
+ agents,
+ companies,
+ companyMemberships,
+ createDb,
+ invites,
+ principalPermissionGrants,
+} from "@paperclipai/db";
+import { buildHostServices } from "../services/plugin-host-services.js";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+const pluginId = "plugin-record-id";
+
+function createEventBusStub() {
+ return {
+ forPlugin() {
+ return {
+ emit: vi.fn(),
+ subscribe: vi.fn(),
+ clear: vi.fn(),
+ };
+ },
+ } as any;
+}
+
+async function createCompany(db: ReturnType, prefix: string) {
+ return db
+ .insert(companies)
+ .values({
+ name: `${prefix} ${randomUUID()}`,
+ issuePrefix: `${prefix}${randomUUID().slice(0, 6).toUpperCase()}`,
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+}
+
+describeEmbeddedPostgres("plugin access and authorization host services", () => {
+ let db!: ReturnType;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-access-authz-");
+ db = createDb(tempDb.connectionString);
+ }, 20_000);
+
+ afterEach(async () => {
+ await db.delete(activityLog);
+ await db.delete(principalPermissionGrants);
+ await db.delete(invites);
+ await db.delete(agents);
+ await db.delete(companyMemberships);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ it("rejects grant writes for principals outside the requested company", async () => {
+ const targetCompany = await createCompany(db, "PAX");
+ const otherCompany = await createCompany(db, "PAY");
+ const otherAgent = await db
+ .insert(agents)
+ .values({
+ companyId: otherCompany.id,
+ name: "Other agent",
+ role: "engineer",
+ adapterType: "process",
+ adapterConfig: {},
+ permissions: {},
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
+
+ await expect(
+ services.authorization.setGrants({
+ companyId: targetCompany.id,
+ principalType: "agent",
+ principalId: otherAgent.id,
+ grants: [{ permissionKey: "tasks:assign" }],
+ }),
+ ).rejects.toThrow("Agent not found");
+
+ const rows = await db.select().from(principalPermissionGrants);
+ expect(rows).toEqual([]);
+ services.dispose();
+ });
+
+ it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => {
+ const company = await createCompany(db, "PAZ");
+ const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
+
+ const created = await services.access.createInvite({
+ companyId: company.id,
+ allowedJoinTypes: "human",
+ defaultsPayload: {
+ human: { role: "operator", apiKey: "secret-value" },
+ secret: "top-secret",
+ },
+ });
+
+ expect(created.token).toMatch(/^pcp_invite_/);
+ expect("tokenHash" in created).toBe(false);
+ expect(created.defaultsPayload).toMatchObject({
+ human: { role: "operator", apiKey: "***REDACTED***" },
+ secret: "***REDACTED***",
+ });
+
+ const listed = await services.access.listInvites({ companyId: company.id });
+ expect(listed.invites).toHaveLength(1);
+ expect("token" in listed.invites[0]!).toBe(false);
+ expect("tokenHash" in listed.invites[0]!).toBe(false);
+ services.dispose();
+ });
+
+ it("filters authorization audit entries by allow or deny decision details", async () => {
+ const company = await createCompany(db, "PAU");
+ const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
+ await db.insert(activityLog).values([
+ {
+ companyId: company.id,
+ actorType: "agent",
+ actorId: "agent-1",
+ action: "authorization.assignment_preview",
+ entityType: "issue",
+ entityId: "issue-1",
+ details: { decision: "allow", secret: "do-not-leak" },
+ createdAt: new Date("2026-01-02T00:00:00Z"),
+ },
+ {
+ companyId: company.id,
+ actorType: "agent",
+ actorId: "agent-1",
+ action: "authorization.assignment_preview",
+ entityType: "issue",
+ entityId: "issue-2",
+ details: { reason: "deny_scope" },
+ createdAt: new Date("2026-01-03T00:00:00Z"),
+ },
+ ]);
+
+ const [allowed, denied] = await Promise.all([
+ services.authorization.searchAudit({
+ companyId: company.id,
+ action: "authorization.assignment_preview",
+ decision: "allow",
+ limit: 1,
+ }),
+ services.authorization.searchAudit({
+ companyId: company.id,
+ action: "authorization.assignment_preview",
+ decision: "deny",
+ }),
+ ]);
+
+ expect(allowed).toHaveLength(1);
+ expect(allowed[0]!.entityId).toBe("issue-1");
+ expect(allowed[0]!.details).toMatchObject({ decision: "allow", secret: "***REDACTED***" });
+ expect(denied).toHaveLength(1);
+ expect(denied[0]!.entityId).toBe("issue-2");
+ services.dispose();
+ });
+
+ it("uses persisted agent policy for plugin assignment preview and explanation", async () => {
+ const company = await createCompany(db, "PAP");
+ const [actorAgent, targetAgent] = await db
+ .insert(agents)
+ .values([
+ {
+ companyId: company.id,
+ name: "Actor agent",
+ role: "engineer",
+ adapterType: "process",
+ adapterConfig: {},
+ permissions: {},
+ },
+ {
+ companyId: company.id,
+ name: "Protected target",
+ role: "engineer",
+ adapterType: "process",
+ adapterConfig: {},
+ permissions: {},
+ },
+ ])
+ .returning();
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "agent",
+ principalId: actorAgent!.id,
+ status: "active",
+ membershipRole: "member",
+ });
+
+ const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
+ const updatedPolicy = await services.authorization.updatePolicy({
+ companyId: company.id,
+ resourceType: "agent",
+ resourceId: targetAgent!.id,
+ policy: {
+ assignmentPolicy: {
+ mode: "protected",
+ protectedAgentRequiresApproval: true,
+ },
+ protectedAgent: {
+ requiresApproval: true,
+ approvalReason: "Needs board approval",
+ },
+ managedBy: "permissions-extension",
+ },
+ });
+ const input = {
+ companyId: company.id,
+ actor: {
+ type: "agent" as const,
+ agentId: actorAgent!.id,
+ companyId: company.id,
+ source: "agent_key" as const,
+ },
+ target: { assigneeAgentId: targetAgent!.id },
+ };
+ const [policy, preview, explanation] = await Promise.all([
+ Promise.resolve(updatedPolicy),
+ services.authorization.previewAssignment(input),
+ services.authorization.explainAssignment(input),
+ ]);
+
+ expect(policy.policy).toMatchObject({
+ protectedAgent: { requiresApproval: true },
+ });
+ expect(preview).toMatchObject({
+ allowed: false,
+ reason: "deny_policy_restricted",
+ });
+ expect(explanation).toMatchObject(preview);
+
+ const injectedBoardPreview = await services.authorization.previewAssignment({
+ companyId: company.id,
+ actor: {
+ type: "board",
+ userId: "operator",
+ companyIds: [company.id],
+ source: "local_implicit",
+ isInstanceAdmin: true,
+ } as any,
+ target: { assigneeAgentId: targetAgent!.id },
+ });
+ expect(injectedBoardPreview).toMatchObject({
+ allowed: false,
+ reason: "deny_policy_restricted",
+ });
+ services.dispose();
+ });
+
+ it("sanitizes plugin authorization policy updates and records audit activity", async () => {
+ const company = await createCompany(db, "PAS");
+ const targetAgent = await db
+ .insert(agents)
+ .values({
+ companyId: company.id,
+ name: "Policy target",
+ role: "engineer",
+ adapterType: "process",
+ adapterConfig: {},
+ permissions: {},
+ })
+ .returning()
+ .then((rows) => rows[0]!);
+ const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
+
+ const updatedPolicy = await services.authorization.updatePolicy({
+ companyId: company.id,
+ resourceType: "agent",
+ resourceId: targetAgent.id,
+ policy: {
+ assignmentPolicy: { mode: "protected" },
+ apiKey: "sk-test-secret",
+ nested: {
+ authorization: "Bearer should-not-persist",
+ safeLabel: "kept",
+ },
+ },
+ });
+
+ expect(updatedPolicy.policy).toMatchObject({
+ assignmentPolicy: { mode: "protected" },
+ apiKey: "***REDACTED***",
+ nested: {
+ authorization: "***REDACTED***",
+ safeLabel: "kept",
+ },
+ });
+
+ const rows = await db.select().from(activityLog);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ companyId: company.id,
+ actorType: "plugin",
+ actorId: pluginId,
+ action: "authorization.policy_updated_by_plugin",
+ entityType: "agent",
+ entityId: targetAgent.id,
+ });
+ expect(rows[0]!.details).toMatchObject({
+ hasPolicy: true,
+ sourcePluginId: pluginId,
+ sourcePluginKey: "permissions-extension",
+ });
+ expect(JSON.stringify(rows[0]!.details)).not.toContain("sk-test-secret");
+ expect(JSON.stringify(rows[0]!.details)).not.toContain("should-not-persist");
+ services.dispose();
+ });
+});
diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts
index 0c19848822f..52fe1c84750 100644
--- a/server/src/__tests__/plugin-routes-authz.test.ts
+++ b/server/src/__tests__/plugin-routes-authz.test.ts
@@ -613,6 +613,28 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(call).not.toHaveBeenCalled();
});
+ it("forwards authorized bridge company scope to the plugin worker", async () => {
+ readyPlugin();
+ const call = vi.fn().mockResolvedValue({ ok: true });
+ const { app } = await createApp(boardActor(), {}, {
+ bridgeDeps: {
+ workerManager: { call },
+ },
+ });
+
+ const res = await request(app)
+ .post(`/api/plugins/${pluginId}/data/health`)
+ .send({ companyId: companyA, params: { view: "compact" } });
+
+ expect(res.status).toBe(200);
+ expect(call).toHaveBeenCalledWith(pluginId, "getData", {
+ key: "health",
+ companyId: companyA,
+ params: { view: "compact" },
+ renderEnvironment: null,
+ });
+ });
+
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts
index 2510416179c..bb66dae702f 100644
--- a/server/src/__tests__/plugin-sdk-testing.test.ts
+++ b/server/src/__tests__/plugin-sdk-testing.test.ts
@@ -82,4 +82,29 @@ describe("plugin SDK test harness", () => {
"missing required capability 'skills.managed'",
);
});
+
+ it("requires access and authorization capabilities for permission SDK calls", async () => {
+ const manifest: PaperclipPluginManifestV1 = {
+ id: "paperclip.test-missing-access-authz-capability",
+ apiVersion: 1,
+ version: "0.1.0",
+ displayName: "Missing Access Capability",
+ description: "Test plugin",
+ author: "Paperclip",
+ categories: ["automation"],
+ capabilities: [],
+ entrypoints: { worker: "./dist/worker.js" },
+ };
+ const harness = createTestHarness({ manifest });
+
+ await expect(harness.ctx.access.members.list({ companyId: "company-1" })).rejects.toThrow(
+ "missing required capability 'access.members.read'",
+ );
+ await expect(harness.ctx.authorization.grants.list({ companyId: "company-1" })).rejects.toThrow(
+ "missing required capability 'authorization.grants.read'",
+ );
+ await expect(harness.ctx.authorization.audit.search({ companyId: "company-1" })).rejects.toThrow(
+ "missing required capability 'authorization.audit.read'",
+ );
+ });
});
diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts
index 4f578fda072..626ed448cba 100644
--- a/server/src/__tests__/plugin-worker-manager.test.ts
+++ b/server/src/__tests__/plugin-worker-manager.test.ts
@@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
+ createHostClientHandlers,
JsonRpcCallError,
+ PLUGIN_RPC_ERROR_CODES,
+ type HostServices,
type HostToWorkerMethods,
} from "@paperclipai/plugin-sdk";
import {
@@ -14,6 +17,10 @@ import {
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
+const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(
+ FIXTURES_DIR,
+ "plugin-worker-invocation-scope.cjs",
+);
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
@@ -178,4 +185,86 @@ describe("plugin-worker-manager stderr failure context", () => {
await handle.stop().catch(() => undefined);
}
});
+
+ it("passes echoed invocation scope to worker-to-host handlers", async () => {
+ const companiesGet = vi.fn(async () => ({ id: "company-1" }));
+ const handle = createPluginWorkerHandle("test.plugin", {
+ entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
+ manifest: TEST_MANIFEST,
+ config: {},
+ instanceInfo: {
+ instanceId: "instance-1",
+ hostVersion: "1.0.0",
+ },
+ apiVersion: 1,
+ hostHandlers: {
+ "companies.get": companiesGet,
+ },
+ });
+
+ try {
+ await handle.start();
+
+ await expect(handle.call("getData", {
+ key: "probe",
+ companyId: "company-1",
+ params: {
+ mode: "echo",
+ requestedCompanyId: "company-1",
+ },
+ } as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" });
+
+ expect(companiesGet).toHaveBeenCalledWith(
+ { companyId: "company-1" },
+ { invocationScope: { companyId: "company-1" } },
+ );
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
+ const companiesGet = vi.fn(async () => ({ id: "company-2" }));
+ const hostHandlers = createHostClientHandlers({
+ pluginId: "test.plugin",
+ capabilities: ["companies.read"],
+ services: {
+ companies: {
+ get: companiesGet,
+ },
+ } as unknown as HostServices,
+ });
+ const handle = createPluginWorkerHandle("test.plugin", {
+ entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
+ manifest: TEST_MANIFEST,
+ config: {},
+ instanceInfo: {
+ instanceId: "instance-1",
+ hostVersion: "1.0.0",
+ },
+ apiVersion: 1,
+ hostHandlers,
+ });
+
+ try {
+ await handle.start();
+
+ for (const mode of ["omit", "unknown"]) {
+ await expect(handle.call("getData", {
+ key: "probe",
+ companyId: "company-1",
+ params: {
+ mode,
+ requestedCompanyId: "company-2",
+ },
+ } as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
+ code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
+ });
+ }
+
+ expect(companiesGet).not.toHaveBeenCalled();
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
});
diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts
index c288b6e6322..f1023a91533 100644
--- a/server/src/__tests__/server-startup-feedback-export.test.ts
+++ b/server/src/__tests__/server-startup-feedback-export.test.ts
@@ -138,6 +138,10 @@ vi.mock("../realtime/live-events-ws.js", () => ({
}));
vi.mock("../services/index.js", () => ({
+ backfillPrincipalAccessCompatibility: vi.fn(async () => ({
+ agentMembershipsInserted: 0,
+ humanGrantsInserted: 0,
+ })),
feedbackService: feedbackServiceFactoryMock,
heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined),
diff --git a/server/src/app.ts b/server/src/app.ts
index 421a72a4390..a7c9a4ed630 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -96,6 +96,12 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000);
}
+export function resolveViteHmrHost(bindHost: string): string | undefined {
+ const normalized = bindHost.trim().toLowerCase();
+ if (normalized === "0.0.0.0" || normalized === "::") return undefined;
+ return bindHost;
+}
+
export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
@@ -373,6 +379,7 @@ export async function createApp(
const uiRoot = path.resolve(__dirname, "../../ui");
const publicUiRoot = path.resolve(uiRoot, "public");
const hmrPort = resolveViteHmrPort(opts.serverPort);
+ const hmrHost = resolveViteHmrHost(opts.bindHost);
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: uiRoot,
@@ -380,7 +387,7 @@ export async function createApp(
server: {
middlewareMode: true,
hmr: {
- host: opts.bindHost,
+ ...(hmrHost ? { host: hmrHost } : {}),
port: hmrPort,
clientPort: hmrPort,
},
diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts
index 3c84b9f555c..6eb2024938c 100644
--- a/server/src/auth/better-auth.ts
+++ b/server/src/auth/better-auth.ts
@@ -44,6 +44,15 @@ export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: bo
};
}
+export function shouldDisableSecureAuthCookies(config: Config): boolean {
+ const configuredPublicUrl = (
+ process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
+ (config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl?.trim() : "")
+ );
+ if (!configuredPublicUrl) return true;
+ return configuredPublicUrl.startsWith("http://");
+}
+
function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers {
const headers = new Headers();
for (const [key, raw] of Object.entries(rawHeaders)) {
@@ -99,8 +108,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
"For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.",
);
}
- const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl;
- const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
+ const disableSecureCookies = shouldDisableSecureAuthCookies(config);
const authConfig = {
baseURL: baseUrl,
@@ -120,7 +128,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},
- advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }),
+ advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }),
};
if (!baseUrl) {
diff --git a/server/src/board-claim.ts b/server/src/board-claim.ts
index 4c87b732bfc..c4784406646 100644
--- a/server/src/board-claim.ts
+++ b/server/src/board-claim.ts
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
import type { DeploymentMode } from "@paperclipai/shared";
+import { ensureHumanRoleDefaultGrants } from "./services/principal-access-compatibility.js";
const LOCAL_BOARD_USER_ID = "local-board";
const CLAIM_TTL_MS = 1000 * 60 * 60 * 24;
@@ -89,6 +90,7 @@ export async function claimBoardOwnership(
const status = getChallengeStatus(opts.token, opts.code);
if (status !== "available") return { status };
+ const claimedCompanyIds: string[] = [];
await db.transaction(async (tx) => {
const existingTargetAdmin = await tx
.select({ id: instanceUserRoles.id })
@@ -108,6 +110,7 @@ export async function claimBoardOwnership(
const allCompanies = await tx.select({ id: companies.id }).from(companies);
for (const company of allCompanies) {
+ claimedCompanyIds.push(company.id);
const existing = await tx
.select({ id: companyMemberships.id, status: companyMemberships.status })
.from(companyMemberships)
@@ -140,6 +143,15 @@ export async function claimBoardOwnership(
}
});
+ for (const companyId of claimedCompanyIds) {
+ await ensureHumanRoleDefaultGrants(db, {
+ companyId,
+ principalId: opts.userId,
+ membershipRole: "owner",
+ grantedByUserId: opts.userId,
+ });
+ }
+
if (activeChallenge && activeChallenge.token === opts.token) {
activeChallenge.claimedAt = new Date();
activeChallenge.claimedByUserId = opts.userId;
diff --git a/server/src/index.ts b/server/src/index.ts
index c039f7584f1..bb89a46e567 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -30,6 +30,7 @@ import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import {
feedbackService,
+ backfillPrincipalAccessCompatibility,
heartbeatService,
instanceSettingsService,
reconcilePersistedRuntimeServicesOnStartup,
@@ -512,6 +513,10 @@ export async function startServer(): Promise {
if (config.deploymentMode === "local_trusted") {
await ensureLocalTrustedBoardPrincipal(db as any);
}
+ const accessBackfill = await backfillPrincipalAccessCompatibility(db as any);
+ if (accessBackfill.agentMembershipsInserted > 0 || accessBackfill.humanGrantsInserted > 0) {
+ logger.info(accessBackfill, "Backfilled principal access compatibility records");
+ }
if (config.deploymentMode === "authenticated") {
const {
createBetterAuthHandler,
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 03f6ef58ee5..07c2dd6de33 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -501,6 +501,15 @@ export function agentRoutes(
};
}
+ if (membership?.status === "active") {
+ return {
+ canAssignTasks: true,
+ taskAssignSource: "simple_default" as const,
+ membership,
+ grants,
+ };
+ }
+
return {
canAssignTasks: false,
taskAssignSource: "none" as const,
@@ -543,34 +552,32 @@ export function agentRoutes(
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
- if (req.actor.type === "board") {
- if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null;
- const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
- if (!allowed) {
- throw forbidden("Missing permission: agents:create");
- }
- return null;
+ const decision = await access.decide({
+ actor: req.actor,
+ action: "agents:create",
+ resource: { type: "company", companyId },
+ });
+ if (!decision.allowed) {
+ throw forbidden(decision.explanation);
}
- if (!req.actor.agentId) throw forbidden("Agent authentication required");
- const actorAgent = await svc.getById(req.actor.agentId);
+ if (req.actor.type !== "agent") return null;
+ const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
- const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
- if (!allowedByGrant && !canCreateAgents(actorAgent)) {
- throw forbidden("Missing permission: can create agents");
- }
return actorAgent;
}
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
- if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
- const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
- if (!allowed) {
- throw forbidden("Missing permission: agents:create");
- }
+ const decision = await access.decide({
+ actor: req.actor,
+ action: "agents:create",
+ resource: { type: "company", companyId },
+ });
+ if (decision.allowed) return;
+ throw forbidden(decision.explanation);
}
async function assertCanReadConfigurations(req: Request, companyId: string) {
@@ -592,15 +599,12 @@ export function agentRoutes(
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
- if (req.actor.type === "board") {
- if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
- return access.canUser(companyId, req.actor.userId, "agents:create");
- }
- if (!req.actor.agentId) return false;
- const actorAgent = await svc.getById(req.actor.agentId);
- if (!actorAgent || actorAgent.companyId !== companyId) return false;
- const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
- return allowedByGrant || canCreateAgents(actorAgent);
+ const decision = await access.decide({
+ actor: req.actor,
+ action: "agent_config:read",
+ resource: { type: "company", companyId },
+ });
+ return decision.allowed;
}
async function buildSkippedWakeupResponse(
@@ -672,27 +676,13 @@ export function agentRoutes(
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
- if (req.actor.type === "board") {
- await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
- return;
- }
- if (!req.actor.agentId) throw forbidden("Agent authentication required");
-
- const actorAgent = await svc.getById(req.actor.agentId);
- if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
- throw forbidden("Agent key cannot access another company");
- }
-
- if (actorAgent.id === targetAgent.id) return;
- if (actorAgent.role === "ceo") return;
- const allowedByGrant = await access.hasPermission(
- targetAgent.companyId,
- "agent",
- actorAgent.id,
- "agents:create",
- );
- if (allowedByGrant || canCreateAgents(actorAgent)) return;
- throw forbidden("Only CEO or agent creators can modify other agents");
+ const decision = await access.decide({
+ actor: req.actor,
+ action: "agent_config:update",
+ resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
+ });
+ if (decision.allowed) return;
+ throw forbidden(decision.explanation);
}
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts
index 96a004ac039..2de26afbe13 100644
--- a/server/src/routes/companies.ts
+++ b/server/src/routes/companies.ts
@@ -271,7 +271,14 @@ export function companyRoutes(db: Db, storage?: StorageService) {
throw forbidden("Instance admin required");
}
const company = await svc.create(req.body);
- await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active");
+ const ownerPrincipalId = req.actor.userId ?? "local-board";
+ await access.ensureMembership(company.id, "user", ownerPrincipalId, "owner", "active");
+ await access.ensureRoleDefaultGrants(
+ company.id,
+ ownerPrincipalId,
+ "owner",
+ req.actor.userId ?? null,
+ );
await logActivity(db, {
companyId: company.id,
actorType: "user",
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 9b2b9da7963..3e2549b373e 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -1246,29 +1246,48 @@ export function issueRoutes(
return (req.actor.companyIds ?? []).includes(companyId);
}
- function canCreateAgentsLegacy(agent: { permissions: Record | null | undefined; role: string }) {
- if (agent.role === "ceo") return true;
- if (!agent.permissions || typeof agent.permissions !== "object") return false;
- return Boolean((agent.permissions as Record).canCreateAgents);
+ type TaskAssignmentAuthorizationScope = {
+ issueId?: string | null;
+ projectId?: string | null;
+ parentIssueId?: string | null;
+ assigneeAgentId?: string | null;
+ assigneeUserId?: string | null;
+ };
+
+ async function resolveAssignmentProjectId(input: {
+ companyId: string;
+ projectId: string | null | undefined;
+ parentIssueId?: string | null;
+ }) {
+ if (input.projectId !== undefined) return input.projectId;
+ if (!input.parentIssueId) return null;
+ const parent = await svc.getById(input.parentIssueId);
+ if (!parent || parent.companyId !== input.companyId) return null;
+ return parent.projectId ?? null;
}
- async function assertCanAssignTasks(req: Request, companyId: string) {
+ async function assertCanAssignTasks(
+ req: Request,
+ companyId: string,
+ assignmentScope?: TaskAssignmentAuthorizationScope,
+ ) {
assertCompanyAccess(req, companyId);
- if (req.actor.type === "board") {
- if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
- const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
- if (!allowed) throw forbidden("Missing permission: tasks:assign");
- return;
- }
- if (req.actor.type === "agent") {
- if (!req.actor.agentId) throw forbidden("Agent authentication required");
- const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign");
- if (allowedByGrant) return;
- const actorAgent = await agentsSvc.getById(req.actor.agentId);
- if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return;
- throw forbidden("Missing permission: tasks:assign");
- }
- throw unauthorized();
+ const decision = await access.decide({
+ actor: req.actor,
+ action: "tasks:assign",
+ resource: {
+ type: "issue",
+ companyId,
+ issueId: assignmentScope?.issueId ?? null,
+ projectId: assignmentScope?.projectId ?? null,
+ parentIssueId: assignmentScope?.parentIssueId ?? null,
+ assigneeAgentId: assignmentScope?.assigneeAgentId ?? null,
+ assigneeUserId: assignmentScope?.assigneeUserId ?? null,
+ },
+ scope: assignmentScope ?? null,
+ });
+ if (decision.allowed) return;
+ throw forbidden(decision.explanation);
}
function requireAgentRunId(req: Request, res: Response) {
@@ -1284,31 +1303,12 @@ export function issueRoutes(
companyId: string,
assigneeAgentId: string,
) {
- const allowedByGrant = await access.hasPermission(
- companyId,
- "agent",
- actorAgentId,
- "tasks:manage_active_checkouts",
- );
- if (allowedByGrant) return true;
-
- const companyAgents = await agentsSvc.list(companyId);
- const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent]));
- const actorAgent = agentsById.get(actorAgentId);
- if (!actorAgent) return false;
- if (canCreateAgentsLegacy(actorAgent)) return true;
-
- // Reporting-chain managers may intervene in an agent's active checkout
- // without taking the task over. Peers must own the checkout/run first.
- let cursor: string | null = assigneeAgentId;
- for (let depth = 0; cursor && depth < 50; depth += 1) {
- const assignee = agentsById.get(cursor);
- if (!assignee) return false;
- if (assignee.reportsTo === actorAgentId) return true;
- cursor = assignee.reportsTo;
- }
-
- return false;
+ const decision = await access.decide({
+ actor: { type: "agent", agentId: actorAgentId, companyId },
+ action: "tasks:manage_active_checkouts",
+ resource: { type: "issue", companyId, assigneeAgentId },
+ });
+ return decision.allowed;
}
async function assertAgentIssueMutationAllowed(
@@ -3146,7 +3146,16 @@ export function issueRoutes(
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
- await assertCanAssignTasks(req, companyId);
+ await assertCanAssignTasks(req, companyId, {
+ projectId: await resolveAssignmentProjectId({
+ companyId,
+ projectId: req.body.projectId,
+ parentIssueId: req.body.parentId,
+ }),
+ parentIssueId: req.body.parentId ?? null,
+ assigneeAgentId: req.body.assigneeAgentId ?? null,
+ assigneeUserId: req.body.assigneeUserId ?? null,
+ });
}
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
@@ -3242,7 +3251,12 @@ export function issueRoutes(
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
- await assertCanAssignTasks(req, parent.companyId);
+ await assertCanAssignTasks(req, parent.companyId, {
+ projectId: req.body.projectId ?? parent.projectId ?? null,
+ parentIssueId: parent.id,
+ assigneeAgentId: req.body.assigneeAgentId ?? null,
+ assigneeUserId: req.body.assigneeUserId ?? null,
+ });
}
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
@@ -3631,7 +3645,23 @@ export function issueRoutes(
if (assigneeWillChange && !transition.workflowControlledAssignment) {
if (!isAgentReturningIssueToCreator) {
- await assertCanAssignTasks(req, existing.companyId);
+ await assertCanAssignTasks(req, existing.companyId, {
+ issueId: existing.id,
+ projectId: await resolveAssignmentProjectId({
+ companyId: existing.companyId,
+ projectId: updateFields.projectId === undefined
+ ? existing.projectId
+ : updateFields.projectId as string | null | undefined,
+ parentIssueId: (updateFields.parentId === undefined
+ ? existing.parentId
+ : updateFields.parentId) as string | null | undefined,
+ }),
+ parentIssueId: (updateFields.parentId === undefined
+ ? existing.parentId
+ : updateFields.parentId) as string | null | undefined,
+ assigneeAgentId: nextAssigneeAgentId,
+ assigneeUserId: nextAssigneeUserId,
+ });
}
}
@@ -4401,6 +4431,16 @@ export function issueRoutes(
return;
}
+ if (issue.assigneeAgentId !== req.body.agentId) {
+ await assertCanAssignTasks(req, issue.companyId, {
+ issueId: issue.id,
+ projectId: issue.projectId ?? null,
+ parentIssueId: issue.parentId ?? null,
+ assigneeAgentId: req.body.agentId,
+ assigneeUserId: null,
+ });
+ }
+
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
if (closedExecutionWorkspace) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts
index 6abb7c56ec5..741fa1513fe 100644
--- a/server/src/routes/plugins.ts
+++ b/server/src/routes/plugins.ts
@@ -1116,7 +1116,7 @@ export function pluginRoutes(
return;
}
- assertPluginBridgeScope(req, body.companyId);
+ const companyId = assertPluginBridgeScope(req, body.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1124,6 +1124,7 @@ export function pluginRoutes(
"getData",
{
key: body.key,
+ ...(companyId ? { companyId } : {}),
params: body.params ?? {},
renderEnvironment: body.renderEnvironment ?? null,
},
@@ -1208,7 +1209,7 @@ export function pluginRoutes(
return;
}
- assertPluginBridgeScope(req, body.companyId);
+ const companyId = assertPluginBridgeScope(req, body.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1216,6 +1217,7 @@ export function pluginRoutes(
"performAction",
{
key: body.key,
+ ...(companyId ? { companyId } : {}),
params: body.params ?? {},
renderEnvironment: body.renderEnvironment ?? null,
},
@@ -1301,7 +1303,7 @@ export function pluginRoutes(
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
} | undefined;
- assertPluginBridgeScope(req, body?.companyId);
+ const companyId = assertPluginBridgeScope(req, body?.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1309,6 +1311,7 @@ export function pluginRoutes(
"getData",
{
key,
+ ...(companyId ? { companyId } : {}),
params: body?.params ?? {},
renderEnvironment: body?.renderEnvironment ?? null,
},
@@ -1390,7 +1393,7 @@ export function pluginRoutes(
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
} | undefined;
- assertPluginBridgeScope(req, body?.companyId);
+ const companyId = assertPluginBridgeScope(req, body?.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1398,6 +1401,7 @@ export function pluginRoutes(
"performAction",
{
key,
+ ...(companyId ? { companyId } : {}),
params: body?.params ?? {},
renderEnvironment: body?.renderEnvironment ?? null,
},
diff --git a/server/src/services/access.ts b/server/src/services/access.ts
index faac70da407..ad58db87df4 100644
--- a/server/src/services/access.ts
+++ b/server/src/services/access.ts
@@ -9,6 +9,8 @@ import {
} from "@paperclipai/db";
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
import { conflict } from "../errors.js";
+import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
+import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js";
type MembershipRow = typeof companyMemberships.$inferSelect;
type GrantInput = {
@@ -24,6 +26,8 @@ type MemberArchiveInput = {
};
export function accessService(db: Db) {
+ const authorization = authorizationService(db);
+
async function isInstanceAdmin(userId: string | null | undefined): Promise {
if (!userId) return false;
const row = await db
@@ -58,21 +62,13 @@ export function accessService(db: Db) {
principalId: string,
permissionKey: PermissionKey,
): Promise {
- const membership = await getMembership(companyId, principalType, principalId);
- if (!membership || membership.status !== "active") return false;
- const grant = await db
- .select({ id: principalPermissionGrants.id })
- .from(principalPermissionGrants)
- .where(
- and(
- eq(principalPermissionGrants.companyId, companyId),
- eq(principalPermissionGrants.principalType, principalType),
- eq(principalPermissionGrants.principalId, principalId),
- eq(principalPermissionGrants.permissionKey, permissionKey),
- ),
- )
- .then((rows) => rows[0] ?? null);
- return Boolean(grant);
+ return authorization.decidePrincipalGrant({
+ companyId,
+ principalType,
+ principalId,
+ permissionKey,
+ action: permissionKey,
+ }).then((decision) => decision.allowed);
}
async function canUser(
@@ -80,9 +76,20 @@ export function accessService(db: Db) {
userId: string | null | undefined,
permissionKey: PermissionKey,
): Promise {
- if (!userId) return false;
- if (await isInstanceAdmin(userId)) return true;
- return hasPermission(companyId, "user", userId, permissionKey);
+ return authorization.decide({
+ actor: { type: "board", userId },
+ action: permissionKey,
+ resource: { type: "company", companyId },
+ }).then((decision) => decision.allowed);
+ }
+
+ async function decide(input: {
+ actor: AuthorizationActor;
+ action: Parameters[0]["action"];
+ resource: AuthorizationResource;
+ scope?: Record | null;
+ }) {
+ return authorization.decide(input);
}
async function listMembers(companyId: string) {
@@ -616,10 +623,30 @@ export function accessService(db: Db) {
membership.membershipRole,
"active",
);
+ await ensureHumanRoleDefaultGrants(db, {
+ companyId: targetCompanyId,
+ principalId: membership.principalId,
+ membershipRole: membership.membershipRole,
+ grantedByUserId: null,
+ });
}
return sourceMemberships;
}
+ async function ensureRoleDefaultGrants(
+ companyId: string,
+ principalId: string,
+ membershipRole: string | null | undefined,
+ grantedByUserId: string | null,
+ ) {
+ return ensureHumanRoleDefaultGrants(db, {
+ companyId,
+ principalId,
+ membershipRole,
+ grantedByUserId,
+ });
+ }
+
async function listPrincipalGrants(
companyId: string,
principalType: PrincipalType,
@@ -768,6 +795,7 @@ export function accessService(db: Db) {
return {
isInstanceAdmin,
+ decide,
canUser,
hasPermission,
getMembership,
@@ -776,6 +804,7 @@ export function accessService(db: Db) {
listMembers,
listActiveUserMemberships,
copyActiveUserMemberships,
+ ensureRoleDefaultGrants,
archiveMember,
setMemberPermissions,
updateMemberAndPermissions,
diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts
index a0379c92e07..97cd13bf584 100644
--- a/server/src/services/agent-permissions.ts
+++ b/server/src/services/agent-permissions.ts
@@ -18,7 +18,9 @@ export function normalizeAgentPermissions(
}
const record = permissions as Record;
+ const preserved = { ...record };
return {
+ ...preserved,
canCreateAgents:
typeof record.canCreateAgents === "boolean"
? record.canCreateAgents
diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts
index 0d4762cc9d4..faf63723673 100644
--- a/server/src/services/agents.ts
+++ b/server/src/services/agents.ts
@@ -554,7 +554,7 @@ export function agentService(db: Db) {
const updated = await db
.update(agents)
.set({
- permissions: normalizeAgentPermissions(permissions, existing.role),
+ permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
updatedAt: new Date(),
})
.where(eq(agents.id, id))
diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts
new file mode 100644
index 00000000000..bb1d18b9b40
--- /dev/null
+++ b/server/src/services/authorization.ts
@@ -0,0 +1,823 @@
+import { and, eq } from "drizzle-orm";
+import type { Db } from "@paperclipai/db";
+import {
+ agents,
+ companyMemberships,
+ instanceUserRoles,
+ issues,
+ principalPermissionGrants,
+ projects,
+} from "@paperclipai/db";
+import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
+
+export type AuthorizationActor =
+ {
+ type: "board" | "agent" | "none";
+ userId?: string | null;
+ companyIds?: string[];
+ memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
+ isInstanceAdmin?: boolean;
+ agentId?: string | null;
+ companyId?: string | null;
+ source?:
+ | "local_implicit"
+ | "session"
+ | "board_key"
+ | "agent_key"
+ | "agent_jwt"
+ | "cloud_tenant"
+ | "none";
+ };
+
+export type AuthorizationAction =
+ | PermissionKey
+ | "agent_config:read"
+ | "agent_config:update"
+ | "issue:mutate";
+
+export type AuthorizationResource =
+ | { type: "company"; companyId: string }
+ | { type: "agent"; companyId: string; agentId?: string | null }
+ | {
+ type: "issue";
+ companyId: string;
+ issueId?: string | null;
+ projectId?: string | null;
+ parentIssueId?: string | null;
+ assigneeAgentId?: string | null;
+ assigneeUserId?: string | null;
+ status?: string | null;
+ };
+
+export type AuthorizationDecision = {
+ allowed: boolean;
+ action: AuthorizationAction;
+ explanation: string;
+ reason:
+ | "allow_local_board"
+ | "allow_instance_admin"
+ | "allow_explicit_grant"
+ | "allow_legacy_agent_creator"
+ | "allow_self"
+ | "allow_company_agent"
+ | "allow_simple_company_member"
+ | "allow_manager_chain"
+ | "deny_unauthenticated"
+ | "deny_company_boundary"
+ | "deny_missing_membership"
+ | "deny_missing_grant"
+ | "deny_policy_restricted"
+ | "deny_scope"
+ | "deny_unsupported_action";
+ grant?: {
+ principalType: PrincipalType;
+ principalId: string;
+ permissionKey: PermissionKey;
+ scope: Record | null;
+ };
+};
+
+type PrincipalGrantDecision = AuthorizationDecision & {
+ grant?: NonNullable;
+};
+
+function companyIdForResource(resource: AuthorizationResource) {
+ return resource.companyId;
+}
+
+function permissionForAction(action: AuthorizationAction): PermissionKey | null {
+ if (action === "agent_config:read" || action === "agent_config:update") return "agents:create";
+ if (action === "issue:mutate") return null;
+ return action;
+}
+
+function canCreateAgentsLegacy(agent: { role: string; permissions: Record | null | undefined }) {
+ if (agent.role === "ceo") return true;
+ if (!agent.permissions || typeof agent.permissions !== "object") return false;
+ return Boolean(agent.permissions.canCreateAgents);
+}
+
+function scopeValueList(value: unknown): string[] {
+ if (typeof value === "string" && value.trim()) return [value.trim()];
+ if (!Array.isArray(value)) return [];
+ return value
+ .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
+ .map((entry) => entry.trim());
+}
+
+function prefixedScopeValues(grantScope: Record, prefix: string) {
+ return scopeValueList(grantScope.allow)
+ .filter((rule) => rule.startsWith(prefix))
+ .map((rule) => rule.slice(prefix.length))
+ .filter((value) => value.length > 0);
+}
+
+function scopeValuesForKeys(grantScope: Record, keys: string[]) {
+ return keys.flatMap((key) => scopeValueList(grantScope[key]));
+}
+
+function scopeIncludesId(ids: string[], id: string | null | undefined) {
+ return Boolean(id && ids.includes(id));
+}
+
+function isSimpleAssignableAgentStatus(status: string | null | undefined) {
+ return status !== "pending_approval" && status !== "terminated";
+}
+
+function isPlainRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function objectIsEmpty(value: Record) {
+ return Object.keys(value).length === 0;
+}
+
+function readPolicyObject(container: unknown, key: string): Record | null {
+ if (!isPlainRecord(container)) return null;
+ const value = container[key];
+ return isPlainRecord(value) ? value : null;
+}
+
+function readString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+function readBoolean(value: unknown): boolean | null {
+ return typeof value === "boolean" ? value : null;
+}
+
+type AssignmentPolicyEffect =
+ | { kind: "none" }
+ | { kind: "restricted"; explanation: string }
+ | { kind: "requires_approval"; explanation: string }
+ | { kind: "unknown"; explanation: string };
+
+type AgentHierarchyRow = { id: string; reportsTo: string | null };
+
+function evaluateAuthorizationPolicyForAssignment(
+ policy: Record | null | undefined,
+ label: string,
+): AssignmentPolicyEffect {
+ if (!policy || objectIsEmpty(policy)) return { kind: "none" };
+
+ const agentVisibility = readPolicyObject(policy, "agentVisibility");
+ const assignmentPolicy = readPolicyObject(policy, "assignmentPolicy");
+ const protectedAgent = readPolicyObject(policy, "protectedAgent");
+ const knownTopLevelKeys = new Set([
+ "agentVisibility",
+ "assignmentPolicy",
+ "protectedAgent",
+ "managedBy",
+ ]);
+ const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key));
+ const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent);
+ if (hasUnknownTopLevelKey || !hasKnownPolicySection) {
+ return {
+ kind: "unknown",
+ explanation: `${label} has authorization policy data that core cannot evaluate for task assignment.`,
+ };
+ }
+
+ const visibilityMode = readString(agentVisibility?.mode);
+ if (visibilityMode && visibilityMode !== "discoverable" && visibilityMode !== "private") {
+ return {
+ kind: "unknown",
+ explanation: `${label} has an unsupported agent visibility policy mode.`,
+ };
+ }
+
+ const assignmentMode = readString(assignmentPolicy?.mode);
+ if (assignmentMode && assignmentMode !== "company_default" && assignmentMode !== "protected") {
+ return {
+ kind: "unknown",
+ explanation: `${label} has an unsupported assignment policy mode.`,
+ };
+ }
+
+ const requiresApproval =
+ readBoolean(protectedAgent?.requiresApproval) === true ||
+ readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true;
+ if (requiresApproval) {
+ return {
+ kind: "requires_approval",
+ explanation: `${label} requires approval before task assignment.`,
+ };
+ }
+
+ if (
+ visibilityMode === "private" ||
+ readBoolean(agentVisibility?.hiddenFromDefaultDirectory) === true
+ ) {
+ return {
+ kind: "restricted",
+ explanation: `${label} is private and cannot use simple company-wide task assignment.`,
+ };
+ }
+
+ if (assignmentMode === "protected") {
+ return {
+ kind: "restricted",
+ explanation: `${label} is protected and requires an explicit assignment grant.`,
+ };
+ }
+
+ return { kind: "none" };
+}
+
+function agentIsInSubtree(
+ agentsById: Map,
+ rootAgentId: string,
+ targetAgentId: string,
+) {
+ if (rootAgentId === targetAgentId) return true;
+
+ let cursor: string | null = targetAgentId;
+ for (let depth = 0; cursor && depth < 50; depth += 1) {
+ const current = agentsById.get(cursor);
+ if (!current) return false;
+ if (current.reportsTo === rootAgentId) return true;
+ cursor = current.reportsTo;
+ }
+ return false;
+}
+
+async function loadCompanyAgentHierarchy(db: Db, companyId: string) {
+ const rows = await db
+ .select({ id: agents.id, reportsTo: agents.reportsTo })
+ .from(agents)
+ .where(eq(agents.companyId, companyId));
+ return new Map(rows.map((agent) => [agent.id, agent]));
+}
+
+async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) {
+ return agentIsInSubtree(
+ await loadCompanyAgentHierarchy(db, companyId),
+ rootAgentId,
+ targetAgentId,
+ );
+}
+
+async function scopeAllows(
+ db: Db,
+ companyId: string,
+ grantScope: Record | null,
+ requestedScope: Record | null | undefined,
+ options: { requireStructuredScope?: boolean } = {},
+) {
+ if (!grantScope || Object.keys(grantScope).length === 0) return !options.requireStructuredScope;
+ if (!requestedScope) return false;
+
+ const targetAssigneeAgentId =
+ typeof requestedScope.assigneeAgentId === "string"
+ ? requestedScope.assigneeAgentId
+ : typeof requestedScope.targetAgentId === "string"
+ ? requestedScope.targetAgentId
+ : null;
+ const requestedProjectId = typeof requestedScope.projectId === "string" ? requestedScope.projectId : null;
+ let constrained = false;
+
+ const projectIds = [
+ ...scopeValueList(grantScope.projectId),
+ ...scopeValueList(grantScope.projectIds),
+ ...prefixedScopeValues(grantScope, "project:"),
+ ];
+ if (projectIds.length > 0) {
+ constrained = true;
+ if (!scopeIncludesId(projectIds, requestedProjectId)) return false;
+ }
+
+ const targetAgentIds = [
+ ...scopeValuesForKeys(grantScope, [
+ "agentId",
+ "agentIds",
+ "assigneeAgentId",
+ "assigneeAgentIds",
+ "targetAgentId",
+ "targetAgentIds",
+ ]),
+ ...prefixedScopeValues(grantScope, "agent:"),
+ ];
+ if (targetAgentIds.length > 0) {
+ constrained = true;
+ if (!scopeIncludesId(targetAgentIds, targetAssigneeAgentId)) return false;
+ }
+
+ const subtreeRootAgentIds = [
+ ...scopeValuesForKeys(grantScope, [
+ "managerAgentId",
+ "managerAgentIds",
+ "managedSubtreeAgentId",
+ "managedSubtreeAgentIds",
+ "subtreeAgentId",
+ "subtreeAgentIds",
+ "subtreeRootAgentId",
+ "subtreeRootAgentIds",
+ ]),
+ ...prefixedScopeValues(grantScope, "subtree:"),
+ ];
+ if (subtreeRootAgentIds.length > 0) {
+ constrained = true;
+ if (!targetAssigneeAgentId) return false;
+ const agentsById = await loadCompanyAgentHierarchy(db, companyId);
+ let matchesSubtree = false;
+ for (const rootAgentId of subtreeRootAgentIds) {
+ if (agentIsInSubtree(agentsById, rootAgentId, targetAssigneeAgentId)) {
+ matchesSubtree = true;
+ break;
+ }
+ }
+ if (!matchesSubtree) return false;
+ }
+
+ // Unknown metadata keys do not constrain the grant. Recognized constraints
+ // return false above when they fail to match the requested assignment scope.
+ return !constrained ? true : constrained;
+}
+
+function allow(input: Omit): AuthorizationDecision {
+ return { ...input, allowed: true };
+}
+
+function deny(input: Omit): AuthorizationDecision {
+ return { ...input, allowed: false };
+}
+
+export function authorizationService(db: Db) {
+ async function isInstanceAdmin(userId: string | null | undefined): Promise {
+ if (!userId) return false;
+ if (
+ await db
+ .select({ id: instanceUserRoles.id })
+ .from(instanceUserRoles)
+ .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
+ .then((rows) => rows[0] ?? null)
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ async function getActiveMembership(
+ companyId: string,
+ principalType: PrincipalType,
+ principalId: string,
+ ) {
+ return db
+ .select()
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.companyId, companyId),
+ eq(companyMemberships.principalType, principalType),
+ eq(companyMemberships.principalId, principalId),
+ eq(companyMemberships.status, "active"),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ }
+
+ async function findGrant(
+ companyId: string,
+ principalType: PrincipalType,
+ principalId: string,
+ permissionKey: PermissionKey,
+ ) {
+ return db
+ .select()
+ .from(principalPermissionGrants)
+ .where(
+ and(
+ eq(principalPermissionGrants.companyId, companyId),
+ eq(principalPermissionGrants.principalType, principalType),
+ eq(principalPermissionGrants.principalId, principalId),
+ eq(principalPermissionGrants.permissionKey, permissionKey),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ }
+
+ async function decidePrincipalGrant(input: {
+ companyId: string;
+ principalType: PrincipalType;
+ principalId: string;
+ action: AuthorizationAction;
+ permissionKey: PermissionKey;
+ scope?: Record | null;
+ }): Promise {
+ const membership = await getActiveMembership(input.companyId, input.principalType, input.principalId);
+ if (!membership) {
+ return deny({
+ action: input.action,
+ reason: "deny_missing_membership",
+ explanation: `${input.principalType} principal ${input.principalId} is not an active member of company ${input.companyId}.`,
+ });
+ }
+
+ const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey);
+ if (!grant) {
+ return deny({
+ action: input.action,
+ reason: "deny_missing_grant",
+ explanation: `Missing permission: ${input.permissionKey}.`,
+ });
+ }
+
+ if (
+ !(await scopeAllows(db, input.companyId, grant.scope, input.scope, {
+ requireStructuredScope: input.permissionKey === "tasks:assign_scope",
+ }))
+ ) {
+ return deny({
+ action: input.action,
+ reason: "deny_scope",
+ explanation: `Permission ${input.permissionKey} does not cover the requested scope.`,
+ grant: {
+ principalType: input.principalType,
+ principalId: input.principalId,
+ permissionKey: input.permissionKey,
+ scope: grant.scope ?? null,
+ },
+ });
+ }
+
+ return allow({
+ action: input.action,
+ reason: "allow_explicit_grant",
+ explanation: `Allowed by explicit grant ${input.permissionKey}.`,
+ grant: {
+ principalType: input.principalType,
+ principalId: input.principalId,
+ permissionKey: input.permissionKey,
+ scope: grant.scope ?? null,
+ },
+ });
+ }
+
+ async function loadAgent(agentId: string) {
+ return db
+ .select({
+ id: agents.id,
+ companyId: agents.companyId,
+ role: agents.role,
+ status: agents.status,
+ reportsTo: agents.reportsTo,
+ permissions: agents.permissions,
+ })
+ .from(agents)
+ .where(eq(agents.id, agentId))
+ .then((rows) => rows[0] ?? null);
+ }
+
+ async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) {
+ const row = await db
+ .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
+ .from(projects)
+ .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
+ .then((rows) => rows[0] ?? null);
+ return readPolicyObject(row?.executionWorkspacePolicy, "authorizationPolicy");
+ }
+
+ async function loadIssueAuthorizationPolicy(companyId: string, issueId: string) {
+ const row = await db
+ .select({ executionPolicy: issues.executionPolicy })
+ .from(issues)
+ .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
+ .then((rows) => rows[0] ?? null);
+ return readPolicyObject(row?.executionPolicy, "authorizationPolicy");
+ }
+
+ async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
+ if (resource.type !== "issue") return true;
+ if (resource.assigneeAgentId) {
+ const target = await loadAgent(resource.assigneeAgentId);
+ return Boolean(
+ target &&
+ target.companyId === resource.companyId &&
+ isSimpleAssignableAgentStatus(target.status),
+ );
+ }
+ if (resource.assigneeUserId) {
+ return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId));
+ }
+ return true;
+ }
+
+ async function assignmentPolicyEffect(resource: AuthorizationResource): Promise {
+ if (resource.type !== "issue") return { kind: "none" };
+
+ const checks: Array> = [];
+ if (resource.assigneeAgentId) {
+ checks.push(
+ loadAgent(resource.assigneeAgentId).then((agent) =>
+ evaluateAuthorizationPolicyForAssignment(
+ readPolicyObject(agent?.permissions, "authorizationPolicy"),
+ "Target agent",
+ ),
+ ),
+ );
+ }
+ if (resource.projectId) {
+ checks.push(
+ loadProjectAuthorizationPolicy(resource.companyId, resource.projectId).then((policy) =>
+ evaluateAuthorizationPolicyForAssignment(policy, "Target project"),
+ ),
+ );
+ }
+ if (resource.issueId) {
+ checks.push(
+ loadIssueAuthorizationPolicy(resource.companyId, resource.issueId).then((policy) =>
+ evaluateAuthorizationPolicyForAssignment(policy, "Target issue"),
+ ),
+ );
+ }
+ if (resource.parentIssueId && resource.parentIssueId !== resource.issueId) {
+ checks.push(
+ loadIssueAuthorizationPolicy(resource.companyId, resource.parentIssueId).then((policy) =>
+ evaluateAuthorizationPolicyForAssignment(policy, "Parent issue"),
+ ),
+ );
+ }
+ if (checks.length === 0) return { kind: "none" };
+
+ const effects = await Promise.all(checks);
+ return (
+ effects.find((effect) => effect.kind === "unknown") ??
+ effects.find((effect) => effect.kind === "requires_approval") ??
+ effects.find((effect) => effect.kind === "restricted") ??
+ { kind: "none" }
+ );
+ }
+
+ async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) {
+ return isAgentInSubtree(db, companyId, managerAgentId, assigneeAgentId);
+ }
+
+ async function decide(input: {
+ actor: AuthorizationActor;
+ action: AuthorizationAction;
+ resource: AuthorizationResource;
+ scope?: Record | null;
+ }): Promise {
+ const permissionKey = permissionForAction(input.action);
+ const companyId = companyIdForResource(input.resource);
+
+ async function decideWithTaskAssignmentGrants(
+ principalType: PrincipalType,
+ principalId: string,
+ ): Promise {
+ const broadDecision = await decidePrincipalGrant({
+ companyId,
+ principalType,
+ principalId,
+ action: input.action,
+ permissionKey: "tasks:assign",
+ scope: input.scope,
+ });
+ if (broadDecision.allowed || broadDecision.reason === "deny_missing_membership") return broadDecision;
+ const scopedDecision = await decidePrincipalGrant({
+ companyId,
+ principalType,
+ principalId,
+ action: input.action,
+ permissionKey: "tasks:assign_scope",
+ scope: input.scope,
+ });
+ if (scopedDecision.allowed || broadDecision.reason === "deny_missing_grant") return scopedDecision;
+ return broadDecision;
+ }
+
+ async function denyForAssignmentPolicyIfNeeded(
+ policyEffect: AssignmentPolicyEffect,
+ ): Promise {
+ if (policyEffect.kind === "none" || policyEffect.kind === "restricted") return null;
+ return deny({
+ action: input.action,
+ reason: "deny_policy_restricted",
+ explanation: policyEffect.explanation,
+ });
+ }
+
+ function denyRestrictedAssignmentPolicy(policyEffect: AssignmentPolicyEffect): AuthorizationDecision {
+ return deny({
+ action: input.action,
+ reason: "deny_policy_restricted",
+ explanation:
+ policyEffect.kind === "restricted"
+ ? policyEffect.explanation
+ : "Restrictive authorization policy blocks simple company-wide task assignment.",
+ });
+ }
+
+ if (input.actor.type === "none") {
+ return deny({
+ action: input.action,
+ reason: "deny_unauthenticated",
+ explanation: "Authentication required.",
+ });
+ }
+
+ if (input.actor.type === "board") {
+ let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null;
+ if (input.actor.source === "local_implicit") {
+ return allow({
+ action: input.action,
+ reason: "allow_local_board",
+ explanation: "Allowed because the actor is the local implicit board.",
+ });
+ }
+ if (input.actor.isInstanceAdmin || await isInstanceAdmin(input.actor.userId)) {
+ return allow({
+ action: input.action,
+ reason: "allow_instance_admin",
+ explanation: "Allowed because the actor is an instance admin.",
+ });
+ }
+ if (!input.actor.userId) {
+ return deny({
+ action: input.action,
+ reason: "deny_unauthenticated",
+ explanation: "Board user id is required.",
+ });
+ }
+ if (input.action === "tasks:assign") {
+ if (!(await assignmentTargetIsInCompany(input.resource))) {
+ return deny({
+ action: input.action,
+ reason: "deny_company_boundary",
+ explanation: "Task assignment target agent is not active in the target company.",
+ });
+ }
+ const policyEffect = await assignmentPolicyEffect(input.resource);
+ taskAssignmentPolicyEffect = policyEffect;
+ const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
+ if (policyDeny) return policyDeny;
+ const membership = await getActiveMembership(companyId, "user", input.actor.userId);
+ if (policyEffect.kind === "none" && membership && membership.membershipRole !== "viewer") {
+ return allow({
+ action: input.action,
+ reason: "allow_simple_company_member",
+ explanation: "Allowed by simple mode company-wide task assignment default.",
+ });
+ }
+ }
+ if (!permissionKey) {
+ return deny({
+ action: input.action,
+ reason: "deny_unsupported_action",
+ explanation: `No board permission mapping exists for ${input.action}.`,
+ });
+ }
+ if (input.action === "tasks:assign") {
+ const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId);
+ if (grantDecision.allowed) return grantDecision;
+ const policyEffect = taskAssignmentPolicyEffect ?? await assignmentPolicyEffect(input.resource);
+ if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect);
+ return grantDecision;
+ }
+ return decidePrincipalGrant({
+ companyId,
+ principalType: "user",
+ principalId: input.actor.userId,
+ action: input.action,
+ permissionKey,
+ scope: input.scope,
+ });
+ }
+
+ const actorAgentId = input.actor.agentId ?? null;
+ if (!actorAgentId) {
+ return deny({
+ action: input.action,
+ reason: "deny_unauthenticated",
+ explanation: "Agent authentication required.",
+ });
+ }
+ if (input.actor.companyId !== companyId) {
+ return deny({
+ action: input.action,
+ reason: "deny_company_boundary",
+ explanation: "Agent key cannot access another company.",
+ });
+ }
+
+ const actorAgent = await loadAgent(actorAgentId);
+ if (!actorAgent || actorAgent.companyId !== companyId) {
+ return deny({
+ action: input.action,
+ reason: "deny_company_boundary",
+ explanation: "Actor agent was not found in the target company.",
+ });
+ }
+
+ if (input.action === "tasks:assign") {
+ if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
+ return deny({
+ action: input.action,
+ reason: "deny_missing_membership",
+ explanation: "Actor agent is not active for simple mode task assignment.",
+ });
+ }
+ if (!(await assignmentTargetIsInCompany(input.resource))) {
+ return deny({
+ action: input.action,
+ reason: "deny_company_boundary",
+ explanation: "Task assignment target agent is not active in the target company.",
+ });
+ }
+ const policyEffect = await assignmentPolicyEffect(input.resource);
+ const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
+ if (policyDeny) return policyDeny;
+ if (policyEffect.kind === "restricted") {
+ const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId);
+ if (grantDecision.allowed) return grantDecision;
+ return denyRestrictedAssignmentPolicy(policyEffect);
+ }
+ return allow({
+ action: input.action,
+ reason: "allow_simple_company_member",
+ explanation: "Allowed by simple mode company-wide task assignment default.",
+ });
+ }
+
+ if (input.action === "issue:mutate") {
+ const resource = input.resource.type === "issue" ? input.resource : null;
+ if (resource?.assigneeAgentId === actorAgentId) {
+ return allow({
+ action: input.action,
+ reason: "allow_self",
+ explanation: "Allowed because the actor owns the assigned issue.",
+ });
+ }
+ if (!resource?.assigneeAgentId) {
+ return allow({
+ action: input.action,
+ reason: "allow_company_agent",
+ explanation: "Allowed because the issue has no agent assignee.",
+ });
+ }
+ }
+ if (
+ input.action === "agent_config:update" &&
+ input.resource.type === "agent" &&
+ input.resource.agentId === actorAgentId
+ ) {
+ return allow({
+ action: input.action,
+ reason: "allow_self",
+ explanation: "Allowed because the actor is updating its own agent configuration.",
+ });
+ }
+
+ if (permissionKey) {
+ const grantDecision = await decidePrincipalGrant({
+ companyId,
+ principalType: "agent",
+ principalId: actorAgentId,
+ action: input.action,
+ permissionKey,
+ scope: input.scope,
+ });
+ if (grantDecision.allowed) return grantDecision;
+ }
+
+ if (
+ (input.action === "agents:create" ||
+ input.action === "agent_config:read" ||
+ input.action === "agent_config:update" ||
+ input.action === "tasks:manage_active_checkouts") &&
+ canCreateAgentsLegacy(actorAgent)
+ ) {
+ return allow({
+ action: input.action,
+ reason: "allow_legacy_agent_creator",
+ explanation: "Allowed by legacy agent creator authority.",
+ });
+ }
+
+ if (
+ input.action === "tasks:manage_active_checkouts" &&
+ input.resource.type === "issue" &&
+ input.resource.assigneeAgentId &&
+ await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId)
+ ) {
+ return allow({
+ action: input.action,
+ reason: "allow_manager_chain",
+ explanation: "Allowed because the actor manages the issue assignee in the reporting chain.",
+ });
+ }
+
+ return deny({
+ action: input.action,
+ reason: "deny_missing_grant",
+ explanation: permissionKey
+ ? `Missing permission: ${permissionKey}.`
+ : `No agent permission mapping exists for ${input.action}.`,
+ });
+ }
+
+ return {
+ decide,
+ decidePrincipalGrant,
+ };
+}
diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts
index 6e8513ee451..593770ca42e 100644
--- a/server/src/services/company-member-roles.ts
+++ b/server/src/services/company-member-roles.ts
@@ -28,6 +28,7 @@ export function grantsForHumanRole(
case "owner":
return [
{ permissionKey: "agents:create", scope: null },
+ { permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
@@ -36,6 +37,7 @@ export function grantsForHumanRole(
case "admin":
return [
{ permissionKey: "agents:create", scope: null },
+ { permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts
index b1a4ede3969..1b9dfc85794 100644
--- a/server/src/services/company-portability.ts
+++ b/server/src/services/company-portability.ts
@@ -4118,7 +4118,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (mode === "agent_safe" && options?.sourceCompanyId) {
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
} else {
- await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active");
+ const ownerPrincipalId = actorUserId ?? "board";
+ await access.ensureMembership(created.id, "user", ownerPrincipalId, "owner", "active");
+ await access.ensureRoleDefaultGrants(
+ created.id,
+ ownerPrincipalId,
+ "owner",
+ actorUserId ?? null,
+ );
}
targetCompany = created;
companyAction = "created";
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index 2e7c6bcdaf5..a8570d2d27f 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -44,6 +44,19 @@ export { sidebarBadgeService } from "./sidebar-badges.js";
export { sidebarPreferenceService } from "./sidebar-preferences.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js";
+export {
+ backfillPrincipalAccessCompatibility,
+ ensureHumanRoleDefaultGrants,
+ insertMissingPrincipalGrants,
+ type PrincipalAccessCompatibilityBackfillStats,
+} from "./principal-access-compatibility.js";
+export { authorizationService } from "./authorization.js";
+export type {
+ AuthorizationAction,
+ AuthorizationActor,
+ AuthorizationDecision,
+ AuthorizationResource,
+} from "./authorization.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js";
diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts
index e400ab88ca9..f8b47866c35 100644
--- a/server/src/services/plugin-capability-validator.ts
+++ b/server/src/services/plugin-capability-validator.ts
@@ -149,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record = {
commentAnnotation: "ui.commentAnnotation.register",
commentContextMenuItem: "ui.action.register",
settingsPage: "instance.settings.register",
+ companySettingsPage: "instance.settings.register",
routeSidebar: "ui.sidebar.register",
};
diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts
index fef165dc1a8..7c2a9156267 100644
--- a/server/src/services/plugin-host-services.ts
+++ b/server/src/services/plugin-host-services.ts
@@ -1,14 +1,18 @@
import type { Db } from "@paperclipai/db";
import {
+ activityLog,
agentTaskSessions as agentTaskSessionsTable,
agents as agentsTable,
budgetIncidents,
costEvents,
heartbeatRuns,
+ invites,
issues as issuesTable,
pluginLogs,
+ principalPermissionGrants,
+ projects as projectsTable,
} from "@paperclipai/db";
-import { eq, and, like, desc, inArray, sql } from "drizzle-orm";
+import { eq, and, like, desc, inArray, sql, isNull, isNotNull, gt, lte } from "drizzle-orm";
import type {
HostServices,
Company,
@@ -22,7 +26,7 @@ import type {
PluginIssueOrchestrationSummary,
PluginExecutionWorkspaceMetadata,
} from "@paperclipai/plugin-sdk";
-import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
+import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared";
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import { companyService } from "./companies.js";
import { agentService } from "./agents.js";
@@ -36,11 +40,8 @@ import { heartbeatService } from "./heartbeat.js";
import { budgetService } from "./budgets.js";
import { issueApprovalService } from "./issue-approvals.js";
import { subscribeCompanyLiveEvents } from "./live-events.js";
-import { randomUUID } from "node:crypto";
+import { createHash, randomBytes, randomUUID } from "node:crypto";
import path from "node:path";
-import { activityService } from "./activity.js";
-import { costService } from "./costs.js";
-import { assetService } from "./assets.js";
import { pluginRegistryService } from "./plugin-registry.js";
import { pluginStateStore } from "./plugin-state-store.js";
import { pluginDatabaseService } from "./plugin-database.js";
@@ -71,6 +72,9 @@ import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
+import { accessService } from "./access.js";
+import { authorizationService, type AuthorizationActor } from "./authorization.js";
+import { sanitizeRecord } from "../redaction.js";
// ---------------------------------------------------------------------------
// SSRF protection for plugin HTTP fetch
@@ -526,11 +530,10 @@ export function buildHostServices(
const issues = issueService(db);
const documents = documentService(db);
const goals = goalService(db);
- const activity = activityService(db);
- const costs = costService(db);
+ const access = accessService(db);
+ const authorization = authorizationService(db);
const budgets = budgetService(db);
const issueApprovals = issueApprovalService(db);
- const assets = assetService(db);
const scopedBus = eventBus.forPlugin(pluginKey);
// Track active session event subscriptions for cleanup
@@ -562,6 +565,17 @@ export function buildHostServices(
return rows.slice(offset, offset + limit);
};
+ const authorizationAuditDecisionCondition = (decisionFilter: string) => {
+ const conditions = [
+ sql`lower(${activityLog.details}->>'decision') = ${decisionFilter}`,
+ decisionFilter === "allow" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 6) = 'allow_'` : undefined,
+ decisionFilter === "deny" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 5) = 'deny_'` : undefined,
+ decisionFilter === "allow" ? sql`${activityLog.details}->>'allowed' = 'true'` : undefined,
+ decisionFilter === "deny" ? sql`${activityLog.details}->>'allowed' = 'false'` : undefined,
+ ].filter((condition): condition is NonNullable => Boolean(condition));
+ return sql`(${sql.join(conditions, sql` OR `)})`;
+ };
+
/**
* Plugins are instance-wide in the current runtime. Company IDs are still
* required for company-scoped data access, but there is no per-company
@@ -841,6 +855,202 @@ export function buildHostServices(
}));
};
+ const INVITE_TOKEN_PREFIX = "pcp_invite_";
+ const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
+ const INVITE_TOKEN_SUFFIX_LENGTH = 8;
+ const INVITE_TOKEN_MAX_RETRIES = 5;
+ const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000;
+
+ const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
+
+ const createInviteToken = () => {
+ const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
+ let suffix = "";
+ for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
+ suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length];
+ }
+ return `${INVITE_TOKEN_PREFIX}${suffix}`;
+ };
+
+ const isInviteTokenHashCollisionError = (error: unknown) => {
+ const candidates = [
+ error,
+ (error as { cause?: unknown } | null)?.cause ?? null,
+ ];
+ for (const candidate of candidates) {
+ if (!candidate || typeof candidate !== "object") continue;
+ const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null;
+ const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : "";
+ const constraint = "constraint" in candidate && typeof candidate.constraint === "string" ? candidate.constraint : null;
+ if (code !== "23505") continue;
+ if (constraint === "invites_token_hash_unique_idx") return true;
+ if (message.includes("invites_token_hash_unique_idx")) return true;
+ }
+ return false;
+ };
+
+ const inviteState = (invite: typeof invites.$inferSelect) => {
+ if (invite.revokedAt) return "revoked" as const;
+ if (invite.acceptedAt) return "accepted" as const;
+ if (invite.expiresAt <= new Date()) return "expired" as const;
+ return "active" as const;
+ };
+
+ const redactInvite = (invite: typeof invites.$inferSelect) => {
+ const { tokenHash: _tokenHash, defaultsPayload, ...safeInvite } = invite;
+ return {
+ ...safeInvite,
+ allowedJoinTypes: safeInvite.allowedJoinTypes as InviteJoinType,
+ defaultsPayload: defaultsPayload && typeof defaultsPayload === "object"
+ ? sanitizeRecord(defaultsPayload)
+ : defaultsPayload ?? null,
+ state: inviteState(invite),
+ };
+ };
+
+ const inviteStateWhereClause = (state: unknown) => {
+ const now = new Date();
+ switch (state) {
+ case "active":
+ return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), gt(invites.expiresAt, now));
+ case "accepted":
+ return isNotNull(invites.acceptedAt);
+ case "expired":
+ return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), lte(invites.expiresAt, now));
+ case "revoked":
+ return isNotNull(invites.revokedAt);
+ default:
+ return undefined;
+ }
+ };
+
+ const mergeInviteDefaults = (defaultsPayload: Record | null | undefined, agentMessage: string | null, humanRole: string | null) => {
+ const defaults = defaultsPayload && typeof defaultsPayload === "object"
+ ? { ...defaultsPayload }
+ : {};
+ if (humanRole) {
+ defaults.human = {
+ ...(typeof defaults.human === "object" && defaults.human !== null ? defaults.human as Record : {}),
+ role: humanRole,
+ };
+ }
+ if (agentMessage) {
+ defaults.agent = {
+ ...(typeof defaults.agent === "object" && defaults.agent !== null ? defaults.agent as Record : {}),
+ message: agentMessage,
+ };
+ }
+ return sanitizeRecord(defaults);
+ };
+
+ const redactGrant = (grant: typeof principalPermissionGrants.$inferSelect) => ({
+ ...grant,
+ principalType: grant.principalType as PrincipalType,
+ permissionKey: grant.permissionKey as PermissionKey,
+ scope: grant.scope && typeof grant.scope === "object" ? sanitizeRecord(grant.scope) : grant.scope ?? null,
+ });
+
+ const loadPluginMember = async (companyId: string, memberId: string) => {
+ const member = await access.getMemberById(companyId, memberId);
+ if (!member) return null;
+ const grants = await access.listPrincipalGrants(
+ companyId,
+ member.principalType as PrincipalType,
+ member.principalId,
+ );
+ return {
+ ...member,
+ principalType: member.principalType as PrincipalType,
+ status: member.status as "pending" | "active" | "suspended" | "archived",
+ grants: grants.map(redactGrant),
+ };
+ };
+
+ const pluginAssignmentActor = (actor: {
+ type: "agent" | "board";
+ agentId?: string | null;
+ companyId?: string | null;
+ userId?: string | null;
+ companyIds?: string[];
+ }): AuthorizationActor => {
+ if (actor.type === "agent") {
+ return {
+ type: "agent",
+ agentId: actor.agentId ?? null,
+ companyId: actor.companyId ?? null,
+ source: "agent_key",
+ };
+ }
+ return {
+ type: "board",
+ userId: actor.userId ?? null,
+ companyIds: Array.isArray(actor.companyIds) ? actor.companyIds : [],
+ source: "session",
+ };
+ };
+
+ const policyPathForResource = (resourceType: "company" | "agent" | "project" | "issue") => {
+ switch (resourceType) {
+ case "agent":
+ return { table: "agent" as const };
+ case "project":
+ return { table: "project" as const };
+ case "issue":
+ return { table: "issue" as const };
+ case "company":
+ return { table: "company" as const };
+ }
+ };
+
+ const readAuthorizationPolicy = async (companyId: string, resourceType: "company" | "agent" | "project" | "issue", resourceId: string) => {
+ const pathInfo = policyPathForResource(resourceType);
+ if (pathInfo.table === "agent") {
+ const agent = await agents.getById(resourceId);
+ if (!inCompany(agent, companyId)) return null;
+ const permissions = agent.permissions && typeof agent.permissions === "object" ? agent.permissions as Record : {};
+ return {
+ resourceType,
+ resourceId,
+ companyId,
+ policy: permissions.authorizationPolicy && typeof permissions.authorizationPolicy === "object"
+ ? sanitizeRecord(permissions.authorizationPolicy as Record)
+ : null,
+ updatedAt: agent.updatedAt,
+ };
+ }
+ if (pathInfo.table === "project") {
+ const project = await projects.getById(resourceId);
+ if (!inCompany(project, companyId)) return null;
+ const policy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object"
+ ? (project.executionWorkspacePolicy as unknown as Record).authorizationPolicy
+ : null;
+ return {
+ resourceType,
+ resourceId,
+ companyId,
+ policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null,
+ updatedAt: project.updatedAt,
+ };
+ }
+ if (pathInfo.table === "issue") {
+ const issue = await issues.getById(resourceId);
+ if (!inCompany(issue, companyId)) return null;
+ const policy = issue.executionPolicy && typeof issue.executionPolicy === "object"
+ ? (issue.executionPolicy as Record).authorizationPolicy
+ : null;
+ return {
+ resourceType,
+ resourceId,
+ companyId,
+ policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null,
+ updatedAt: issue.updatedAt,
+ };
+ }
+ const company = await companies.getById(resourceId);
+ if (!company || company.id !== companyId) return null;
+ return { resourceType, resourceId, companyId, policy: null, updatedAt: company.updatedAt };
+ };
+
return {
config: {
async get() {
@@ -1993,6 +2203,337 @@ export function buildHostServices(
},
},
+ access: {
+ async listMembers(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const rows = await access.listMembers(companyId);
+ const visibleRows = params.includeArchived ? rows : rows.filter((row) => row.status !== "archived");
+ const grants = await db
+ .select()
+ .from(principalPermissionGrants)
+ .where(eq(principalPermissionGrants.companyId, companyId));
+ const grantsByPrincipal = new Map();
+ for (const grant of grants) {
+ const key = `${grant.principalType}:${grant.principalId}`;
+ const existing = grantsByPrincipal.get(key) ?? [];
+ existing.push(grant);
+ grantsByPrincipal.set(key, existing);
+ }
+ return visibleRows.map((member) => ({
+ ...member,
+ principalType: member.principalType as PrincipalType,
+ status: member.status as "pending" | "active" | "suspended" | "archived",
+ grants: (grantsByPrincipal.get(`${member.principalType}:${member.principalId}`) ?? []).map(redactGrant),
+ }));
+ },
+ async getMember(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ return loadPluginMember(companyId, params.memberId);
+ },
+ async updateMember(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const updated = await access.updateMember(companyId, params.memberId, params.patch);
+ if (!updated) throw new Error("Member not found");
+ await logPluginActivity({
+ companyId,
+ action: "company_member.updated_by_plugin",
+ entityType: "company_membership",
+ entityId: params.memberId,
+ details: {
+ patch: sanitizeRecord(params.patch as Record),
+ },
+ });
+ return (await loadPluginMember(companyId, params.memberId))!;
+ },
+ async listInvites(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const limit = Math.min(Math.max(Number(params.limit ?? 20), 1), 100);
+ const offset = Math.max(Number(params.offset ?? 0), 0);
+ const stateClause = inviteStateWhereClause(params.state);
+ const rows = await db
+ .select()
+ .from(invites)
+ .where(stateClause ? and(eq(invites.companyId, companyId), stateClause) : eq(invites.companyId, companyId))
+ .orderBy(desc(invites.createdAt))
+ .limit(limit + 1)
+ .offset(offset);
+ const hasMore = rows.length > limit;
+ return {
+ invites: rows.slice(0, limit).map(redactInvite),
+ nextOffset: hasMore ? offset + limit : null,
+ };
+ },
+ async createInvite(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const normalizedAgentMessage = typeof params.agentMessage === "string"
+ ? params.agentMessage.trim() || null
+ : null;
+ const allowedJoinTypes = params.allowedJoinTypes ?? "both";
+ const humanRole = allowedJoinTypes === "agent" ? null : params.humanRole ?? "operator";
+ const insertValues = {
+ companyId,
+ inviteType: "company_join" as const,
+ allowedJoinTypes,
+ defaultsPayload: mergeInviteDefaults(params.defaultsPayload ?? null, normalizedAgentMessage, humanRole),
+ expiresAt: new Date(Date.now() + COMPANY_INVITE_TTL_MS),
+ invitedByUserId: null,
+ };
+ let token: string | null = null;
+ let created: typeof invites.$inferSelect | null = null;
+ for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
+ const candidateToken = createInviteToken();
+ try {
+ created = await db
+ .insert(invites)
+ .values({
+ ...insertValues,
+ tokenHash: hashToken(candidateToken),
+ })
+ .returning()
+ .then((rows) => rows[0] ?? null);
+ token = candidateToken;
+ break;
+ } catch (error) {
+ if (!isInviteTokenHashCollisionError(error)) throw error;
+ }
+ }
+ if (!token || !created) throw new Error("Failed to generate a unique invite token");
+ await logPluginActivity({
+ companyId,
+ action: "invite.created_by_plugin",
+ entityType: "invite",
+ entityId: created.id,
+ details: {
+ allowedJoinTypes: created.allowedJoinTypes,
+ expiresAt: created.expiresAt.toISOString(),
+ hasAgentMessage: Boolean(normalizedAgentMessage),
+ },
+ });
+ return { ...redactInvite(created), token };
+ },
+ async revokeInvite(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const invite = await db
+ .select()
+ .from(invites)
+ .where(and(eq(invites.id, params.inviteId), eq(invites.companyId, companyId)))
+ .then((rows) => rows[0] ?? null);
+ if (!invite) throw new Error("Invite not found");
+ if (invite.acceptedAt) throw new Error("Invite already consumed");
+ if (invite.revokedAt) return redactInvite(invite);
+ const revoked = await db
+ .update(invites)
+ .set({ revokedAt: new Date(), updatedAt: new Date() })
+ .where(eq(invites.id, invite.id))
+ .returning()
+ .then((rows) => rows[0] ?? invite);
+ await logPluginActivity({
+ companyId,
+ action: "invite.revoked_by_plugin",
+ entityType: "invite",
+ entityId: invite.id,
+ });
+ return redactInvite(revoked);
+ },
+ },
+
+ authorization: {
+ async listGrants(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const conditions = [
+ eq(principalPermissionGrants.companyId, companyId),
+ params.principalType ? eq(principalPermissionGrants.principalType, params.principalType) : undefined,
+ params.principalId ? eq(principalPermissionGrants.principalId, params.principalId) : undefined,
+ ].filter((condition): condition is NonNullable => Boolean(condition));
+ const rows = await db
+ .select()
+ .from(principalPermissionGrants)
+ .where(and(...conditions))
+ .orderBy(principalPermissionGrants.principalType, principalPermissionGrants.principalId, principalPermissionGrants.permissionKey);
+ return rows.map(redactGrant);
+ },
+ async setGrants(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ if (params.principalType !== "agent" && params.principalType !== "user") {
+ throw new Error("principalType must be 'agent' or 'user'");
+ }
+ if (params.principalType === "agent") {
+ requireInCompany("Agent", await agents.getById(params.principalId), companyId);
+ } else {
+ const membership = await access.getMembership(companyId, params.principalType as PrincipalType, params.principalId);
+ if (!membership) throw new Error("Principal is not a member of this company");
+ }
+ await access.setPrincipalGrants(
+ companyId,
+ params.principalType as PrincipalType,
+ params.principalId,
+ params.grants.map((grant) => ({
+ permissionKey: grant.permissionKey as PermissionKey,
+ scope: grant.scope ? sanitizeRecord(grant.scope) : null,
+ })),
+ params.grantedByUserId ?? null,
+ );
+ await logPluginActivity({
+ companyId,
+ action: "authorization.grants_updated_by_plugin",
+ entityType: "principal_permission_grants",
+ entityId: `${params.principalType}:${params.principalId}`,
+ details: { grantCount: params.grants.length },
+ });
+ return access
+ .listPrincipalGrants(companyId, params.principalType as PrincipalType, params.principalId)
+ .then((rows) => rows.map(redactGrant));
+ },
+ async policySummary(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const [members, grants] = await Promise.all([
+ access.listMembers(companyId),
+ db
+ .select({ id: principalPermissionGrants.id })
+ .from(principalPermissionGrants)
+ .where(eq(principalPermissionGrants.companyId, companyId)),
+ ]);
+ return {
+ companyId,
+ permissionsMode: "simple" as const,
+ memberCount: members.length,
+ activeMemberCount: members.filter((member) => member.status === "active").length,
+ grantCount: grants.length,
+ advancedPolicyAvailable: false as const,
+ };
+ },
+ async getPolicy(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ return readAuthorizationPolicy(companyId, params.resourceType, params.resourceId);
+ },
+ async updatePolicy(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const policy = params.policy ? sanitizeRecord(params.policy) : null;
+ if (params.resourceType === "agent") {
+ const agent = requireInCompany("Agent", await agents.getById(params.resourceId), companyId);
+ const permissions = agent.permissions && typeof agent.permissions === "object"
+ ? { ...(agent.permissions as Record) }
+ : {};
+ if (policy) permissions.authorizationPolicy = policy;
+ else delete permissions.authorizationPolicy;
+ await db
+ .update(agentsTable)
+ .set({ permissions, updatedAt: new Date() })
+ .where(eq(agentsTable.id, agent.id));
+ } else if (params.resourceType === "project") {
+ const project = requireInCompany("Project", await projects.getById(params.resourceId), companyId);
+ const executionWorkspacePolicy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object"
+ ? { ...(project.executionWorkspacePolicy as unknown as Record) }
+ : {};
+ if (policy) executionWorkspacePolicy.authorizationPolicy = policy;
+ else delete executionWorkspacePolicy.authorizationPolicy;
+ await db
+ .update(projectsTable)
+ .set({ executionWorkspacePolicy, updatedAt: new Date() })
+ .where(eq(projectsTable.id, project.id));
+ } else if (params.resourceType === "issue") {
+ const issue = requireInCompany("Issue", await issues.getById(params.resourceId), companyId);
+ const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object"
+ ? { ...(issue.executionPolicy as Record) }
+ : {};
+ if (policy) executionPolicy.authorizationPolicy = policy;
+ else delete executionPolicy.authorizationPolicy;
+ await db
+ .update(issuesTable)
+ .set({ executionPolicy, updatedAt: new Date() })
+ .where(eq(issuesTable.id, issue.id));
+ } else {
+ const company = await companies.getById(params.resourceId);
+ if (!company || company.id !== companyId) throw new Error("Company not found");
+ throw new Error("Company authorization policy updates are not supported by the current core schema");
+ }
+ await logPluginActivity({
+ companyId,
+ action: "authorization.policy_updated_by_plugin",
+ entityType: params.resourceType,
+ entityId: params.resourceId,
+ details: { hasPolicy: Boolean(policy) },
+ });
+ const updated = await readAuthorizationPolicy(companyId, params.resourceType, params.resourceId);
+ if (!updated) throw new Error("Policy resource not found");
+ return updated;
+ },
+ async previewAssignment(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ return authorization.decide({
+ actor: pluginAssignmentActor(params.actor),
+ action: "tasks:assign",
+ resource: { type: "issue", companyId, ...params.target },
+ scope: {
+ issueId: params.target.issueId ?? null,
+ projectId: params.target.projectId ?? null,
+ parentIssueId: params.target.parentIssueId ?? null,
+ assigneeAgentId: params.target.assigneeAgentId ?? null,
+ assigneeUserId: params.target.assigneeUserId ?? null,
+ },
+ });
+ },
+ async explainAssignment(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ return authorization.decide({
+ actor: pluginAssignmentActor(params.actor),
+ action: "tasks:assign",
+ resource: { type: "issue", companyId, ...params.target },
+ scope: {
+ issueId: params.target.issueId ?? null,
+ projectId: params.target.projectId ?? null,
+ parentIssueId: params.target.parentIssueId ?? null,
+ assigneeAgentId: params.target.assigneeAgentId ?? null,
+ assigneeUserId: params.target.assigneeUserId ?? null,
+ },
+ });
+ },
+ async searchAudit(params) {
+ const companyId = ensureCompanyId(params.companyId);
+ await ensurePluginAvailableForCompany(companyId);
+ const limit = Math.min(Math.max(Number(params.limit ?? 50), 1), 100);
+ const offset = Math.max(Number(params.offset ?? 0), 0);
+ const decisionFilter = typeof params.decision === "string" && params.decision.trim()
+ ? params.decision.trim().toLowerCase()
+ : null;
+ const conditions = [
+ eq(activityLog.companyId, companyId),
+ params.action ? eq(activityLog.action, params.action) : undefined,
+ params.actorType ? eq(activityLog.actorType, params.actorType) : undefined,
+ params.actorId ? eq(activityLog.actorId, params.actorId) : undefined,
+ params.entityType ? eq(activityLog.entityType, params.entityType) : undefined,
+ params.entityId ? eq(activityLog.entityId, params.entityId) : undefined,
+ decisionFilter ? authorizationAuditDecisionCondition(decisionFilter) : undefined,
+ ].filter((condition): condition is NonNullable => Boolean(condition));
+ const rows = await db
+ .select()
+ .from(activityLog)
+ .where(and(...conditions))
+ .orderBy(desc(activityLog.createdAt))
+ .limit(limit)
+ .offset(offset);
+ return rows.map((row) => ({
+ ...row,
+ details: row.details && typeof row.details === "object"
+ ? sanitizeRecord(row.details)
+ : row.details ?? null,
+ }));
+ },
+ },
+
agentSessions: {
async create(params) {
const companyId = ensureCompanyId(params.companyId);
diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts
index 6e1d7689be2..81f21a91f85 100644
--- a/server/src/services/plugin-worker-manager.ts
+++ b/server/src/services/plugin-worker-manager.ts
@@ -19,6 +19,7 @@
*/
import { fork, type ChildProcess } from "node:child_process";
+import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
@@ -39,9 +40,12 @@ import {
} from "@paperclipai/plugin-sdk";
import type {
JsonRpcId,
+ PluginInvocationContext,
+ PluginInvocationScope,
JsonRpcResponse,
JsonRpcRequest,
JsonRpcNotification,
+ WorkerHostCallContext,
HostToWorkerMethodName,
HostToWorkerMethods,
WorkerToHostMethodName,
@@ -108,6 +112,7 @@ export type WorkerStatus =
*/
export type WorkerToHostHandler = (
params: WorkerToHostMethods[M][0],
+ context?: WorkerHostCallContext,
) => Promise;
/**
@@ -201,6 +206,11 @@ interface PendingRequest {
sentAt: number;
}
+interface ActiveInvocation {
+ scope: PluginInvocationScope;
+ timer?: ReturnType;
+}
+
// ---------------------------------------------------------------------------
// PluginWorkerHandle — manages a single worker process
// ---------------------------------------------------------------------------
@@ -379,6 +389,7 @@ export function createPluginWorkerHandle(
// Pending RPC requests awaiting a response
const pendingRequests = new Map();
let nextRequestId = 1;
+ const activeInvocations = new Map();
// Optional methods reported by the worker during initialization
let supportedMethods: string[] = [];
@@ -475,13 +486,78 @@ export function createPluginWorkerHandle(
pending.resolve(response);
}
+ function readNonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+ }
+
+ function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+ }
+
+ function deriveInvocationScope(
+ method: HostToWorkerMethodName | string,
+ params: unknown,
+ ): PluginInvocationScope | null {
+ if (!isRecord(params)) return null;
+
+ const directCompanyId = readNonEmptyString(params.companyId);
+ if (directCompanyId) return { companyId: directCompanyId };
+
+ if (method === "executeTool" && isRecord(params.runContext)) {
+ const companyId = readNonEmptyString(params.runContext.companyId);
+ return companyId ? { companyId } : null;
+ }
+
+ if (method === "onEvent" && isRecord(params.event)) {
+ const companyId = readNonEmptyString(params.event.companyId);
+ return companyId ? { companyId } : null;
+ }
+
+ return null;
+ }
+
+ function registerInvocation(scope: PluginInvocationScope, ttlMs?: number): PluginInvocationContext {
+ const invocation: PluginInvocationContext = {
+ id: randomUUID(),
+ scope,
+ };
+ const entry: ActiveInvocation = { scope };
+ if (ttlMs !== undefined) {
+ entry.timer = setTimeout(() => {
+ activeInvocations.delete(invocation.id);
+ }, ttlMs);
+ if (entry.timer.unref) entry.timer.unref();
+ }
+ activeInvocations.set(invocation.id, entry);
+ return invocation;
+ }
+
+ function clearInvocation(invocation: PluginInvocationContext | null): void {
+ if (!invocation) return;
+ const entry = activeInvocations.get(invocation.id);
+ if (entry?.timer) clearTimeout(entry.timer);
+ activeInvocations.delete(invocation.id);
+ }
+
+ function contextForWorkerMessage(message: JsonRpcRequest | JsonRpcNotification): WorkerHostCallContext {
+ const invocationId = readNonEmptyString(
+ (message as { paperclipInvocationId?: unknown }).paperclipInvocationId,
+ );
+ if (!invocationId) {
+ return activeInvocations.size > 0 ? { invalidInvocationScope: true } : {};
+ }
+ const entry = activeInvocations.get(invocationId);
+ if (!entry) return { invalidInvocationScope: true };
+ return { invocationScope: entry.scope };
+ }
+
/**
* Handle a JSON-RPC request from the worker (worker→host call).
*/
async function handleWorkerRequest(request: JsonRpcRequest): Promise {
const method = request.method as WorkerToHostMethodName;
const handler = options.hostHandlers[method] as
- | ((params: unknown) => Promise)
+ | ((params: unknown, context?: WorkerHostCallContext) => Promise)
| undefined;
if (!handler) {
@@ -501,7 +577,7 @@ export function createPluginWorkerHandle(
}
try {
- const result = await handler(request.params);
+ const result = await handler(request.params, contextForWorkerMessage(request));
sendMessage({
jsonrpc: JSONRPC_VERSION,
id: request.id,
@@ -509,12 +585,15 @@ export function createPluginWorkerHandle(
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
+ const errorCode = typeof (err as { code?: unknown }).code === "number"
+ ? (err as { code: number }).code
+ : JSONRPC_ERROR_CODES.INTERNAL_ERROR;
log.error({ method, err: errorMessage }, "host handler error");
try {
sendMessage(
createErrorResponse(
request.id,
- JSONRPC_ERROR_CODES.INTERNAL_ERROR,
+ errorCode,
errorMessage,
),
);
@@ -572,12 +651,28 @@ export function createPluginWorkerHandle(
notification.method === "streams.close"
) {
const params = (notification.params ?? {}) as Record;
+ const companyId = String(params.companyId ?? "");
+ const context = contextForWorkerMessage(notification);
+ if (context.invalidInvocationScope) {
+ log.warn(
+ { method: notification.method, companyId },
+ "dropping plugin stream notification with invalid invocation scope",
+ );
+ return;
+ }
+ const allowedCompanyId = context.invocationScope?.companyId;
+ if (allowedCompanyId && companyId !== allowedCompanyId) {
+ log.warn(
+ { method: notification.method, companyId, allowedCompanyId },
+ "dropping plugin stream notification outside invocation company scope",
+ );
+ return;
+ }
// Track open channels so we can emit synthetic close on crash
if (notification.method === "streams.open") {
const ch = String(params.channel ?? "");
- const co = String(params.companyId ?? "");
- if (ch) openStreamChannels.set(ch, co);
+ if (ch) openStreamChannels.set(ch, companyId);
} else if (notification.method === "streams.close") {
openStreamChannels.delete(String(params.channel ?? ""));
}
@@ -760,6 +855,10 @@ export function createPluginWorkerHandle(
);
}
pendingRequests.clear();
+ for (const invocation of activeInvocations.values()) {
+ if (invocation.timer) clearTimeout(invocation.timer);
+ }
+ activeInvocations.clear();
}
// -----------------------------------------------------------------------
@@ -1020,6 +1119,8 @@ export function createPluginWorkerHandle(
const id = nextRequestId++;
const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS);
+ const invocationScope = deriveInvocationScope(method, params);
+ const invocation = invocationScope ? registerInvocation(invocationScope) : null;
// Guard against double-settlement. When a process exits all pending
// requests are rejected via rejectAllPending(), but the timeout timer
@@ -1032,6 +1133,7 @@ export function createPluginWorkerHandle(
settled = true;
clearTimeout(timer);
pendingRequests.delete(id);
+ clearInvocation(invocation);
fn(value);
};
@@ -1064,11 +1166,15 @@ export function createPluginWorkerHandle(
pendingRequests.set(id, pending);
try {
- const request = createRequest(method, params, id);
+ const request = {
+ ...createRequest(method, params, id),
+ ...(invocation ? { paperclipInvocation: invocation } : {}),
+ };
sendMessage(request);
} catch (err) {
clearTimeout(timer);
pendingRequests.delete(id);
+ clearInvocation(invocation);
reject(
new Error(
`Failed to send "${method}" to worker: ${
@@ -1135,13 +1241,17 @@ export function createPluginWorkerHandle(
notify(method: string, params: unknown) {
if (status !== "running") return;
+ const invocationScope = deriveInvocationScope(method, params);
+ const invocation = invocationScope ? registerInvocation(invocationScope, MAX_RPC_TIMEOUT_MS) : null;
try {
sendMessage({
jsonrpc: JSONRPC_VERSION,
method,
params,
+ ...(invocation ? { paperclipInvocation: invocation } : {}),
});
} catch {
+ clearInvocation(invocation);
log.warn({ method }, "failed to send notification to worker");
}
},
diff --git a/server/src/services/principal-access-compatibility.ts b/server/src/services/principal-access-compatibility.ts
new file mode 100644
index 00000000000..cb1e86be82f
--- /dev/null
+++ b/server/src/services/principal-access-compatibility.ts
@@ -0,0 +1,141 @@
+import { and, eq, notInArray } from "drizzle-orm";
+import type { Db } from "@paperclipai/db";
+import { agents, companyMemberships, principalPermissionGrants } from "@paperclipai/db";
+import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
+import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
+
+type GrantInput = {
+ permissionKey: PermissionKey;
+ scope?: Record | null;
+};
+
+export type PrincipalAccessCompatibilityBackfillStats = {
+ agentMembershipsInserted: number;
+ humanGrantsInserted: number;
+};
+
+export async function insertMissingPrincipalGrants(
+ db: Db,
+ input: {
+ companyId: string;
+ principalType: PrincipalType;
+ principalId: string;
+ grants: GrantInput[];
+ grantedByUserId: string | null;
+ },
+): Promise {
+ if (input.grants.length === 0) return 0;
+
+ const now = new Date();
+ const inserted = await db
+ .insert(principalPermissionGrants)
+ .values(
+ input.grants.map((grant) => ({
+ companyId: input.companyId,
+ principalType: input.principalType,
+ principalId: input.principalId,
+ permissionKey: grant.permissionKey,
+ scope: grant.scope ?? null,
+ grantedByUserId: input.grantedByUserId,
+ createdAt: now,
+ updatedAt: now,
+ })),
+ )
+ .onConflictDoNothing({
+ target: [
+ principalPermissionGrants.companyId,
+ principalPermissionGrants.principalType,
+ principalPermissionGrants.principalId,
+ principalPermissionGrants.permissionKey,
+ ],
+ })
+ .returning({ id: principalPermissionGrants.id });
+
+ return inserted.length;
+}
+
+export async function ensureHumanRoleDefaultGrants(
+ db: Db,
+ input: {
+ companyId: string;
+ principalId: string;
+ membershipRole: string | null | undefined;
+ grantedByUserId: string | null;
+ },
+): Promise {
+ const role = normalizeHumanRole(input.membershipRole, "operator");
+ return insertMissingPrincipalGrants(db, {
+ companyId: input.companyId,
+ principalType: "user",
+ principalId: input.principalId,
+ grants: grantsForHumanRole(role),
+ grantedByUserId: input.grantedByUserId,
+ });
+}
+
+export async function backfillPrincipalAccessCompatibility(
+ db: Db,
+): Promise {
+ const now = new Date();
+ const nonTerminalAgents = await db
+ .select({
+ companyId: agents.companyId,
+ principalId: agents.id,
+ })
+ .from(agents)
+ .where(notInArray(agents.status, ["pending_approval", "terminated"]));
+
+ const agentMembershipsInserted = nonTerminalAgents.length > 0
+ ? await db
+ .insert(companyMemberships)
+ .values(
+ nonTerminalAgents.map((agent) => ({
+ companyId: agent.companyId,
+ principalType: "agent",
+ principalId: agent.principalId,
+ status: "active",
+ membershipRole: "member",
+ createdAt: now,
+ updatedAt: now,
+ })),
+ )
+ .onConflictDoNothing({
+ target: [
+ companyMemberships.companyId,
+ companyMemberships.principalType,
+ companyMemberships.principalId,
+ ],
+ })
+ .returning({ id: companyMemberships.id })
+ .then((rows) => rows.length)
+ : 0;
+
+ const activeHumanMemberships = await db
+ .select({
+ companyId: companyMemberships.companyId,
+ principalId: companyMemberships.principalId,
+ membershipRole: companyMemberships.membershipRole,
+ })
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.status, "active"),
+ ),
+ );
+
+ let humanGrantsInserted = 0;
+ for (const membership of activeHumanMemberships) {
+ humanGrantsInserted += await ensureHumanRoleDefaultGrants(db, {
+ companyId: membership.companyId,
+ principalId: membership.principalId,
+ membershipRole: membership.membershipRole,
+ grantedByUserId: null,
+ });
+ }
+
+ return {
+ agentMembershipsInserted,
+ humanGrantsInserted,
+ };
+}
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 0da91b9a183..abc17fc2487 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -30,7 +30,8 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
-import { CompanyAccess } from "./pages/CompanyAccess";
+import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
+import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills";
import { Secrets } from "./pages/Secrets";
@@ -69,11 +70,13 @@ function boardRoutes() {
} />
} />
} />
- } />
+ } />
+ } />
} />
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx
index 429d0812237..6514cd4a99b 100644
--- a/ui/src/components/CompanySettingsSidebar.test.tsx
+++ b/ui/src/components/CompanySettingsSidebar.test.tsx
@@ -10,6 +10,7 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
+const mockUsePluginSlots = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({
@@ -61,6 +62,10 @@ vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi,
}));
+vi.mock("@/plugins/slots", () => ({
+ usePluginSlots: mockUsePluginSlots,
+}));
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -83,6 +88,11 @@ describe("CompanySettingsSidebar", () => {
failedRuns: 0,
joinRequests: 2,
});
+ mockUsePluginSlots.mockReturnValue({
+ slots: [],
+ isLoading: false,
+ errorMessage: null,
+ });
});
afterEach(() => {
@@ -110,7 +120,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
- expect(container.textContent).toContain("Access");
+ expect(container.textContent).toContain("Members");
expect(container.textContent).toContain("Invites");
expect(container.textContent).toContain("Secrets");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
@@ -129,8 +139,8 @@ describe("CompanySettingsSidebar", () => {
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
- to: "/company/settings/access",
- label: "Access",
+ to: "/company/settings/members",
+ label: "Members",
badge: 2,
end: true,
}),
@@ -154,4 +164,50 @@ describe("CompanySettingsSidebar", () => {
root.unmount();
});
});
+
+ it("renders company settings pages contributed by ready plugins", async () => {
+ mockUsePluginSlots.mockReturnValue({
+ slots: [
+ {
+ type: "companySettingsPage",
+ id: "permissions",
+ displayName: "Permissions",
+ exportName: "PermissionsPage",
+ routePath: "permissions",
+ pluginId: "plugin-1",
+ pluginKey: "permissions-extension",
+ pluginDisplayName: "Permissions Extension",
+ pluginVersion: "0.1.0",
+ },
+ ],
+ isLoading: false,
+ errorMessage: null,
+ });
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(container.textContent).toContain("Permissions");
+ expect(sidebarNavItemMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: "/company/settings/permissions",
+ label: "Permissions",
+ end: true,
+ }),
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
});
diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx
index fb8870c999c..ed84e9baed3 100644
--- a/ui/src/components/CompanySettingsSidebar.tsx
+++ b/ui/src/components/CompanySettingsSidebar.tsx
@@ -1,16 +1,22 @@
import { useQuery } from "@tanstack/react-query";
-import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
+import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
import { ApiError } from "@/api/client";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
+import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem";
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
+ const { slots: companySettingsPluginSlots } = usePluginSlots({
+ slotTypes: ["companySettingsPage"],
+ companyId: selectedCompanyId,
+ enabled: !!selectedCompanyId,
+ });
const { data: badges } = useQuery({
queryKey: selectedCompanyId
? queryKeys.sidebarBadges(selectedCompanyId)
@@ -61,12 +67,23 @@ export function CompanySettingsSidebar() {
end
/>
+ {companySettingsPluginSlots
+ .filter((slot) => slot.routePath)
+ .map((slot) => (
+
+ ))}
diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx
index 4dad91b1a89..27f93a0b8ed 100644
--- a/ui/src/components/IssueBlockedNotice.test.tsx
+++ b/ui/src/components/IssueBlockedNotice.test.tsx
@@ -3,10 +3,14 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
+import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
+import { ToastProvider } from "../context/ToastContext";
+
+const retryNowMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => (
@@ -14,11 +18,57 @@ vi.mock("@/lib/router", () => ({
),
}));
+vi.mock("../api/issues", () => ({
+ issuesApi: {
+ retryScheduledRetryNow: retryNowMock,
+ },
+}));
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType | null = null;
let container: HTMLDivElement | null = null;
+let dateNowSpy: ReturnType | null = null;
+
+const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
+
+const baseRetry: IssueScheduledRetry = {
+ runId: "retry-run-1",
+ status: "scheduled_retry",
+ agentId: "agent-1",
+ agentName: "CodexCoder",
+ retryOfRunId: "source-run-1",
+ scheduledRetryAt: "2026-04-19T20:00:00.000Z",
+ scheduledRetryAttempt: 1,
+ scheduledRetryReason: "max_turns_continuation",
+ retryExhaustedReason: null,
+ error: null,
+ errorCode: null,
+};
+
+function buildRetryResponse(outcome: IssueRetryNowOutcome) {
+ return {
+ outcome,
+ message:
+ outcome === "promoted"
+ ? "Promoted scheduled retry"
+ : outcome === "already_promoted"
+ ? "Scheduled retry already promoted"
+ : outcome === "no_scheduled_retry"
+ ? "No scheduled retry"
+ : "Promotion suppressed by gate",
+ scheduledRetry:
+ outcome === "promoted" || outcome === "already_promoted"
+ ? { ...baseRetry, status: "queued" as const }
+ : null,
+ };
+}
+
+beforeEach(() => {
+ dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
+ retryNowMock.mockReset();
+});
afterEach(() => {
if (root) {
@@ -27,13 +77,22 @@ afterEach(() => {
root = null;
container?.remove();
container = null;
+ dateNowSpy?.mockRestore();
+ dateNowSpy = null;
});
function withProviders(node: ReactNode) {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0, staleTime: 0 },
+ mutations: { retry: false },
+ },
+ });
return (
- {node}
+
+ {node}
+
);
}
@@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => {
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
+ expect(node.textContent).not.toContain("Retry now");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
+ it("shows retry-now action for next-step notices with a scheduled retry", async () => {
+ retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
+ const node = render(
+ ,
+ );
+
+ expect(node.textContent).toContain("Corrective wake scheduled in 1d");
+ const button = node.querySelector('[data-testid="issue-next-step-retry-now"]');
+ expect(button).not.toBeNull();
+ expect(button!.textContent ?? "").toContain("Retry now");
+
+ await act(async () => {
+ button!.click();
+ await Promise.resolve();
+ });
+
+ await vi.waitFor(() => {
+ expect(retryNowMock).toHaveBeenCalledWith("issue-1");
+ expect(button!.textContent ?? "").toContain("Promoted");
+ expect(button!.disabled).toBe(true);
+ });
+ });
+
it("does not render when the issue is done even if a stale handoff state is required", () => {
const node = render(
+
+
+ Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately.
+
+
retryNow.mutate()}
+ disabled={retryNow.isPending || success}
+ data-testid="issue-next-step-retry-now"
+ >
+ {retryNow.isPending ? (
+
+
+ Retrying...
+
+ ) : success ? (
+
+
+ {retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
+
+ ) : (
+
+
+ Retry now
+
+ )}
+
+
+ {
+ retryNow.reset();
+ retryNow.mutate();
+ }}
+ />
+
+ );
+}
+
export function IssueBlockedNotice({
+ issueId,
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
+ scheduledRetry,
agentName,
}: {
+ issueId?: string | null;
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
+ scheduledRetry?: IssueScheduledRetry | null;
agentName?: string | null;
}) {
if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
+ const successfulRunRetryNow = showSuccessfulRunHandoff
+ && issueId
+ && scheduledRetry?.status === "scheduled_retry"
+ ? { issueId, scheduledRetry }
+ : null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -162,6 +241,12 @@ export function IssueBlockedNotice({
Detected progress: {successfulRunHandoff.detectedProgressSummary}
) : null}
+ {successfulRunRetryNow ? (
+
+ ) : null}
>
) : null}
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index b25eb39fd40..a08c58e57aa 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -38,6 +38,7 @@ import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
+ IssueScheduledRetry,
SuccessfulRunHandoffState,
IssueWorkMode,
} from "@paperclipai/shared";
@@ -296,9 +297,11 @@ interface IssueChatThreadProps {
timelineEvents?: IssueTimelineEvent[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
+ issueId?: string | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
+ scheduledRetry?: IssueScheduledRetry | null;
recoveryAction?: IssueRecoveryAction | null;
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
@@ -3617,9 +3620,11 @@ export function IssueChatThread({
timelineEvents = [],
liveRuns = [],
activeRun = null,
+ issueId = null,
blockedBy = [],
blockerAttention = null,
successfulRunHandoff = null,
+ scheduledRetry = null,
recoveryAction = null,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction = false,
@@ -4299,10 +4304,12 @@ export function IssueChatThread({
/>
) : null}
({
+ Link: ({
+ children,
+ to,
+ ...props
+ }: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
+ {children}
+ ),
+}));
+
+vi.mock("../api/issues", () => ({
+ issuesApi: {
+ get: vi.fn(),
+ },
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+let root: ReturnType | null = null;
+let container: HTMLDivElement | null = null;
+
+afterEach(() => {
+ if (root) {
+ flushSync(() => root?.unmount());
+ }
+ root = null;
+ container?.remove();
+ container = null;
+});
+
+function renderMarkdown(children: string) {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ flushSync(() => {
+ root?.render(
+
+
+ {children}
+
+ ,
+ );
+ });
+
+ return container;
+}
+
+function click(element: Element | null) {
+ if (!element) throw new Error("Expected element to exist");
+ flushSync(() => {
+ element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+}
+
+describe("MarkdownBody code block interactions", () => {
+ it("toggles line wrapping for indented preformatted markdown blocks", () => {
+ const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
+ const pre = node.querySelector("pre");
+ const wrapButton = node.querySelector(".paperclip-markdown-codeblock-wrap");
+
+ expect(pre?.style.whiteSpace).toBe("");
+ expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
+
+ click(wrapButton);
+
+ expect(pre?.style.whiteSpace).toBe("pre-wrap");
+ expect(pre?.style.overflowWrap).toBe("anywhere");
+ expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
+ expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
+
+ click(wrapButton);
+
+ expect(pre?.style.whiteSpace).toBe("");
+ expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
+ expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
+ });
+});
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index 75675a0f695..41ec495a52c 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -1,6 +1,6 @@
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
-import { Check, Copy, ExternalLink, Github } from "lucide-react";
+import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
@@ -364,6 +364,7 @@ function CodeBlock({
}) {
const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
+ const [wrapLines, setWrapLines] = useState(false);
const preRef = useRef(null);
const timerRef = useRef>(undefined);
@@ -401,33 +402,57 @@ function CodeBlock({
}, 1500);
}, [children]);
- const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
+ const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
+ const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines";
return (
-
+
{children}
-
- {copied && !failed ? (
-
- ) : (
-
- )}
- {label}
-
+
+ setWrapLines((value) => !value)}
+ aria-label={wrapLabel}
+ title={wrapLabel}
+ className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
+ aria-pressed={wrapLines}
+ data-active={wrapLines || undefined}
+ >
+
+ {wrapLabel}
+
+
+ {copied && !failed ? (
+
+ ) : (
+
+ )}
+ {copyLabel}
+
+
);
}
diff --git a/ui/src/components/access/CompanySettingsNav.test.tsx b/ui/src/components/access/CompanySettingsNav.test.tsx
index be3daa819c3..7d795ee825a 100644
--- a/ui/src/components/access/CompanySettingsNav.test.tsx
+++ b/ui/src/components/access/CompanySettingsNav.test.tsx
@@ -60,27 +60,29 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
- expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
- expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
+ expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
+ expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
+ expect(getCompanySettingsTab("/company/settings/access")).toBe("members");
+ expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members");
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
});
it("renders the active tab and navigates when a different tab is selected", async () => {
- currentPathname = "/PAP/company/settings/access";
+ currentPathname = "/PAP/company/settings/members";
const root = createRoot(container);
await act(async () => {
root.render(
);
});
- expect(container.textContent).toContain("access");
+ expect(container.textContent).toContain("members");
expect(pageTabBarMock).toHaveBeenCalledWith(
expect.objectContaining({
- value: "access",
+ value: "members",
items: [
{ value: "general", label: "General" },
{ value: "environments", label: "Environments" },
- { value: "access", label: "Access" },
+ { value: "members", label: "Members" },
{ value: "invites", label: "Invites" },
],
}),
diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx
index 82fb224551e..ded7a63def8 100644
--- a/ui/src/components/access/CompanySettingsNav.tsx
+++ b/ui/src/components/access/CompanySettingsNav.tsx
@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router";
const items = [
{ value: "general", label: "General", href: "/company/settings" },
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
- { value: "access", label: "Access", href: "/company/settings/access" },
+ { value: "members", label: "Members", href: "/company/settings/members" },
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
] as const;
@@ -16,8 +16,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
return "environments";
}
- if (pathname.includes("/company/settings/access")) {
- return "access";
+ if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) {
+ return "members";
}
if (pathname.includes("/company/settings/invites")) {
diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts
index 74f94256d8e..6c001e2fae4 100644
--- a/ui/src/components/transcript/useLiveRunTranscripts.ts
+++ b/ui/src/components/transcript/useLiveRunTranscripts.ts
@@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
+import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
@@ -279,8 +280,9 @@ export function useLiveRunTranscripts({
const connect = () => {
if (closed) return;
- const protocol = window.location.protocol === "https:" ? "wss" : "ws";
- const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
+ const url = buildSameOriginWebSocketUrl(
+ `/api/companies/${encodeURIComponent(companyId)}/events/ws`,
+ );
socket = new WebSocket(url);
socket.onmessage = (message) => {
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx
index 783537e0708..376a92ad5db 100644
--- a/ui/src/context/LiveUpdatesProvider.tsx
+++ b/ui/src/context/LiveUpdatesProvider.tsx
@@ -14,6 +14,7 @@ import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-iss
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router";
+import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
const TOAST_COOLDOWN_MAX = 3;
@@ -979,8 +980,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const connect = () => {
if (closed) return;
- const protocol = window.location.protocol === "https:" ? "wss" : "ws";
- const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`;
+ const url = buildSameOriginWebSocketUrl(
+ `/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`,
+ );
const nextSocket = new WebSocket(url);
socket = nextSocket;
diff --git a/ui/src/index.css b/ui/src/index.css
index a29d4b67bc8..82128ed9a02 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -717,15 +717,23 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
background: none;
}
-/* Copy-to-clipboard button on fenced code blocks */
+/* Actions for fenced and indented preformatted markdown blocks */
.paperclip-markdown-codeblock {
position: relative;
}
-.paperclip-markdown-codeblock-copy {
+.paperclip-markdown-codeblock-actions {
position: absolute;
top: 0.4rem;
right: 0.4rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.12s ease;
+}
+
+.paperclip-markdown-codeblock-action {
display: inline-flex;
align-items: center;
gap: 0.25rem;
@@ -737,30 +745,31 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
- opacity: 0;
- transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease;
+ transition: background-color 0.12s ease, color 0.12s ease;
}
-.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy,
-.paperclip-markdown-codeblock-copy:focus-visible,
-.paperclip-markdown-codeblock-copy[data-copied] {
+.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
+.paperclip-markdown-codeblock-actions:focus-within,
+.paperclip-markdown-codeblock-action[data-copied],
+.paperclip-markdown-codeblock-action[data-active] {
opacity: 1;
}
-.paperclip-markdown-codeblock-copy:hover {
+.paperclip-markdown-codeblock-action:hover {
background-color: var(--accent);
color: var(--accent-foreground);
}
-.paperclip-markdown-codeblock-copy[data-copied] {
+.paperclip-markdown-codeblock-action[data-active],
+.paperclip-markdown-codeblock-action[data-copied] {
color: var(--primary);
}
-.paperclip-markdown-codeblock-copy[data-failed] {
+.paperclip-markdown-codeblock-action[data-failed] {
color: var(--destructive);
}
-.paperclip-markdown-codeblock-copy-label {
+.paperclip-markdown-codeblock-action-label {
font-weight: 500;
}
diff --git a/ui/src/lib/websocket-url.test.ts b/ui/src/lib/websocket-url.test.ts
new file mode 100644
index 00000000000..5971bd8cc28
--- /dev/null
+++ b/ui/src/lib/websocket-url.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { browserReachableHost, buildSameOriginWebSocketUrl } from "./websocket-url";
+
+describe("browserReachableHost", () => {
+ it("keeps concrete browser hosts unchanged", () => {
+ expect(browserReachableHost({
+ protocol: "http:",
+ hostname: "paperclip-dev",
+ host: "paperclip-dev:46259",
+ port: "46259",
+ })).toBe("paperclip-dev:46259");
+ });
+
+ it("rewrites wildcard IPv4 bind hosts to localhost", () => {
+ expect(browserReachableHost({
+ protocol: "http:",
+ hostname: "0.0.0.0",
+ host: "0.0.0.0:46259",
+ port: "46259",
+ })).toBe("localhost:46259");
+ });
+
+ it("rewrites wildcard IPv6 bind hosts to localhost", () => {
+ expect(browserReachableHost({
+ protocol: "http:",
+ hostname: "::",
+ host: "[::]:46259",
+ port: "46259",
+ })).toBe("localhost:46259");
+ });
+});
+
+describe("buildSameOriginWebSocketUrl", () => {
+ it("uses wss for https pages", () => {
+ expect(buildSameOriginWebSocketUrl("/api/events/ws", {
+ protocol: "https:",
+ hostname: "example.com",
+ host: "example.com",
+ port: "",
+ })).toBe("wss://example.com/api/events/ws");
+ });
+
+ it("does not emit 0.0.0.0 websocket URLs", () => {
+ expect(buildSameOriginWebSocketUrl("api/events/ws", {
+ protocol: "http:",
+ hostname: "0.0.0.0",
+ host: "0.0.0.0:46259",
+ port: "46259",
+ })).toBe("ws://localhost:46259/api/events/ws");
+ });
+});
diff --git a/ui/src/lib/websocket-url.ts b/ui/src/lib/websocket-url.ts
new file mode 100644
index 00000000000..a82ffe6db77
--- /dev/null
+++ b/ui/src/lib/websocket-url.ts
@@ -0,0 +1,20 @@
+type BrowserLocationLike = Pick
;
+
+function isWildcardHost(hostname: string): boolean {
+ const normalized = hostname.trim().toLowerCase();
+ return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]";
+}
+
+export function browserReachableHost(location: BrowserLocationLike = window.location): string {
+ if (!isWildcardHost(location.hostname)) return location.host;
+ return location.port ? `localhost:${location.port}` : "localhost";
+}
+
+export function buildSameOriginWebSocketUrl(
+ path: string,
+ location: BrowserLocationLike = window.location,
+): string {
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
+ return `${protocol}://${browserReachableHost(location)}${normalizedPath}`;
+}
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index a803b8f730d..81ab1cf864e 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
+import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
@@ -1713,7 +1714,9 @@ function ConfigurationTab({
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant."
- : "Disabled unless explicitly granted.";
+ : taskAssignSource === "simple_default"
+ ? "Enabled by simple company-wide task assignment defaults."
+ : "Disabled unless explicitly granted.";
return (
@@ -3863,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const connect = () => {
if (closed) return;
- const protocol = window.location.protocol === "https:" ? "wss" : "ws";
- const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
+ const url = buildSameOriginWebSocketUrl(
+ `/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
+ );
socket = new WebSocket(url);
socket.onopen = () => {
diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx
index eefb7d73054..73f9e3f4fea 100644
--- a/ui/src/pages/CompanyAccess.test.tsx
+++ b/ui/src/pages/CompanyAccess.test.tsx
@@ -4,23 +4,25 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { CompanyAccess } from "./CompanyAccess";
+import { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess";
const listMembersMock = vi.hoisted(() => vi.fn());
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
-const updateMemberAccessMock = vi.hoisted(() => vi.fn());
+const updateMemberMock = vi.hoisted(() => vi.fn());
const archiveMemberMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const listIssuesMock = vi.hoisted(() => vi.fn());
+const mockUsePluginSlots = vi.hoisted(() => vi.fn());
+const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/access", () => ({
accessApi: {
listMembers: (companyId: string) => listMembersMock(companyId),
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status),
- updateMember: vi.fn(),
+ updateMember: (companyId: string, memberId: string, input: unknown) =>
+ updateMemberMock(companyId, memberId, input),
updateMemberPermissions: vi.fn(),
- updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
- updateMemberAccessMock(companyId, memberId, input),
+ updateMemberAccess: vi.fn(),
archiveMember: (companyId: string, memberId: string, input: unknown) =>
archiveMemberMock(companyId, memberId, input),
approveJoinRequest: vi.fn(),
@@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({
},
}));
+vi.mock("@/lib/router", () => ({
+ Link: ({ to, children }: { to: string; children: React.ReactNode }) =>
{children} ,
+ Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
+ mockNavigate(to, replace);
+ return
{to}
;
+ },
+}));
+
+vi.mock("@/plugins/slots", () => ({
+ usePluginSlots: mockUsePluginSlots,
+}));
+
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
@@ -146,7 +160,7 @@ describe("CompanyAccess", () => {
},
},
]);
- updateMemberAccessMock.mockResolvedValue({});
+ updateMemberMock.mockResolvedValue({});
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
listAgentsMock.mockResolvedValue([
{
@@ -164,6 +178,11 @@ describe("CompanyAccess", () => {
status: "todo",
},
]);
+ mockUsePluginSlots.mockReturnValue({
+ slots: [],
+ isLoading: false,
+ errorMessage: null,
+ });
});
afterEach(() => {
@@ -172,7 +191,7 @@ describe("CompanyAccess", () => {
vi.clearAllMocks();
});
- it("keeps the page human-focused and explains implicit versus explicit grants", async () => {
+ it("keeps the page human-focused and hides advanced permission controls", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -188,10 +207,15 @@ describe("CompanyAccess", () => {
await flushReact();
await flushReact();
- expect(container.textContent).toContain("Manage company user memberships");
+ expect(container.textContent).toContain("Manage the people who can work in Paperclip");
+ expect(container.textContent).toContain("Members can collaborate across the company by default");
+ expect(container.textContent).toContain("Core keeps this page focused on membership");
expect(container.textContent).toContain("Humans");
expect(container.textContent).toContain("Pending human joins");
expect(container.textContent).toContain("User account");
+ expect(container.textContent).not.toContain("Grants");
+ expect(container.textContent).not.toContain("explicit grants");
+ expect(container.textContent).not.toContain("Assign scoped tasks");
expect(container.textContent).not.toContain("Agents");
expect(container.textContent).not.toContain("Pending agent joins");
expect(container.textContent).not.toContain("Open join request queue");
@@ -210,18 +234,16 @@ describe("CompanyAccess", () => {
});
await flushReact();
- expect(document.body.textContent).toContain("Implicit grants from role");
- expect(document.body.textContent).toContain("Owner currently includes these permissions automatically.");
- expect(document.body.textContent).toContain(
- "Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.",
- );
+ expect(document.body.textContent).toContain("Update company role and membership status");
+ expect(document.body.textContent).not.toContain("Implicit grants from role");
+ expect(document.body.textContent).not.toContain("permissionKey");
await act(async () => {
root.unmount();
});
});
- it("saves member role, status, and grants in one request", async () => {
+ it("saves member role and status without touching grants", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -248,7 +270,7 @@ describe("CompanyAccess", () => {
await flushReact();
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
- (button) => button.textContent === "Save access",
+ (button) => button.textContent === "Save member",
);
expect(saveButton).toBeTruthy();
@@ -257,10 +279,9 @@ describe("CompanyAccess", () => {
});
await flushReact();
- expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", {
+ expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
membershipRole: "owner",
status: "active",
- grants: [],
});
await act(async () => {
@@ -382,4 +403,65 @@ describe("CompanyAccess", () => {
root.unmount();
});
});
+
+ it("redirects legacy access deep links to the permissions extension route when installed", async () => {
+ mockUsePluginSlots.mockReturnValue({
+ slots: [
+ {
+ type: "companySettingsPage",
+ id: "permissions",
+ displayName: "Permissions",
+ routePath: "permissions",
+ pluginKey: "permissions-extension",
+ },
+ ],
+ isLoading: false,
+ errorMessage: null,
+ });
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(mockNavigate).toHaveBeenCalledWith("/company/settings/permissions", true);
+ expect(container.textContent).toContain("/company/settings/permissions");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("shows a read-only unavailable fallback for legacy access deep links", async () => {
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(container.textContent).toContain("Advanced Permissions");
+ expect(container.textContent).toContain("Advanced permissions unavailable");
+ expect(container.textContent).toContain("Open Members");
+ expect(container.textContent).toContain("Open Invites");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
});
diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx
index 3f31fe1ef5b..9c43d5ae70a 100644
--- a/ui/src/pages/CompanyAccess.tsx
+++ b/ui/src/pages/CompanyAccess.tsx
@@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
- PERMISSION_KEYS,
type Agent,
- type PermissionKey,
} from "@paperclipai/shared";
-import { ShieldCheck, Trash2, Users } from "lucide-react";
+import { Shield, ShieldCheck, Trash2, Users } from "lucide-react";
import { accessApi, type CompanyMember } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -25,38 +22,13 @@ import { Badge } from "@/components/ui/badge";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
+import { Link, Navigate } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
-
-const permissionLabels: Record
= {
- "agents:create": "Create agents",
- "users:invite": "Invite humans and agents",
- "users:manage_permissions": "Manage members and grants",
- "tasks:assign": "Assign tasks",
- "tasks:assign_scope": "Assign scoped tasks",
- "tasks:manage_active_checkouts": "Manage active task checkouts",
- "joins:approve": "Approve join requests",
- "environments:manage": "Manage environments",
-};
-
-function formatGrantSummary(member: CompanyMember) {
- if (member.grants.length === 0) return "No explicit grants";
- return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", ");
-}
-
-const implicitRoleGrantMap: Record, PermissionKey[]> = {
- owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"],
- admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"],
- operator: ["tasks:assign"],
- viewer: [],
-};
+import { usePluginSlots } from "@/plugins/slots";
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
type EditableMemberStatus = "pending" | "active" | "suspended";
-function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
- return role ? implicitRoleGrantMap[role] : [];
-}
-
export function CompanyAccess() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -67,13 +39,12 @@ export function CompanyAccess() {
const [reassignmentTarget, setReassignmentTarget] = useState("__unassigned");
const [draftRole, setDraftRole] = useState(null);
const [draftStatus, setDraftStatus] = useState("active");
- const [draftGrants, setDraftGrants] = useState>(new Set());
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
- { label: "Access" },
+ { label: "Members" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
@@ -103,11 +74,10 @@ export function CompanyAccess() {
};
const updateMemberMutation = useMutation({
- mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => {
- return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
+ mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => {
+ return accessApi.updateMember(selectedCompanyId!, input.memberId, {
membershipRole: input.membershipRole,
status: input.status,
- grants: input.grants.map((permissionKey) => ({ permissionKey })),
});
},
onSuccess: async () => {
@@ -223,7 +193,6 @@ export function CompanyAccess() {
if (!editingMember) return;
setDraftRole(editingMember.membershipRole);
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
- setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
}, [editingMember]);
useEffect(() => {
@@ -255,8 +224,6 @@ export function CompanyAccess() {
joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? [];
const joinRequestActionPending =
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
- const implicitGrantKeys = getImplicitGrantKeys(draftRole);
- const implicitGrantSet = new Set(implicitGrantKeys);
const activeReassignmentUsers = members.filter(
(member) =>
member.status === "active" &&
@@ -271,11 +238,14 @@ export function CompanyAccess() {
-
Company Access
+ Company Members
- Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}.
+ Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.
+
+ Core keeps this page focused on membership, invite approvals, and safe member removal.
+
{access && !access.currentUserRole && (
@@ -291,7 +261,7 @@ export function CompanyAccess() {
Humans
- Manage human company memberships, status, and grants here.
+ Manage human company memberships and status here.
@@ -340,11 +310,10 @@ export function CompanyAccess() {
) : null}
-
+
User account
Role
Status
-
Grants
Action
{members.length === 0 ? (
@@ -356,7 +325,7 @@ export function CompanyAccess() {
return (
{member.user?.name?.trim() || member.user?.email || member.principalId}
@@ -372,7 +341,6 @@ export function CompanyAccess() {
{member.status.replace("_", " ")}
-
{formatGrantSummary(member)}
setEditingMemberId(member.id)}>
@@ -405,7 +373,7 @@ export function CompanyAccess() {
Edit member
- Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
+ Update company role and membership status for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
{editingMember && (
@@ -443,66 +411,6 @@ export function CompanyAccess() {
-
-
-
-
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) => (
-
- {
- setDraftGrants((current) => {
- const next = new Set(current);
- if (checked) next.add(permissionKey);
- else next.delete(permissionKey);
- return next;
- });
- }}
- />
-
- {permissionLabels[permissionKey]}
- {permissionKey}
- {implicitGrantSet.has(permissionKey) ? (
-
- Included implicitly by the {draftRole ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole] : "selected"} role. Add an explicit grant only if it should stay after the role changes.
-
- ) : null}
- {draftGrants.has(permissionKey) ? (
-
- Stored explicitly for this member.
-
- ) : null}
-
-
- ))}
-
-
)}
@@ -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}
+
+
+
+ Open Members
+
+
+ Open Invites
+
+
+
+
+ );
+}
+
function memberDisplayName(member: CompanyMember | null) {
if (!member) return "this member";
return member.user?.name?.trim() || member.user?.email || member.principalId;
diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx
index 03937030a7d..27961dbc73c 100644
--- a/ui/src/pages/CompanyInvites.test.tsx
+++ b/ui/src/pages/CompanyInvites.test.tsx
@@ -152,7 +152,8 @@ describe("CompanyInvites", () => {
expect(container.textContent).toContain("Choose a role");
expect(container.textContent).toContain("Each invite link is single-use.");
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
- expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants.");
+ expect(container.textContent).toContain("Everything in Admin, plus managing members.");
+ expect(container.textContent).not.toContain("permission grants");
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx
index 0d52bf314dc..f20ee636b3a 100644
--- a/ui/src/pages/CompanyInvites.tsx
+++ b/ui/src/pages/CompanyInvites.tsx
@@ -14,8 +14,8 @@ const inviteRoleOptions = [
{
value: "viewer",
label: "Viewer",
- description: "Can view company work and follow along without operational permissions.",
- gets: "No built-in grants.",
+ description: "Can view company work and follow along.",
+ gets: "View-only company membership.",
},
{
value: "operator",
@@ -32,8 +32,8 @@ const inviteRoleOptions = [
{
value: "owner",
label: "Owner",
- description: "Full company access, including membership and permission management.",
- gets: "Everything in Admin, plus managing members and permission grants.",
+ description: "Full company access, including membership management.",
+ gets: "Everything in Admin, plus managing members.",
},
] as const;
diff --git a/ui/src/pages/CompanySettingsPluginPage.test.tsx b/ui/src/pages/CompanySettingsPluginPage.test.tsx
new file mode 100644
index 00000000000..12c5a8bb17a
--- /dev/null
+++ b/ui/src/pages/CompanySettingsPluginPage.test.tsx
@@ -0,0 +1,140 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { CompanySettingsPluginPage } from "./CompanySettingsPluginPage";
+
+const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
+const mockUsePluginSlots = vi.hoisted(() => vi.fn());
+const mockParams = vi.hoisted(() => ({
+ companyPrefix: "PAP" as string | undefined,
+ settingsRoutePath: "permissions" as string | undefined,
+}));
+
+vi.mock("@/context/BreadcrumbContext", () => ({
+ useBreadcrumbs: () => ({
+ setBreadcrumbs: mockSetBreadcrumbs,
+ }),
+}));
+
+vi.mock("@/context/CompanyContext", () => ({
+ useCompany: () => ({
+ companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
+ selectedCompanyId: "company-1",
+ }),
+}));
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ to, children }: { to: string; children: React.ReactNode }) =>
{children} ,
+ useLocation: () => ({ pathname: "/PAP/company/settings/permissions", search: "", hash: "" }),
+ useParams: () => mockParams,
+}));
+
+vi.mock("@/plugins/slots", () => ({
+ usePluginSlots: mockUsePluginSlots,
+ PluginSlotMount: ({
+ slot,
+ context,
+ }: {
+ slot: { displayName: string };
+ context: { companyId: string | null; companyPrefix: string | null };
+ }) => (
+
+ {slot.displayName}:{context.companyId}:{context.companyPrefix}
+
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+async function flushReact() {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+}
+
+async function renderPage(container: HTMLDivElement) {
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ return root;
+}
+
+describe("CompanySettingsPluginPage", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ mockParams.companyPrefix = "PAP";
+ mockParams.settingsRoutePath = "permissions";
+ mockUsePluginSlots.mockReturnValue({
+ slots: [
+ {
+ type: "companySettingsPage",
+ id: "permissions",
+ displayName: "Permissions",
+ exportName: "PermissionsPage",
+ routePath: "permissions",
+ pluginId: "plugin-1",
+ pluginKey: "permissions-extension",
+ pluginDisplayName: "Permissions Extension",
+ pluginVersion: "0.1.0",
+ },
+ ],
+ isLoading: false,
+ errorMessage: null,
+ });
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ });
+
+ it("mounts the matching company settings slot with company context", async () => {
+ const root = await renderPage(container);
+
+ expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe(
+ "Permissions:company-1:PAP",
+ );
+ expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
+ { label: "Settings", href: "/company/settings" },
+ { label: "Permissions" },
+ ]);
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("fails closed when no ready plugin declares the route", async () => {
+ mockUsePluginSlots.mockReturnValue({
+ slots: [],
+ isLoading: false,
+ errorMessage: null,
+ });
+ const root = await renderPage(container);
+
+ expect(container.textContent).toContain("Page not found");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/pages/CompanySettingsPluginPage.tsx b/ui/src/pages/CompanySettingsPluginPage.tsx
new file mode 100644
index 00000000000..e4961a369c3
--- /dev/null
+++ b/ui/src/pages/CompanySettingsPluginPage.tsx
@@ -0,0 +1,88 @@
+import { useEffect, useMemo } from "react";
+import { useParams } from "@/lib/router";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useCompany } from "@/context/CompanyContext";
+import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
+import { NotFoundPage } from "./NotFound";
+
+export function CompanySettingsPluginPage() {
+ const params = useParams<{
+ companyPrefix?: string;
+ settingsRoutePath?: string;
+ }>();
+ const { companyPrefix: routeCompanyPrefix, settingsRoutePath } = params;
+ const { companies, selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+
+ const routeCompany = useMemo(() => {
+ if (!routeCompanyPrefix) return null;
+ const requested = routeCompanyPrefix.toUpperCase();
+ return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null;
+ }, [companies, routeCompanyPrefix]);
+ const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
+ const resolvedCompanyId = routeCompany?.id ?? (routeCompanyPrefix ? null : selectedCompanyId ?? null);
+ const companyPrefix = resolvedCompanyId
+ ? companies.find((company) => company.id === resolvedCompanyId)?.issuePrefix ?? null
+ : null;
+
+ const { slots, isLoading, errorMessage } = usePluginSlots({
+ slotTypes: ["companySettingsPage"],
+ companyId: resolvedCompanyId,
+ enabled: Boolean(resolvedCompanyId && settingsRoutePath),
+ });
+
+ const pageSlots = useMemo(() => {
+ if (!settingsRoutePath) return [];
+ return slots.filter((slot) => slot.routePath === settingsRoutePath);
+ }, [settingsRoutePath, slots]);
+
+ const pageSlot = pageSlots.length === 1 ? pageSlots[0] : null;
+
+ useEffect(() => {
+ if (!pageSlot) return;
+ setBreadcrumbs([
+ { label: "Settings", href: "/company/settings" },
+ { label: pageSlot.displayName },
+ ]);
+ }, [pageSlot, setBreadcrumbs]);
+
+ if (!resolvedCompanyId) {
+ if (hasInvalidCompanyPrefix) {
+ return
;
+ }
+ return
Select a company to view this page.
;
+ }
+
+ if (!settingsRoutePath || isLoading) {
+ return
Loading...
;
+ }
+
+ if (errorMessage) {
+ return (
+
+ Plugin extensions unavailable: {errorMessage}
+
+ );
+ }
+
+ if (pageSlots.length > 1) {
+ return (
+
+ Multiple plugins declare the company settings route {settingsRoutePath}. Disable one plugin or change its route.
+
+ );
+ }
+
+ if (!pageSlot) {
+ return
;
+ }
+
+ return (
+
+ );
+}
diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx
index 0fe56c2dc2f..a800bd6f036 100644
--- a/ui/src/pages/InviteLanding.test.tsx
+++ b/ui/src/pages/InviteLanding.test.tsx
@@ -403,16 +403,16 @@ describe("InviteLandingPage", () => {
expect(container.textContent).toContain("Request to join Acme Robotics");
expect(container.textContent).toContain("A company admin must approve your request to join.");
expect(container.textContent).toContain(
- "Ask them to visit Company Settings → Access to approve your request.",
+ "Ask them to visit Company Settings → Members to approve your request.",
);
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
- expect(container.textContent).not.toContain("http://localhost/company/settings/access");
+ expect(container.textContent).not.toContain("http://localhost/company/settings/members");
const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
- (link) => link.textContent === "Company Settings → Access",
+ (link) => link.textContent === "Company Settings → Members",
);
expect(approvalLinks).toHaveLength(2);
- const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
+ const expectedApprovalUrl = `${window.location.origin}/company/settings/members`;
for (const link of approvalLinks) {
expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
}
@@ -471,7 +471,7 @@ describe("InviteLandingPage", () => {
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
expect(container.textContent).toContain("Your request is still awaiting approval.");
expect(container.textContent).toContain(
- "Ask them to visit Company Settings → Access to approve your request.",
+ "Ask them to visit Company Settings → Members to approve your request.",
);
await act(async () => {
diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx
index 7dcef629b3f..cb394505900 100644
--- a/ui/src/pages/InviteLanding.tsx
+++ b/ui/src/pages/InviteLanding.tsx
@@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({
claimApiKeyPath = null,
onboardingTextUrl = null,
}: AwaitingJoinApprovalPanelProps) {
- const approvalUrl = `${window.location.origin}/company/settings/access`;
+ const approvalUrl = `${window.location.origin}/company/settings/members`;
const approverLabel = invitedByUserName ?? "A company admin";
return (
@@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({
href={approvalUrl}
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
>
- Company Settings → Access
+ Company Settings → Members
- Ask them to visit Company Settings → Access to approve your request.
+ Ask them to visit Company Settings → Members to approve your request.
Refresh this page after you've been approved — you'll be redirected automatically.
diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx
index 941a4875512..9a356d0a27d 100644
--- a/ui/src/pages/InviteUxLab.tsx
+++ b/ui/src/pages/InviteUxLab.tsx
@@ -23,8 +23,8 @@ const inviteRoleOptions = [
{
value: "viewer",
label: "Viewer",
- description: "Can view company work and follow along without operational permissions.",
- gets: "No built-in grants.",
+ description: "Can view company work and follow along.",
+ gets: "View-only company membership.",
},
{
value: "operator",
@@ -41,8 +41,8 @@ const inviteRoleOptions = [
{
value: "owner",
label: "Owner",
- description: "Full company access, including membership and permission management.",
- gets: "Everything in Admin, plus managing members and permission grants.",
+ description: "Full company access, including membership management.",
+ gets: "Everything in Admin, plus managing members.",
},
] as const;
@@ -423,8 +423,8 @@ function InviteResultPreview({
<>
@@ -897,7 +897,7 @@ export function InviteUxLab() {
/>
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 2784a9546e8..e674029a57a 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -617,6 +617,7 @@ type IssueDetailChatTabProps = {
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
+ scheduledRetry: Issue["scheduledRetry"] | null;
recoveryAction: Issue["activeRecoveryAction"];
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
@@ -689,6 +690,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy,
blockerAttention,
successfulRunHandoff,
+ scheduledRetry,
recoveryAction,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction,
@@ -897,9 +899,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
timelineEvents={timelineEvents}
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
+ issueId={issueId}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
+ scheduledRetry={scheduledRetry}
recoveryAction={recoveryAction ?? null}
onResolveRecoveryAction={onResolveRecoveryAction}
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
@@ -3914,6 +3918,7 @@ export function IssueDetail() {
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
+ scheduledRetry={issue.scheduledRetry ?? null}
recoveryAction={issue.activeRecoveryAction ?? null}
onResolveRecoveryAction={handleResolveRecoveryAction}
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts
index 2019830b70f..c208df8319d 100644
--- a/ui/src/plugins/bridge-init.ts
+++ b/ui/src/plugins/bridge-init.ts
@@ -21,7 +21,7 @@ import {
usePluginStream,
usePluginToast,
} from "./bridge.js";
-import { createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
+import { Component, createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "lucide-react";
import {
@@ -524,6 +524,127 @@ function FragmentSafe({ children }: { children?: ReactNode }) {
return createElement("span", { className: "contents" }, children);
}
+type PluginStatusBadgeProps = {
+ label: string;
+ status: "ok" | "warning" | "error" | "info" | "pending";
+};
+
+function PluginSdkStatusBadge({ label, status }: PluginStatusBadgeProps) {
+ const className = {
+ ok: "border-emerald-300 bg-emerald-50 text-emerald-700",
+ warning: "border-amber-300 bg-amber-50 text-amber-800",
+ error: "border-red-300 bg-red-50 text-red-700",
+ info: "border-slate-300 bg-slate-50 text-slate-700",
+ pending: "border-slate-300 bg-slate-50 text-slate-600",
+ }[status];
+ return createElement(
+ "span",
+ { className: `inline-flex w-fit items-center rounded-full border px-2 py-0.5 text-xs font-medium ${className}` },
+ label,
+ );
+}
+
+type PluginDataTableColumn = {
+ key: string;
+ header: string;
+ render?: (value: unknown, row: Record
) => ReactNode;
+ width?: string;
+};
+
+type PluginDataTableProps = {
+ columns: PluginDataTableColumn[];
+ rows: Array & { id?: string }>;
+ loading?: boolean;
+ emptyMessage?: string;
+};
+
+function PluginSdkDataTable({ columns, rows, loading, emptyMessage = "No rows." }: PluginDataTableProps) {
+ if (loading) return createElement("div", { className: "text-sm text-muted-foreground" }, "Loading...");
+ if (!rows.length) return createElement("div", { className: "text-sm text-muted-foreground" }, emptyMessage);
+ const gridColumns = columns.map((column) => column.width ?? "minmax(0, 1fr)").join(" ");
+ return createElement(
+ "div",
+ { className: "overflow-hidden rounded-md border" },
+ createElement(
+ "div",
+ {
+ className: "hidden border-b bg-muted/35 px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground md:grid md:[grid-template-columns:var(--plugin-grid-cols)]",
+ style: { "--plugin-grid-cols": gridColumns },
+ },
+ columns.map((column) => createElement("div", { key: column.key }, column.header)),
+ ),
+ createElement(
+ "div",
+ { className: "divide-y" },
+ rows.map((row, index) => createElement(
+ "div",
+ {
+ key: String(row.id ?? index),
+ className: "grid gap-2 px-3 py-3 md:items-center md:[grid-template-columns:var(--plugin-grid-cols)]",
+ style: { "--plugin-grid-cols": gridColumns },
+ },
+ columns.map((column) => createElement(
+ "div",
+ { key: column.key, className: "min-w-0 text-sm" },
+ createElement("div", { className: "mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground md:hidden" }, column.header),
+ column.render ? column.render(row[column.key], row) : String(row[column.key] ?? ""),
+ )),
+ )),
+ ),
+ );
+}
+
+type PluginKeyValueListProps = {
+ pairs: Array<{ label: string; value: ReactNode }>;
+};
+
+function PluginSdkKeyValueList({ pairs }: PluginKeyValueListProps) {
+ return createElement(
+ "dl",
+ { className: "grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]" },
+ pairs.flatMap((pair) => [
+ createElement("dt", { key: `${pair.label}:label`, className: "text-muted-foreground" }, pair.label),
+ createElement("dd", { key: `${pair.label}:value`, className: "min-w-0" }, pair.value),
+ ]),
+ );
+}
+
+function PluginSdkMetricCard({ label, value, unit }: { label: string; value: string | number; unit?: string }) {
+ return createElement(
+ "div",
+ { className: "rounded-md border bg-card p-3" },
+ createElement("div", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground" }, label),
+ createElement("div", { className: "mt-1 text-lg font-semibold" }, `${value}${unit ?? ""}`),
+ );
+}
+
+function PluginSdkJsonTree({ data }: { data: unknown }) {
+ return createElement("pre", { className: "max-h-80 overflow-auto rounded-md border bg-muted/30 p-2 text-xs" }, JSON.stringify(data, null, 2));
+}
+
+function PluginSdkSpinner({ label = "Loading" }: { size?: "sm" | "md" | "lg"; label?: string }) {
+ return createElement("span", {
+ className: "inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground align-middle",
+ role: "status",
+ "aria-label": label,
+ });
+}
+
+class PluginSdkErrorBoundary extends Component<{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean }> {
+ override state = { hasError: false };
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ override render() {
+ if (this.state.hasError) {
+ return this.props.fallback ?? createElement("div", { className: "rounded-md border border-destructive/30 p-3 text-sm text-destructive" }, "Plugin UI failed to render.");
+ }
+ return this.props.children;
+ }
+}
+
/**
* Initialize the plugin bridge global registry.
*
@@ -564,6 +685,13 @@ export function initPluginBridge(
resolveWikiLinkHref,
children: content,
}),
+ MetricCard: PluginSdkMetricCard,
+ StatusBadge: PluginSdkStatusBadge,
+ DataTable: PluginSdkDataTable,
+ KeyValueList: PluginSdkKeyValueList,
+ JsonTree: PluginSdkJsonTree,
+ Spinner: PluginSdkSpinner,
+ ErrorBoundary: PluginSdkErrorBoundary,
MarkdownEditor: PluginSdkMarkdownEditor,
FileTree: PluginSdkFileTree,
IssuesList: PluginSdkIssuesList,
From a1835cfa5e5f4926591cb956055dd870ede492ef Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 22 May 2026 09:16:24 -0500
Subject: [PATCH 2/4] [codex] Harden plugin runtime invocation scope (#6547)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip orchestrates AI-agent companies through a company-scoped
control plane.
> - Plugins extend that control plane, but plugin workers still call
back into host APIs.
> - Those worker-to-host calls need the same company boundary guarantees
as normal API routes.
> - Plugin action handlers also need authenticated actor context from
the host instead of trusting caller-supplied params.
> - This pull request hardens plugin bridge/action scope and keeps
plugin operation issues out of normal issue surfaces.
> - The benefit is safer plugin execution with clearer authorization
boundaries and better test coverage.
## What Changed
- Added host-owned invocation context plumbing for nested plugin worker
calls.
- Added actor context to plugin `performAction` calls and test harness
helpers.
- Enforced company invocation scope on worker-to-host calls and filtered
company lists to the active invocation scope.
- Extended plugin action route tests for board and agent actor context,
spoofed company params, and cross-company rejection.
- Extended plugin worker manager coverage for invocation-scope
propagation.
- Filtered typed and legacy plugin operation issue origins from default
issue/inbox lists.
## Verification
- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run
packages/plugins/sdk/tests/host-client-factory.test.ts
packages/plugins/sdk/tests/testing-actions.test.ts
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/plugin-worker-manager.test.ts
server/src/__tests__/issues-service.test.ts`
Note: embedded Postgres issue-service tests reported host-level Postgres
init skip for 47 tests; the non-embedded targeted tests passed.
## Risks
- Medium: plugin host authorization paths are sensitive, and external
plugins may rely on previously loose company params.
- Mitigation: the change only tightens calls when the host attached a
company invocation scope and includes explicit tests for board, agent,
and nested worker calls.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.../plugins/sdk/src/host-client-factory.ts | 2 +-
packages/plugins/sdk/src/index.ts | 3 +
packages/plugins/sdk/src/protocol.ts | 26 +++
packages/plugins/sdk/src/testing.ts | 71 +++++++-
packages/plugins/sdk/src/types.ts | 8 +-
packages/plugins/sdk/src/ui/types.ts | 1 +
packages/plugins/sdk/src/worker-rpc-host.ts | 50 +++++-
.../sdk/tests/host-client-factory.test.ts | 9 +
.../plugins/sdk/tests/testing-actions.test.ts | 74 ++++++++
.../plugins/sdk/tests/worker-rpc-host.test.ts | 84 +++++++++
packages/shared/src/constants.ts | 1 +
.../plugin-worker-invocation-scope.cjs | 4 +-
server/src/__tests__/issues-service.test.ts | 37 ++++
.../src/__tests__/plugin-routes-authz.test.ts | 161 ++++++++++++++++++
.../__tests__/plugin-worker-manager.test.ts | 152 ++++++++++++++++-
server/src/routes/plugins.ts | 62 ++++++-
server/src/services/issues.ts | 13 +-
server/src/services/plugin-worker-manager.ts | 21 ++-
18 files changed, 749 insertions(+), 30 deletions(-)
create mode 100644 packages/plugins/sdk/tests/testing-actions.test.ts
diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts
index 3264afbccb9..8fc8fdfa093 100644
--- a/packages/plugins/sdk/src/host-client-factory.ts
+++ b/packages/plugins/sdk/src/host-client-factory.ts
@@ -79,7 +79,7 @@ export class CapabilityDeniedError extends Error {
*/
export class InvocationScopeDeniedError extends Error {
override readonly name = "InvocationScopeDeniedError";
- readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
+ readonly code = PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED;
constructor(pluginId: string, method: string, message: string) {
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts
index 2ca0fa9a7c3..1fbf172b509 100644
--- a/packages/plugins/sdk/src/index.ts
+++ b/packages/plugins/sdk/src/index.ts
@@ -149,6 +149,9 @@ export type {
RunJobParams,
GetDataParams,
PerformActionParams,
+ PluginPerformActionActorType,
+ PluginPerformActionActorContext,
+ PluginPerformActionContext,
ExecuteToolParams,
PluginEnvironmentDiagnostic,
PluginEnvironmentDriverBaseParams,
diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts
index 48075b2f6b9..2c76beab23a 100644
--- a/packages/plugins/sdk/src/protocol.ts
+++ b/packages/plugins/sdk/src/protocol.ts
@@ -233,6 +233,8 @@ export const PLUGIN_RPC_ERROR_CODES = {
TIMEOUT: -32003,
/** The worker does not implement the requested optional method. */
METHOD_NOT_IMPLEMENTED: -32004,
+ /** The worker→host call attempted to escape the current invocation company scope. */
+ INVOCATION_SCOPE_DENIED: -32005,
/** A catch-all for errors that do not fit other categories. */
UNKNOWN: -32099,
} as const;
@@ -368,6 +370,28 @@ export interface GetDataParams {
*
* @see PLUGIN_SPEC.md §13.9 — `performAction`
*/
+export type PluginPerformActionActorType = "user" | "agent" | "system";
+
+export interface PluginPerformActionActorContext {
+ /** Authenticated principal type resolved by the Paperclip host. */
+ type: PluginPerformActionActorType;
+ /** Authenticated board user id when `type === "user"`, otherwise null. */
+ userId: string | null;
+ /** Authenticated agent id when `type === "agent"`, otherwise null. */
+ agentId: string | null;
+ /** Authenticated heartbeat/run id when available. */
+ runId: string | null;
+ /** Company id authorized by the host bridge for this action, when applicable. */
+ companyId: string | null;
+}
+
+export interface PluginPerformActionContext {
+ /** Immutable authenticated actor context supplied by the host. */
+ actor: Readonly;
+ /** Convenience alias for `actor.companyId`. */
+ companyId: string | null;
+}
+
export interface PerformActionParams {
/** Plugin-defined action key (e.g. `"resync"`). */
key: string;
@@ -375,6 +399,8 @@ export interface PerformActionParams {
companyId?: string | null;
/** Action parameters from the UI. */
params: Record;
+ /** Authenticated actor context resolved by the host, never by caller params. */
+ actorContext?: PluginPerformActionActorContext | null;
/** Optional launcher/container metadata from the host render environment. */
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
}
diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts
index 299af7c6866..f8f1fc262aa 100644
--- a/packages/plugins/sdk/src/testing.ts
+++ b/packages/plugins/sdk/src/testing.ts
@@ -57,6 +57,8 @@ import type {
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
+ PluginPerformActionActorContext,
+ PluginPerformActionContext,
} from "./protocol.js";
export interface TestHarnessOptions {
@@ -74,6 +76,20 @@ export interface TestHarnessLogEntry {
meta?: Record;
}
+export interface TestHarnessPerformActionOptions {
+ /**
+ * Authenticated actor context to expose to the action handler. Omitted fields
+ * default to null, and `type` defaults to `system`.
+ */
+ actor?: Partial | null;
+ /**
+ * Host-authorized company scope. When provided, this is injected into
+ * `params.companyId` so tests match the production bridge's anti-spoofing
+ * behavior.
+ */
+ companyId?: string | null;
+}
+
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
@@ -98,7 +114,11 @@ export interface TestHarness {
/** Invoke a `ctx.data.register(...)` handler by key. */
getData(key: string, params?: Record): Promise;
/** Invoke a `ctx.actions.register(...)` handler by key. */
- performAction(key: string, params?: Record): Promise;
+ performAction(
+ key: string,
+ params?: Record,
+ options?: TestHarnessPerformActionOptions,
+ ): Promise;
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
executeTool(name: string, params: unknown, runCtx?: Partial): Promise;
/** Read raw in-memory state for assertions. */
@@ -491,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const jobs = new Map Promise>();
const launchers = new Map();
const dataHandlers = new Map) => Promise>();
- const actionHandlers = new Map) => Promise>();
+ const actionHandlers = new Map<
+ string,
+ (params: Record, context: PluginPerformActionContext) => Promise
+ >();
const toolHandlers = new Map Promise>();
function localFolderKey(companyId: string, folderKey: string): string {
@@ -502,6 +525,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return `${localFolderKey(companyId, folderKey)}:${relativePath}`;
}
+ function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+ }
+
+ function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
+ return value === "user" || value === "agent" || value === "system" ? value : "system";
+ }
+
+ function actionContextFor(
+ params: Record,
+ options?: TestHarnessPerformActionOptions,
+ ): PluginPerformActionContext {
+ const actorInput = options?.actor ?? null;
+ const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId);
+ const actor = Object.freeze({
+ type: actorTypeOrSystem(actorInput?.type),
+ userId: stringOrNull(actorInput?.userId),
+ agentId: stringOrNull(actorInput?.agentId),
+ runId: stringOrNull(actorInput?.runId),
+ companyId,
+ });
+ return Object.freeze({ actor, companyId });
+ }
+
+ function paramsWithHostCompanyScope(
+ params: Record,
+ context: PluginPerformActionContext,
+ options?: TestHarnessPerformActionOptions,
+ ): Record {
+ if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) {
+ return context.companyId ? { ...params, companyId: context.companyId } : { ...params };
+ }
+ return params;
+ }
+
function normalizeLocalFolderRelativePath(relativePath: string): string {
const parts: string[] = [];
for (const segment of relativePath.split(/[\\/]+/)) {
@@ -2302,10 +2360,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (!handler) throw new Error(`No data handler registered for '${key}'`);
return await handler(params) as T;
},
- async performAction(key: string, params: Record = {}) {
+ async performAction(
+ key: string,
+ params: Record = {},
+ options?: TestHarnessPerformActionOptions,
+ ) {
const handler = actionHandlers.get(key);
if (!handler) throw new Error(`No action handler registered for '${key}'`);
- return await handler(params) as T;
+ const context = actionContextFor(params, options);
+ return await handler(paramsWithHostCompanyScope(params, context, options), context) as T;
},
async executeTool(name: string, params: unknown, runCtx: Partial = {}) {
const handler = toolHandlers.get(name);
diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts
index 456f4a9e7c0..90af99b4803 100644
--- a/packages/plugins/sdk/src/types.ts
+++ b/packages/plugins/sdk/src/types.ts
@@ -46,6 +46,7 @@ import type {
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
+import type { PluginPerformActionContext } from "./protocol.js";
// ---------------------------------------------------------------------------
// Re-exports from @paperclipai/shared (plugin authors import from one place)
@@ -957,9 +958,12 @@ export interface PluginActionsClient {
* Register a handler for a plugin-defined action key.
*
* @param key - Stable string identifier for this action (e.g. `"resync"`)
- * @param handler - Async function that receives action params and returns a result
+ * @param handler - Async function that receives action params plus immutable host actor context and returns a result
*/
- register(key: string, handler: (params: Record) => Promise): void;
+ register(
+ key: string,
+ handler: (params: Record, context: PluginPerformActionContext) => Promise,
+ ): void;
}
/**
diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts
index 8923803e4e5..10478216838 100644
--- a/packages/plugins/sdk/src/ui/types.ts
+++ b/packages/plugins/sdk/src/ui/types.ts
@@ -54,6 +54,7 @@ export type {
* Error codes:
* - `WORKER_UNAVAILABLE` — plugin worker is not running
* - `CAPABILITY_DENIED` — plugin lacks the required capability
+ * - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope
* - `WORKER_ERROR` — worker returned an error from its handler
* - `TIMEOUT` — worker did not respond within the configured timeout
* - `UNKNOWN` — unexpected bridge-level failure
diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts
index a50b6d8f14c..a135d1f0a9c 100644
--- a/packages/plugins/sdk/src/worker-rpc-host.ts
+++ b/packages/plugins/sdk/src/worker-rpc-host.ts
@@ -78,6 +78,8 @@ import type {
RunJobParams,
GetDataParams,
PerformActionParams,
+ PluginPerformActionActorContext,
+ PluginPerformActionContext,
ExecuteToolParams,
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
@@ -289,7 +291,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const jobHandlers = new Map Promise>();
const launcherRegistrations = new Map();
const dataHandlers = new Map) => Promise>();
- const actionHandlers = new Map) => Promise>();
+ const actionHandlers = new Map<
+ string,
+ (params: Record, context: PluginPerformActionContext) => Promise
+ >();
const toolHandlers = new Map;
fn: (params: unknown, runCtx: ToolRunContext) => Promise;
@@ -1184,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
actions: {
- register(key: string, handler: (params: Record) => Promise): void {
+ register(
+ key: string,
+ handler: (params: Record, context: PluginPerformActionContext) => Promise,
+ ): void {
actionHandlers.set(key, handler);
},
},
@@ -1514,16 +1522,44 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
}
+ function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+ }
+
+ function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
+ return value === "user" || value === "agent" || value === "system" ? value : "system";
+ }
+
+ function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext {
+ const rawActor = params.actorContext && typeof params.actorContext === "object"
+ ? params.actorContext
+ : null;
+ const actor = Object.freeze({
+ type: actorTypeOrSystem(rawActor?.type),
+ userId: stringOrNull(rawActor?.userId),
+ agentId: stringOrNull(rawActor?.agentId),
+ runId: stringOrNull(rawActor?.runId),
+ companyId: stringOrNull(rawActor?.companyId),
+ });
+ return Object.freeze({
+ actor,
+ companyId: actor.companyId,
+ });
+ }
+
async function handlePerformAction(params: PerformActionParams): Promise {
const handler = actionHandlers.get(params.key);
if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`);
}
- return handler({
- ...params.params,
- ...(params.companyId === undefined ? {} : { companyId: params.companyId }),
- ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
- });
+ return handler(
+ {
+ ...params.params,
+ ...(params.companyId === undefined ? {} : { companyId: params.companyId }),
+ ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
+ },
+ actionContextFromParams(params),
+ );
}
async function handleExecuteTool(params: ExecuteToolParams): Promise {
diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts
index 8cded9c8a15..eb79043e57d 100644
--- a/packages/plugins/sdk/tests/host-client-factory.test.ts
+++ b/packages/plugins/sdk/tests/host-client-factory.test.ts
@@ -6,6 +6,7 @@ import {
createHostClientHandlers,
InvocationScopeDeniedError,
} from "../src/host-client-factory.js";
+import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
describe("createHostClientHandlers invocation company scope", () => {
it("rejects company-scoped host calls outside the current invocation company", async () => {
@@ -28,6 +29,14 @@ describe("createHostClientHandlers invocation company scope", () => {
{ invocationScope: { companyId: "company-a" } },
),
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
+ await expect(
+ handlers["projects.list"](
+ { companyId: "company-b" },
+ { invocationScope: { companyId: "company-a" } },
+ ),
+ ).rejects.toMatchObject({
+ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
+ });
expect(projectsList).not.toHaveBeenCalled();
});
diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts
new file mode 100644
index 00000000000..ab0026b283f
--- /dev/null
+++ b/packages/plugins/sdk/tests/testing-actions.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import { createTestHarness } from "../src/testing.js";
+import type { PaperclipPluginManifestV1 } from "../src/types.js";
+
+const manifest = {
+ id: "paperclip.test-actions",
+ apiVersion: 1,
+ version: "1.0.0",
+ displayName: "Test Actions",
+ description: "Test plugin",
+ author: "Paperclip",
+ categories: ["automation"],
+ capabilities: [],
+ entrypoints: {},
+} satisfies PaperclipPluginManifestV1;
+
+describe("createTestHarness action context", () => {
+ it("passes immutable authenticated actor context and overrides caller company scope", async () => {
+ const harness = createTestHarness({ manifest });
+
+ harness.ctx.actions.register("inspect", async (params, context) => ({
+ paramsCompanyId: params.companyId,
+ actor: context.actor,
+ companyId: context.companyId,
+ contextFrozen: Object.isFrozen(context),
+ actorFrozen: Object.isFrozen(context.actor),
+ }));
+
+ const result = await harness.performAction<{
+ paramsCompanyId: unknown;
+ actor: {
+ type: string;
+ userId: string | null;
+ agentId: string | null;
+ runId: string | null;
+ companyId: string | null;
+ };
+ companyId: string | null;
+ contextFrozen: boolean;
+ actorFrozen: boolean;
+ }>(
+ "inspect",
+ { companyId: "spoofed-company", value: true },
+ {
+ companyId: "host-company",
+ actor: {
+ type: "user",
+ userId: "board-user-1",
+ runId: "run-1",
+ },
+ },
+ );
+
+ expect(result.paramsCompanyId).toBe("host-company");
+ expect(result.companyId).toBe("host-company");
+ expect(result.actor).toEqual({
+ type: "user",
+ userId: "board-user-1",
+ agentId: null,
+ runId: "run-1",
+ companyId: "host-company",
+ });
+ expect(result.contextFrozen).toBe(true);
+ expect(result.actorFrozen).toBe(true);
+ });
+
+ it("keeps existing one-argument action handlers compatible", async () => {
+ const harness = createTestHarness({ manifest });
+ harness.ctx.actions.register("legacy", async (params) => ({ ok: params.ok }));
+
+ await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true });
+ });
+});
diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts
index b7f15781b5f..d41aa863aff 100644
--- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts
+++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts
@@ -71,6 +71,90 @@ describe("isWorkerEntrypoint", () => {
});
});
+describe("worker performAction context", () => {
+ it("does not derive context companyId from caller params without host actor context", async () => {
+ const hostToWorker = new PassThrough();
+ const workerToHost = new PassThrough();
+ const hostReadline = createInterface({ input: workerToHost });
+ const pending = new Map void>();
+ let nextRequestId = 1;
+ const plugin = definePlugin({
+ async setup(ctx) {
+ ctx.actions.register("inspect", async (params, context) => ({
+ paramsCompanyId: params.companyId,
+ actor: context.actor,
+ companyId: context.companyId,
+ }));
+ },
+ });
+ const worker = startWorkerRpcHost({
+ plugin,
+ stdin: hostToWorker,
+ stdout: workerToHost,
+ });
+
+ function callWorker(method: string, params: unknown) {
+ const id = `host-${nextRequestId++}`;
+ const result = new Promise((resolve, reject) => {
+ pending.set(id, (response) => {
+ if ("error" in response && response.error) {
+ reject(new Error(response.error.message));
+ return;
+ }
+ resolve((response as { result?: unknown }).result);
+ });
+ });
+ hostToWorker.write(serializeMessage(createRequest(method, params, id)));
+ return result;
+ }
+
+ hostReadline.on("line", (line) => {
+ const message = parseMessage(line);
+ if (!isJsonRpcResponse(message)) return;
+ pending.get(String(message.id))?.(message);
+ pending.delete(String(message.id));
+ });
+
+ try {
+ await expect(callWorker("initialize", {
+ manifest: {
+ id: "paperclip.test-worker-context",
+ apiVersion: 1,
+ version: "1.0.0",
+ displayName: "Worker Context Test",
+ description: "Test plugin",
+ author: "Paperclip",
+ categories: ["automation"],
+ capabilities: [],
+ entrypoints: {},
+ },
+ config: {},
+ databaseNamespace: null,
+ })).resolves.toMatchObject({ ok: true });
+
+ await expect(callWorker("performAction", {
+ key: "inspect",
+ params: { companyId: "spoofed-company" },
+ })).resolves.toEqual({
+ paramsCompanyId: "spoofed-company",
+ actor: {
+ type: "system",
+ userId: null,
+ agentId: null,
+ runId: null,
+ companyId: null,
+ },
+ companyId: null,
+ });
+ } finally {
+ worker.stop();
+ hostReadline.close();
+ hostToWorker.destroy();
+ workerToHost.destroy();
+ }
+ });
+});
+
describe("worker invocation scope propagation", () => {
it("keeps overlapping company scopes local to each getData invocation", async () => {
const hostToWorker = new PassThrough();
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index b0c92acb006..c102fa73f2a 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -1095,6 +1095,7 @@ export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number];
export const PLUGIN_BRIDGE_ERROR_CODES = [
"WORKER_UNAVAILABLE",
"CAPABILITY_DENIED",
+ "INVOCATION_SCOPE_DENIED",
"WORKER_ERROR",
"TIMEOUT",
"UNKNOWN",
diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
index 6e54689fc3e..9e1f06dfb36 100644
--- a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
+++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
@@ -68,13 +68,13 @@ rl.on("line", (line) => {
id: message.id,
result: {
ok: true,
- supportedMethods: ["getData"],
+ supportedMethods: ["getData", "performAction"],
},
});
return;
}
- if (method === "getData") {
+ if (method === "getData" || method === "performAction") {
sendNestedHostRequest(message, message.paperclipInvocation?.id);
return;
}
diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts
index ef328416d01..150625c3d0f 100644
--- a/server/src/__tests__/issues-service.test.ts
+++ b/server/src/__tests__/issues-service.test.ts
@@ -773,6 +773,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
const normalIssueId = randomUUID();
const pluginVisibleIssueId = randomUUID();
const operationIssueId = randomUUID();
+ const typedOperationIssueId = randomUUID();
+ const legacyContentMachineOperationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
@@ -826,12 +828,36 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
originKind: "plugin:paperclip.missions:operation",
originId: "mission-alpha:operation-1",
},
+ {
+ id: typedOperationIssueId,
+ companyId,
+ projectId,
+ title: "Typed plugin operation issue",
+ status: "todo",
+ priority: "medium",
+ assigneeAgentId: agentId,
+ originKind: "plugin:paperclip.missions:operation:evaluation",
+ originId: "mission-alpha:operation-2",
+ },
+ {
+ id: legacyContentMachineOperationIssueId,
+ companyId,
+ projectId,
+ title: "Legacy Content Machine operation issue",
+ status: "todo",
+ priority: "medium",
+ assigneeAgentId: agentId,
+ originKind: "plugin:paperclipai.content-machine:evaluation",
+ originId: "content-machine-operation-1",
+ },
]);
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
expect(defaultIssueIds).toContain(normalIssueId);
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
expect(defaultIssueIds).not.toContain(operationIssueId);
+ expect(defaultIssueIds).not.toContain(typedOperationIssueId);
+ expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId);
const inboxIssueIds = (await svc.list(companyId, {
assigneeAgentId: agentId,
@@ -840,17 +866,28 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
})).map((issue) => issue.id);
expect(inboxIssueIds).toContain(normalIssueId);
expect(inboxIssueIds).not.toContain(operationIssueId);
+ expect(inboxIssueIds).not.toContain(typedOperationIssueId);
+ expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId);
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
+ await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" }))
+ .resolves.toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: operationIssueId }),
+ expect.objectContaining({ id: typedOperationIssueId }),
+ ]));
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
expect(projectIssueIds).toContain(operationIssueId);
+ expect(projectIssueIds).toContain(typedOperationIssueId);
+ expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId);
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
expect(advancedIssueIds).toContain(operationIssueId);
+ expect(advancedIssueIds).toContain(typedOperationIssueId);
+ expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId);
});
it("excludes plugin operation issues from unread inbox counts", async () => {
diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts
index 52fe1c84750..272e20c3575 100644
--- a/server/src/__tests__/plugin-routes-authz.test.ts
+++ b/server/src/__tests__/plugin-routes-authz.test.ts
@@ -114,6 +114,17 @@ function boardActor(overrides: Record = {}) {
};
}
+function agentActor(overrides: Record = {}) {
+ return {
+ type: "agent",
+ agentId: agentA,
+ companyId: companyA,
+ runId: runA,
+ source: "agent_jwt",
+ ...overrides,
+ };
+}
+
function readyPlugin() {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
@@ -656,10 +667,160 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {},
+ actorContext: {
+ type: "user",
+ userId: "admin-1",
+ agentId: null,
+ runId: null,
+ companyId: null,
+ },
renderEnvironment: null,
});
});
+ it("passes authenticated actor context and overrides spoofed company scope for plugin actions", async () => {
+ readyPlugin();
+ const call = vi.fn().mockResolvedValue({ ok: true });
+ const { app } = await createApp(boardActor({ runId: runA }), {}, {
+ bridgeDeps: {
+ workerManager: { call },
+ },
+ });
+
+ const res = await request(app)
+ .post(`/api/plugins/${pluginId}/actions/sync`)
+ .send({
+ companyId: companyA,
+ params: {
+ companyId: companyB,
+ reviewerUserId: "spoofed-user",
+ },
+ });
+
+ expect(res.status).toBe(200);
+ expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
+ key: "sync",
+ params: {
+ companyId: companyA,
+ reviewerUserId: "spoofed-user",
+ },
+ actorContext: {
+ type: "user",
+ userId: "user-1",
+ agentId: null,
+ runId: runA,
+ companyId: companyA,
+ },
+ renderEnvironment: null,
+ });
+ });
+
+ it("uses null for board actor userId when no authenticated user id is present", async () => {
+ readyPlugin();
+ const call = vi.fn().mockResolvedValue({ ok: true });
+ const { app } = await createApp(boardActor({ userId: undefined }), {}, {
+ bridgeDeps: {
+ workerManager: { call },
+ },
+ });
+
+ const res = await request(app)
+ .post(`/api/plugins/${pluginId}/actions/sync`)
+ .send({ companyId: companyA });
+
+ expect(res.status).toBe(200);
+ expect(call).toHaveBeenCalledWith(pluginId, "performAction", expect.objectContaining({
+ actorContext: expect.objectContaining({
+ type: "user",
+ userId: null,
+ companyId: companyA,
+ }),
+ }));
+ });
+
+ it("allows agent-scoped plugin actions with authenticated actor context", async () => {
+ readyPlugin();
+ const call = vi.fn().mockResolvedValue({ ok: true });
+ const { app } = await createApp(agentActor(), {}, {
+ bridgeDeps: {
+ workerManager: { call },
+ },
+ });
+
+ const res = await request(app)
+ .post(`/api/plugins/${pluginId}/actions/sync`)
+ .send({
+ companyId: companyA,
+ params: {
+ companyId: companyB,
+ reviewerAgentId: "spoofed-agent",
+ },
+ });
+
+ expect(res.status).toBe(200);
+ expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
+ key: "sync",
+ params: {
+ companyId: companyA,
+ reviewerAgentId: "spoofed-agent",
+ },
+ actorContext: {
+ type: "agent",
+ userId: null,
+ agentId: agentA,
+ runId: runA,
+ companyId: companyA,
+ },
+ renderEnvironment: null,
+ });
+
+ call.mockClear();
+ const legacyRes = await request(app)
+ .post(`/api/plugins/${pluginId}/bridge/action`)
+ .send({
+ key: "sync",
+ companyId: companyA,
+ params: {
+ companyId: companyB,
+ reviewerAgentId: "spoofed-agent",
+ },
+ });
+
+ expect(legacyRes.status).toBe(200);
+ expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
+ key: "sync",
+ params: {
+ companyId: companyA,
+ reviewerAgentId: "spoofed-agent",
+ },
+ actorContext: {
+ type: "agent",
+ userId: null,
+ agentId: agentA,
+ runId: runA,
+ companyId: companyA,
+ },
+ renderEnvironment: null,
+ });
+ });
+
+ it("rejects agent plugin actions outside the authenticated company scope", async () => {
+ readyPlugin();
+ const call = vi.fn().mockResolvedValue({ ok: true });
+ const { app } = await createApp(agentActor(), {}, {
+ bridgeDeps: {
+ workerManager: { call },
+ },
+ });
+
+ const res = await request(app)
+ .post(`/api/plugins/${pluginId}/actions/sync`)
+ .send({ companyId: companyB });
+
+ expect(res.status).toBe(403);
+ expect(call).not.toHaveBeenCalled();
+ });
+
it("attaches worker bridge errors to the HTTP logger context", async () => {
readyPlugin();
const call = vi.fn().mockRejectedValue(new Error("missing source_objects column"));
diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts
index 626ed448cba..8d2d719401e 100644
--- a/server/src/__tests__/plugin-worker-manager.test.ts
+++ b/server/src/__tests__/plugin-worker-manager.test.ts
@@ -186,6 +186,58 @@ describe("plugin-worker-manager stderr failure context", () => {
}
});
+ it("passes performAction invocation scope to nested worker host calls", async () => {
+ const companiesGet = vi.fn(async (
+ params: { companyId: string },
+ context?: { invocationScope?: { companyId?: string | null } | null },
+ ) => ({
+ id: params.companyId,
+ scopedCompanyId: context?.invocationScope?.companyId ?? null,
+ }));
+ const handle = createPluginWorkerHandle("test.plugin", {
+ entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
+ manifest: TEST_MANIFEST,
+ config: {},
+ instanceInfo: {
+ instanceId: "instance-1",
+ hostVersion: "1.0.0",
+ },
+ apiVersion: 1,
+ hostHandlers: {
+ "companies.get": companiesGet as never,
+ },
+ });
+
+ try {
+ await handle.start();
+
+ await expect(handle.call("performAction", {
+ key: "probe",
+ params: {
+ mode: "echo",
+ requestedCompanyId: "company-a",
+ },
+ actorContext: {
+ type: "agent",
+ userId: null,
+ agentId: "agent-1",
+ runId: "run-1",
+ companyId: "company-a",
+ },
+ renderEnvironment: null,
+ })).resolves.toEqual({
+ id: "company-a",
+ scopedCompanyId: "company-a",
+ });
+ expect(companiesGet).toHaveBeenCalledWith(
+ { companyId: "company-a" },
+ { invocationScope: { companyId: "company-a" } },
+ );
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
it("passes echoed invocation scope to worker-to-host handlers", async () => {
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
const handle = createPluginWorkerHandle("test.plugin", {
@@ -223,6 +275,104 @@ describe("plugin-worker-manager stderr failure context", () => {
}
});
+ it("rejects performAction nested host calls that omit the invocation id", async () => {
+ const handlers = createHostClientHandlers({
+ pluginId: "test.plugin",
+ capabilities: ["companies.read"],
+ services: {
+ companies: {
+ list: vi.fn(async () => []),
+ get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })),
+ },
+ } as unknown as HostServices,
+ });
+ const handle = createPluginWorkerHandle("test.plugin", {
+ entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
+ manifest: TEST_MANIFEST,
+ config: {},
+ instanceInfo: {
+ instanceId: "instance-1",
+ hostVersion: "1.0.0",
+ },
+ apiVersion: 1,
+ hostHandlers: handlers,
+ });
+
+ try {
+ await handle.start();
+
+ await expect(handle.call("performAction", {
+ key: "probe",
+ params: {
+ requestedCompanyId: "company-b",
+ },
+ actorContext: {
+ type: "agent",
+ userId: null,
+ agentId: "agent-1",
+ runId: "run-1",
+ companyId: "company-a",
+ },
+ renderEnvironment: null,
+ })).rejects.toMatchObject({
+ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
+ message: expect.stringContaining("unknown invocation scope"),
+ });
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("rejects nested worker host calls that forge an unknown invocation id", async () => {
+ const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId }));
+ const handlers = createHostClientHandlers({
+ pluginId: "test.plugin",
+ capabilities: ["companies.read"],
+ services: {
+ companies: {
+ get: companiesGet,
+ },
+ } as unknown as HostServices,
+ });
+ const handle = createPluginWorkerHandle("test.plugin", {
+ entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
+ manifest: TEST_MANIFEST,
+ config: {},
+ instanceInfo: {
+ instanceId: "instance-1",
+ hostVersion: "1.0.0",
+ },
+ apiVersion: 1,
+ hostHandlers: handlers,
+ });
+
+ try {
+ await handle.start();
+
+ await expect(handle.call("performAction", {
+ key: "probe",
+ params: {
+ mode: "unknown",
+ requestedCompanyId: "company-a",
+ },
+ actorContext: {
+ type: "agent",
+ userId: null,
+ agentId: "agent-1",
+ runId: "run-1",
+ companyId: "company-a",
+ },
+ renderEnvironment: null,
+ })).rejects.toMatchObject({
+ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
+ message: expect.stringContaining("unknown invocation scope"),
+ });
+ expect(companiesGet).not.toHaveBeenCalled();
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
const hostHandlers = createHostClientHandlers({
@@ -258,7 +408,7 @@ describe("plugin-worker-manager stderr failure context", () => {
requestedCompanyId: "company-2",
},
} as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
- code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
+ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
});
}
diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts
index 741fa1513fe..d572c90d6fa 100644
--- a/server/src/routes/plugins.ts
+++ b/server/src/routes/plugins.ts
@@ -55,7 +55,7 @@ import type { PluginJobStore } from "../services/plugin-job-store.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
-import type { ToolRunContext } from "@paperclipai/plugin-sdk";
+import type { PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import {
assertAuthenticated,
@@ -572,6 +572,43 @@ export function pluginRoutes(
return companyId;
}
+ function performActionActorContext(req: Request, companyId: string | undefined): PluginPerformActionActorContext {
+ const scopedCompanyId = companyId ?? null;
+ if (req.actor.type === "agent") {
+ return {
+ type: "agent",
+ userId: null,
+ agentId: req.actor.agentId ?? null,
+ runId: req.actor.runId ?? null,
+ companyId: scopedCompanyId,
+ };
+ }
+ if (req.actor.type === "board") {
+ return {
+ type: "user",
+ userId: req.actor.userId ?? null,
+ agentId: null,
+ runId: req.actor.runId ?? null,
+ companyId: scopedCompanyId,
+ };
+ }
+ return {
+ type: "system",
+ userId: null,
+ agentId: null,
+ runId: req.actor.runId ?? null,
+ companyId: scopedCompanyId,
+ };
+ }
+
+ function actionParamsWithAuthorizedCompanyScope(
+ params: Record | undefined,
+ companyId: string | undefined,
+ ): Record {
+ const base = params ?? {};
+ return companyId === undefined ? base : { ...base, companyId };
+ }
+
async function validateToolRunContextScope(runContext: ToolRunContext): Promise {
const [agent] = await db
.select({ companyId: agents.companyId })
@@ -984,6 +1021,12 @@ export function pluginRoutes(
message: err.message,
details: err.data,
};
+ case PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED:
+ return {
+ code: "INVOCATION_SCOPE_DENIED",
+ message: err.message,
+ details: err.data,
+ };
case PLUGIN_RPC_ERROR_CODES.TIMEOUT:
return {
code: "TIMEOUT",
@@ -1171,7 +1214,7 @@ export function pluginRoutes(
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
router.post("/plugins/:pluginId/bridge/action", async (req, res) => {
- assertBoardOrgAccess(req);
+ assertAuthenticated(req);
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@@ -1217,8 +1260,8 @@ export function pluginRoutes(
"performAction",
{
key: body.key,
- ...(companyId ? { companyId } : {}),
- params: body.params ?? {},
+ params: actionParamsWithAuthorizedCompanyScope(body.params, companyId),
+ actorContext: performActionActorContext(req, companyId),
renderEnvironment: body.renderEnvironment ?? null,
},
);
@@ -1355,7 +1398,7 @@ export function pluginRoutes(
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
router.post("/plugins/:pluginId/actions/:key", async (req, res) => {
- assertBoardOrgAccess(req);
+ assertAuthenticated(req);
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@@ -1401,8 +1444,8 @@ export function pluginRoutes(
"performAction",
{
key,
- ...(companyId ? { companyId } : {}),
- params: body?.params ?? {},
+ params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId),
+ actorContext: performActionActorContext(req, companyId),
renderEnvironment: body?.renderEnvironment ?? null,
},
);
@@ -1606,7 +1649,10 @@ export function pluginRoutes(
} catch (err) {
const status = typeof (err as { status?: unknown }).status === "number"
? (err as { status: number }).status
- : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED
+ : err instanceof JsonRpcCallError && (
+ err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED ||
+ err.code === PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
+ )
? 403
: err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
? 501
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index 0ab88d3eebc..a61d7f2b892 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -683,14 +683,25 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) {
`;
}
+const LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS = [
+ "plugin:paperclipai.content-machine:case",
+ "plugin:paperclipai.content-machine:evaluation",
+ "plugin:paperclipai.content-machine:source-sync",
+] as const;
+
function nonPluginOperationIssueCondition() {
- return sql`NOT (${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%')`;
+ return sql`NOT (
+ ${issues.originKind} LIKE 'plugin:%:operation'
+ OR ${issues.originKind} LIKE 'plugin:%:operation:%'
+ OR ${inArray(issues.originKind, LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS)}
+ )`;
}
function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) {
return Boolean(
filters?.includePluginOperations ||
filters?.originKind ||
+ filters?.originKindPrefix ||
filters?.originId ||
filters?.projectId,
);
diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts
index 81f21a91f85..9847e879c16 100644
--- a/server/src/services/plugin-worker-manager.ts
+++ b/server/src/services/plugin-worker-manager.ts
@@ -204,6 +204,8 @@ interface PendingRequest {
timer: ReturnType;
/** Timestamp when the request was sent. */
sentAt: number;
+ /** Active host-owned invocation id attached to this host→worker call. */
+ invocationId?: string;
}
interface ActiveInvocation {
@@ -435,6 +437,14 @@ export function createPluginWorkerHandle(
childProcess.stdin.write(serialized);
}
+ function errorCodeForWorkerHostError(err: unknown): number {
+ const code = (err as { code?: unknown } | null)?.code;
+ const pluginErrorCodes: readonly number[] = Object.values(PLUGIN_RPC_ERROR_CODES);
+ return typeof code === "number" && pluginErrorCodes.includes(code)
+ ? code
+ : JSONRPC_ERROR_CODES.INTERNAL_ERROR;
+ }
+
// -----------------------------------------------------------------------
// Incoming message handling
// -----------------------------------------------------------------------
@@ -503,6 +513,11 @@ export function createPluginWorkerHandle(
const directCompanyId = readNonEmptyString(params.companyId);
if (directCompanyId) return { companyId: directCompanyId };
+ if (method === "performAction" && isRecord(params.actorContext)) {
+ const companyId = readNonEmptyString(params.actorContext.companyId);
+ return companyId ? { companyId } : null;
+ }
+
if (method === "executeTool" && isRecord(params.runContext)) {
const companyId = readNonEmptyString(params.runContext.companyId);
return companyId ? { companyId } : null;
@@ -585,15 +600,12 @@ export function createPluginWorkerHandle(
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
- const errorCode = typeof (err as { code?: unknown }).code === "number"
- ? (err as { code: number }).code
- : JSONRPC_ERROR_CODES.INTERNAL_ERROR;
log.error({ method, err: errorMessage }, "host handler error");
try {
sendMessage(
createErrorResponse(
request.id,
- errorCode,
+ errorCodeForWorkerHostError(err),
errorMessage,
),
);
@@ -1161,6 +1173,7 @@ export function createPluginWorkerHandle(
},
timer,
sentAt: Date.now(),
+ invocationId: invocation?.id,
};
pendingRequests.set(id, pending);
From e43b392a7931730c75f15b2c5deab212802d99c4 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 22 May 2026 09:56:22 -0500
Subject: [PATCH 3/4] [codex] Add local Cloud Upstream sync (#6548)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the control plane for AI-agent companies.
> - Operators need a path to move local company state toward Paperclip
Cloud without losing local-first control.
> - The Cloud Upstream flow needs API, persistence, CLI, and board UI
surfaces that agree on the same manifest/run model.
> - The existing branch had the feature work plus UX and error-handling
follow-ups.
> - This pull request packages the remaining Cloud Upstream sync work
into one standalone branch.
> - The benefit is an inspectable local-to-cloud sync workflow with
preview, conflicts, activation, and captured UX review states.
## What Changed
- Added Cloud Upstream shared types, server routes/services, and
persisted run schema/migration.
- Added Paperclip Cloud CLI sync helpers and local connection storage.
- Added the Cloud Upstream board UI, settings entry points, query keys,
and UX lab page.
- Added preview/activation checklist behavior, redirect handling,
manifest-only preview support, friendly errors, in-flight hints, and
entity count summaries.
## Verification
- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run cli/src/__tests__/cloud.test.ts
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/instance-settings-service.test.ts
ui/src/pages/CloudUpstream.test.tsx
ui/src/components/CompanySettingsSidebar.test.tsx`
- `NODE_ENV=test pnpm exec vitest run
server/src/__tests__/cloud-upstreams.test.ts`
Worktree setup note: the isolated worktree install skipped native sqlite
build scripts, so I copied the already-built local sqlite binding from
the main checkout before running
`server/src/__tests__/cloud-upstreams.test.ts`. The test then passed.
## Risks
- Medium: this adds a database migration and a broad feature path across
CLI/server/UI.
- Merge order: this is the only PR in this split with a DB migration;
merge it before any future Cloud Upstream migration follow-up.
- Mitigation: the PR is based directly on current `origin/master`, has
targeted route/service/UI tests, and keeps the feature behind existing
experimental Cloud Sync settings.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, screenshot artifacts are
intentionally omitted per reviewer request
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
cli/README.md | 15 +
cli/src/__tests__/cloud.test.ts | 243 +++
cli/src/commands/client/cloud-store.ts | 177 +++
cli/src/commands/client/cloud-transfer.ts | 297 ++++
cli/src/commands/client/cloud.ts | 721 +++++++++
cli/src/index.ts | 2 +
.../src/migrations/0089_cloud_upstreams.sql | 71 +
packages/db/src/migrations/meta/_journal.json | 7 +
packages/db/src/schema/cloud_upstreams.ts | 75 +
packages/db/src/schema/index.ts | 1 +
packages/shared/src/index.ts | 16 +
packages/shared/src/types/cloud-upstream.ts | 110 ++
packages/shared/src/types/instance.ts | 1 +
packages/shared/src/validators/instance.ts | 1 +
.../__tests__/cleanup-removal-service.test.ts | 30 +
server/src/__tests__/cloud-upstreams.test.ts | 334 +++++
.../instance-settings-routes.test.ts | 3 +
.../instance-settings-service.test.ts | 23 +
.../server-startup-feedback-export.test.ts | 1 +
server/src/index.ts | 14 +
server/src/routes/cloud-upstreams.ts | 118 ++
server/src/routes/index.ts | 1 +
server/src/services/cloud-upstreams.ts | 1298 +++++++++++++++++
server/src/services/companies.ts | 10 +
server/src/services/index.ts | 1 +
server/src/services/instance-settings.ts | 10 +-
ui/src/App.tsx | 4 +
ui/src/api/cloudUpstreams.ts | 40 +
.../CompanySettingsSidebar.test.tsx | 43 +
ui/src/components/CompanySettingsSidebar.tsx | 16 +-
ui/src/lib/queryKeys.ts | 1 +
ui/src/pages/CloudUpstream.test.tsx | 408 ++++++
ui/src/pages/CloudUpstream.tsx | 646 ++++++++
ui/src/pages/CloudUpstreamUxLab.tsx | 822 +++++++++++
ui/src/pages/CompanySettings.tsx | 20 +-
ui/src/pages/InstanceExperimentalSettings.tsx | 19 +
36 files changed, 5592 insertions(+), 7 deletions(-)
create mode 100644 cli/src/__tests__/cloud.test.ts
create mode 100644 cli/src/commands/client/cloud-store.ts
create mode 100644 cli/src/commands/client/cloud-transfer.ts
create mode 100644 cli/src/commands/client/cloud.ts
create mode 100644 packages/db/src/migrations/0089_cloud_upstreams.sql
create mode 100644 packages/db/src/schema/cloud_upstreams.ts
create mode 100644 packages/shared/src/types/cloud-upstream.ts
create mode 100644 server/src/__tests__/cloud-upstreams.test.ts
create mode 100644 server/src/__tests__/instance-settings-service.test.ts
create mode 100644 server/src/routes/cloud-upstreams.ts
create mode 100644 server/src/services/cloud-upstreams.ts
create mode 100644 ui/src/api/cloudUpstreams.ts
create mode 100644 ui/src/pages/CloudUpstream.test.tsx
create mode 100644 ui/src/pages/CloudUpstream.tsx
create mode 100644 ui/src/pages/CloudUpstreamUxLab.tsx
diff --git a/cli/README.md b/cli/README.md
index b23c32a69cf..8aa2cc16ac1 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -226,6 +226,21 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
+## Paperclip Cloud Sync
+
+Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing.
+
+```bash
+paperclipai cloud connect https://your-stack.paperclip.app
+paperclipai cloud connect https://your-stack.paperclip.app --no-browser
+paperclipai cloud push --company --dry-run
+paperclipai cloud push --company
+```
+
+`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code.
+
+`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events.
+
## Development
```bash
diff --git a/cli/src/__tests__/cloud.test.ts b/cli/src/__tests__/cloud.test.ts
new file mode 100644
index 00000000000..c66fd33f465
--- /dev/null
+++ b/cli/src/__tests__/cloud.test.ts
@@ -0,0 +1,243 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { CompanyPortabilityExportResult } from "@paperclipai/shared";
+import {
+ assertDiscoveryCompatible,
+ buildBundleFromLocalCompany,
+ cloudCommandExitCodes,
+ connectCloud,
+ resolveDeviceCodeExpiresAt,
+} from "../commands/client/cloud.js";
+import {
+ LocalUpstreamPushCoordinator,
+ normalizedContentHash,
+ type LocalUpstreamExportBundle,
+} from "../commands/client/cloud-transfer.js";
+import { getCloudConnection } from "../commands/client/cloud-store.js";
+
+const originalEnv = { ...process.env };
+const originalFetch = globalThis.fetch;
+
+describe("cloud CLI helpers", () => {
+ let tempHome: string;
+
+ beforeEach(() => {
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-"));
+ process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome };
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ globalThis.fetch = originalFetch;
+ vi.restoreAllMocks();
+ fs.rmSync(tempHome, { recursive: true, force: true });
+ });
+
+ it("connects with the device-code flow and stores the resulting cloud connection", async () => {
+ globalThis.fetch = vi.fn(async (url, init) => {
+ const requestUrl = String(url);
+ if (requestUrl.endsWith("/.well-known/paperclip-upstream")) {
+ return jsonResponse(discovery());
+ }
+ if (requestUrl.endsWith("/api/upstream-sync/device-code")) {
+ expect(JSON.parse(String(init?.body))).toMatchObject({
+ stackId: "stack-1",
+ scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
+ });
+ return jsonResponse({
+ deviceCode: "device-1",
+ userCode: "ABCD-EFGH",
+ verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve",
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ intervalSeconds: 0,
+ });
+ }
+ if (requestUrl.endsWith("/api/upstream-sync/token")) {
+ return jsonResponse({
+ accessToken: "upt_test",
+ scopes: ["upstream_import:preview"],
+ token: {
+ id: "token-1",
+ companyStackId: "stack-1",
+ targetOrigin: "https://cloud.example.test",
+ sourceInstanceId: "paperclip-local-default",
+ sourceInstanceFingerprint: "sha256:test",
+ scopes: ["upstream_import:preview"],
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ },
+ });
+ }
+ return jsonResponse({ error: "not_found" }, 404);
+ }) as typeof fetch;
+
+ const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true });
+
+ expect(connection.accessToken).toBe("upt_test");
+ expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1");
+ });
+
+ it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => {
+ expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i);
+ expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
+ });
+
+ it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => {
+ const now = Date.UTC(2026, 4, 22, 13, 0, 0);
+ const validExpiry = "2026-05-22T13:05:00.000Z";
+
+ expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry));
+ expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000);
+ expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000);
+ });
+
+ it("builds deterministic chunks with validated payload hashes", async () => {
+ const bundle = await buildTestBundle();
+
+ expect(bundle.chunks).toHaveLength(2);
+ expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload));
+ expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash);
+ expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey);
+ });
+
+ it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => {
+ const bundle = await buildTestBundle();
+ const calls: Array<{ path: string; body: unknown }> = [];
+ const coordinator = new LocalUpstreamPushCoordinator({
+ targetOrigin: "https://cloud.example.test",
+ paperclipCompanyId: "target-company-1",
+ fetch: async (url, init) => {
+ const parsed = new URL(String(url));
+ const body = init?.body ? JSON.parse(String(init.body)) as unknown : {};
+ calls.push({ path: parsed.pathname, body });
+ if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } });
+ return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } });
+ },
+ });
+
+ await coordinator.apply(bundle);
+ await coordinator.apply(bundle);
+
+ const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } });
+ const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string });
+ expect(runBodies).toHaveLength(2);
+ expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey);
+ expect(chunkBodies[0]).toEqual(chunkBodies[2]);
+ expect(chunkBodies[1]).toEqual(chunkBodies[3]);
+ });
+});
+
+async function buildTestBundle(): Promise {
+ return buildBundleFromLocalCompany({
+ localCompanyId: "local-company-1",
+ connection: {
+ id: "conn-1",
+ remoteUrl: "https://cloud.example.test",
+ targetOrigin: "https://cloud.example.test",
+ targetHost: "cloud.example.test",
+ stackId: "stack-1",
+ targetCompanyId: "target-company-1",
+ accessToken: "upt_test",
+ token: {
+ id: "token-1",
+ companyStackId: "stack-1",
+ targetOrigin: "https://cloud.example.test",
+ sourceInstanceId: "paperclip-local-default",
+ sourceInstanceFingerprint: "sha256:test",
+ scopes: ["upstream_import:preview"],
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ },
+ privateKeyPem: "unused",
+ sourcePublicKey: "unused",
+ sourceInstanceId: "paperclip-local-default",
+ sourceInstanceFingerprint: "sha256:test",
+ scopes: ["upstream_import:preview"],
+ createdAt: "2026-05-18T00:00:00.000Z",
+ updatedAt: "2026-05-18T00:00:00.000Z",
+ },
+ discovery: discovery(),
+ localApi: {
+ post: async () => portabilityExport() as T,
+ },
+ maxEntitiesPerChunk: 1,
+ mode: "apply",
+ });
+}
+
+function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) {
+ return {
+ schema: "paperclip-upstream-discovery-v1",
+ stack: {
+ id: "stack-1",
+ slug: "cloud-test",
+ displayName: "Cloud Test",
+ companyId: "target-company-1",
+ origin: "https://cloud.example.test",
+ },
+ auth: {
+ deviceCode: {
+ deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code",
+ verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve",
+ tokenUrl: "https://cloud.example.test/api/upstream-sync/token",
+ },
+ scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
+ },
+ transfer: {
+ supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1,
+ featureFlags: ["cloud_sync"],
+ },
+ };
+}
+
+function portabilityExport(): CompanyPortabilityExportResult {
+ return {
+ rootPath: ".",
+ paperclipExtensionPath: ".paperclip.yaml",
+ manifest: {
+ schemaVersion: 1,
+ generatedAt: "2026-05-18T00:00:00.000Z",
+ source: {
+ companyId: "local-company-1",
+ companyName: "Local Company",
+ },
+ includes: {
+ company: true,
+ agents: true,
+ projects: true,
+ issues: true,
+ skills: true,
+ },
+ company: {
+ path: "company.json",
+ name: "Local Company",
+ description: null,
+ brandColor: null,
+ logoPath: null,
+ attachmentMaxBytes: null,
+ requireBoardApprovalForNewAgents: false,
+ feedbackDataSharingEnabled: false,
+ feedbackDataSharingConsentAt: null,
+ feedbackDataSharingConsentByUserId: null,
+ feedbackDataSharingTermsVersion: null,
+ },
+ sidebar: null,
+ agents: [],
+ skills: [],
+ projects: [],
+ issues: [],
+ envInputs: [],
+ },
+ files: {
+ "README.md": "Local Company",
+ },
+ warnings: [],
+ };
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
diff --git a/cli/src/commands/client/cloud-store.ts b/cli/src/commands/client/cloud-store.ts
new file mode 100644
index 00000000000..fa63c7133b0
--- /dev/null
+++ b/cli/src/commands/client/cloud-store.ts
@@ -0,0 +1,177 @@
+import fs from "node:fs";
+import path from "node:path";
+import { resolvePaperclipInstanceRoot } from "../../config/home.js";
+
+export interface CloudConnectionTokenRecord {
+ id: string;
+ companyStackId: string;
+ targetOrigin: string;
+ sourceInstanceId: string;
+ sourceInstanceFingerprint: string;
+ scopes: string[];
+ expiresAt: string;
+ [key: string]: unknown;
+}
+
+export interface CloudConnection {
+ id: string;
+ remoteUrl: string;
+ targetOrigin: string;
+ targetHost: string;
+ stackId: string;
+ stackSlug?: string | null;
+ stackDisplayName?: string | null;
+ targetCompanyId: string;
+ accessToken: string;
+ token: CloudConnectionTokenRecord;
+ privateKeyPem: string;
+ sourcePublicKey: string;
+ sourceInstanceId: string;
+ sourceInstanceFingerprint: string;
+ scopes: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface CloudConnectionStore {
+ version: 1;
+ connections: Record;
+ currentConnectionId?: string;
+}
+
+function defaultStore(): CloudConnectionStore {
+ return {
+ version: 1,
+ connections: {},
+ };
+}
+
+export function resolveCloudConnectionStorePath(): string {
+ return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json");
+}
+
+export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore {
+ if (!fs.existsSync(storePath)) return defaultStore();
+ const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial | null;
+ const connections: Record = {};
+ if (raw?.connections && typeof raw.connections === "object") {
+ for (const [id, value] of Object.entries(raw.connections)) {
+ const normalized = normalizeConnection(value);
+ if (normalized) connections[id] = normalized;
+ }
+ }
+ const currentConnectionId =
+ typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId]
+ ? raw.currentConnectionId
+ : Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id;
+ return {
+ version: 1,
+ connections,
+ currentConnectionId,
+ };
+}
+
+export function writeCloudConnectionStore(
+ store: CloudConnectionStore,
+ storePath = resolveCloudConnectionStorePath(),
+): void {
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
+ fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
+}
+
+export function upsertCloudConnection(
+ connection: CloudConnection,
+ storePath = resolveCloudConnectionStorePath(),
+): CloudConnection {
+ const store = readCloudConnectionStore(storePath);
+ const existing = store.connections[connection.id];
+ const now = new Date().toISOString();
+ const next = {
+ ...connection,
+ createdAt: existing?.createdAt ?? connection.createdAt ?? now,
+ updatedAt: now,
+ };
+ store.connections[next.id] = next;
+ store.currentConnectionId = next.id;
+ writeCloudConnectionStore(store, storePath);
+ return next;
+}
+
+export function getCloudConnection(
+ remoteUrlOrOrigin?: string,
+ storePath = resolveCloudConnectionStorePath(),
+): CloudConnection | null {
+ const store = readCloudConnectionStore(storePath);
+ if (remoteUrlOrOrigin?.trim()) {
+ const needle = normalizeRemoteLookup(remoteUrlOrOrigin);
+ return Object.values(store.connections).find((connection) =>
+ normalizeRemoteLookup(connection.remoteUrl) === needle ||
+ normalizeRemoteLookup(connection.targetOrigin) === needle
+ ) ?? null;
+ }
+ return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null;
+}
+
+function normalizeRemoteLookup(value: string): string {
+ try {
+ const url = new URL(value);
+ return url.origin.replace(/\/+$/u, "");
+ } catch {
+ return value.trim().replace(/\/+$/u, "");
+ }
+}
+
+function normalizeConnection(value: unknown): CloudConnection | null {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
+ const record = value as Record;
+ const id = stringValue(record.id);
+ const remoteUrl = stringValue(record.remoteUrl);
+ const targetOrigin = stringValue(record.targetOrigin);
+ const targetHost = stringValue(record.targetHost);
+ const stackId = stringValue(record.stackId);
+ const targetCompanyId = stringValue(record.targetCompanyId);
+ const accessToken = stringValue(record.accessToken);
+ const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token)
+ ? record.token as CloudConnectionTokenRecord
+ : null;
+ const privateKeyPem = stringValue(record.privateKeyPem);
+ const sourcePublicKey = stringValue(record.sourcePublicKey);
+ const sourceInstanceId = stringValue(record.sourceInstanceId);
+ const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint);
+ const createdAt = stringValue(record.createdAt);
+ const updatedAt = stringValue(record.updatedAt);
+ if (
+ !id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId ||
+ !accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId ||
+ !sourceInstanceFingerprint || !createdAt || !updatedAt
+ ) {
+ return null;
+ }
+ return {
+ id,
+ remoteUrl,
+ targetOrigin,
+ targetHost,
+ stackId,
+ stackSlug: stringValue(record.stackSlug),
+ stackDisplayName: stringValue(record.stackDisplayName),
+ targetCompanyId,
+ accessToken,
+ token,
+ privateKeyPem,
+ sourcePublicKey,
+ sourceInstanceId,
+ sourceInstanceFingerprint,
+ scopes: stringArray(record.scopes),
+ createdAt,
+ updatedAt,
+ };
+}
+
+function stringValue(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+function stringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
+}
diff --git a/cli/src/commands/client/cloud-transfer.ts b/cli/src/commands/client/cloud-transfer.ts
new file mode 100644
index 00000000000..9cd1ccbe74a
--- /dev/null
+++ b/cli/src/commands/client/cloud-transfer.ts
@@ -0,0 +1,297 @@
+import { createHash } from "node:crypto";
+
+export const upstreamTransferSchema = {
+ family: "paperclip-upstream-transfer",
+ version: "1.0.0",
+ major: 1,
+ minor: 0,
+} as const;
+
+export type NormalizedSha256 = `sha256:${string}`;
+
+export interface SourceEntityKey {
+ sourceInstanceId: string;
+ sourceCompanyId: string;
+ sourceEntityType: string;
+ sourceEntityId: string;
+ sourceNaturalKey?: string;
+}
+
+export interface UpstreamTransferWarning {
+ code: string;
+ severity: "info" | "warning" | "blocker";
+ message: string;
+ entity?: SourceEntityKey;
+}
+
+export interface UpstreamTransferEntityRecord {
+ key: SourceEntityKey;
+ contentHash: NormalizedSha256;
+ dependencies: SourceEntityKey[];
+ warnings: UpstreamTransferWarning[];
+}
+
+export interface UpstreamTransferManifestSource {
+ sourceInstanceId: string;
+ sourceCompanyId: string;
+ sourceInstanceKeyFingerprint: string;
+ exporterVersion: string;
+ sourceSchemaVersion: string;
+}
+
+export interface UpstreamTransferManifestTarget {
+ targetStackId: string;
+ targetCompanyId: string;
+ targetOrigin: string;
+ supportedSchemaMajor: number;
+}
+
+export interface UpstreamTransferChunk {
+ chunkIndex: number;
+ totalChunks: number;
+ byteLength: number;
+ sha256: NormalizedSha256;
+ manifestHash: NormalizedSha256;
+}
+
+export interface UpstreamTransferManifest {
+ schema: typeof upstreamTransferSchema;
+ source: UpstreamTransferManifestSource;
+ target: UpstreamTransferManifestTarget;
+ runId: string;
+ idempotencyKey: string;
+ generatedAt: string;
+ entityCount: number;
+ entities: UpstreamTransferEntityRecord[];
+ chunks: UpstreamTransferChunk[];
+ warnings: UpstreamTransferWarning[];
+ featureFlags: string[];
+ manifestHash: NormalizedSha256;
+}
+
+export interface LocalUpstreamExportEntityInput {
+ key: SourceEntityKey;
+ body: Record;
+ dependencies?: SourceEntityKey[];
+ warnings?: UpstreamTransferWarning[];
+ conflictKeys?: string[];
+}
+
+export interface LocalUpstreamExportEntity {
+ record: UpstreamTransferEntityRecord;
+ body: Record;
+ conflictKeys?: string[];
+}
+
+export interface LocalUpstreamExportChunk {
+ chunkIndex: number;
+ totalChunks: number;
+ byteLength: number;
+ sha256: NormalizedSha256;
+ payload: {
+ entityKeys: SourceEntityKey[];
+ };
+}
+
+export interface LocalUpstreamExportBundle {
+ manifest: UpstreamTransferManifest;
+ entities: LocalUpstreamExportEntity[];
+ chunks: LocalUpstreamExportChunk[];
+}
+
+export interface BuildLocalUpstreamExportBundleInput {
+ source: UpstreamTransferManifestSource;
+ target: UpstreamTransferManifestTarget;
+ runId: string;
+ idempotencyKey: string;
+ entities: LocalUpstreamExportEntityInput[];
+ warnings?: UpstreamTransferWarning[];
+ featureFlags?: string[];
+ maxEntitiesPerChunk?: number;
+}
+
+export interface LocalUpstreamPushCoordinatorOptions {
+ targetOrigin: string;
+ paperclipCompanyId: string;
+ fetch?: typeof fetch;
+ headers?: (input: { method: string; path: string }) => HeadersInit | Promise;
+}
+
+export class UpstreamImportRequestError extends Error {
+ readonly status: number;
+ readonly body: unknown;
+
+ constructor(status: number, message: string, body: unknown) {
+ super(message);
+ this.status = status;
+ this.body = body;
+ }
+}
+
+export class LocalUpstreamPushCoordinator {
+ readonly #targetOrigin: string;
+ readonly #paperclipCompanyId: string;
+ readonly #fetch: typeof fetch;
+ readonly #headers: NonNullable;
+
+ constructor(options: LocalUpstreamPushCoordinatorOptions) {
+ this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, "");
+ this.#paperclipCompanyId = options.paperclipCompanyId;
+ this.#fetch = options.fetch ?? fetch;
+ this.#headers = options.headers ?? (() => ({}));
+ }
+
+ async preview(bundle: LocalUpstreamExportBundle): Promise {
+ return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, {
+ manifest: bundle.manifest,
+ entities: bundle.entities,
+ });
+ }
+
+ async apply(bundle: LocalUpstreamExportBundle): Promise {
+ const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, {
+ mode: "apply",
+ manifest: bundle.manifest,
+ entities: bundle.entities,
+ }) as { run?: { id?: unknown } };
+ const runId = typeof run.run?.id === "string" ? run.run.id : undefined;
+ if (!runId) {
+ throw new Error("Remote upstream importer did not return a run id");
+ }
+
+ for (const chunk of bundle.chunks) {
+ await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk);
+ }
+
+ return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {});
+ }
+
+ async events(runId: string): Promise {
+ return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`);
+ }
+
+ private async get(path: string): Promise {
+ const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
+ method: "GET",
+ headers: await this.#headers({ method: "GET", path }),
+ });
+ return parseCoordinatorResponse(response);
+ }
+
+ private async post(path: string, body: unknown): Promise {
+ const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(await this.#headers({ method: "POST", path })),
+ },
+ body: JSON.stringify(body),
+ });
+ return parseCoordinatorResponse(response);
+ }
+}
+
+export function buildLocalUpstreamExportBundle(
+ input: BuildLocalUpstreamExportBundleInput,
+): LocalUpstreamExportBundle {
+ const entities = input.entities.map((entity) => ({
+ record: {
+ key: entity.key,
+ contentHash: normalizedContentHash(entity.body),
+ dependencies: entity.dependencies ?? [],
+ warnings: entity.warnings ?? [],
+ },
+ body: entity.body,
+ conflictKeys: entity.conflictKeys,
+ }));
+ const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100);
+ const manifestWithoutHash = {
+ schema: upstreamTransferSchema,
+ source: input.source,
+ target: input.target,
+ runId: input.runId,
+ idempotencyKey: input.idempotencyKey,
+ generatedAt: new Date(0).toISOString(),
+ entityCount: entities.length,
+ entities: entities.map((entity) => entity.record),
+ chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk),
+ warnings: input.warnings ?? [],
+ featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(),
+ };
+ const manifestHash = normalizedContentHash(manifestWithoutHash);
+ return {
+ manifest: {
+ ...manifestWithoutHash,
+ chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })),
+ manifestHash,
+ },
+ entities,
+ chunks,
+ };
+}
+
+export function normalizedContentHash(value: unknown): NormalizedSha256 {
+ return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
+}
+
+export function canonicalJson(value: unknown): string {
+ return JSON.stringify(sortJson(value));
+}
+
+function buildLocalChunks(
+ entities: LocalUpstreamExportEntity[],
+ maxEntitiesPerChunk: number,
+): LocalUpstreamExportChunk[] {
+ if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) {
+ throw new Error("maxEntitiesPerChunk must be a positive integer");
+ }
+ if (entities.length === 0) return [];
+
+ const groups: LocalUpstreamExportEntity[][] = [];
+ for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) {
+ groups.push(entities.slice(index, index + maxEntitiesPerChunk));
+ }
+
+ return groups.map((group, index) => {
+ const payload = {
+ entityKeys: group.map((entity) => entity.record.key),
+ };
+ return {
+ chunkIndex: index,
+ totalChunks: groups.length,
+ byteLength: Buffer.byteLength(canonicalJson(payload)),
+ sha256: normalizedContentHash(payload),
+ payload,
+ };
+ });
+}
+
+function sortJson(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sortJson);
+ if (typeof value !== "object" || value === null) return value;
+ return Object.fromEntries(
+ Object.entries(value as Record)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, entry]) => [key, sortJson(entry)]),
+ );
+}
+
+async function parseCoordinatorResponse(response: Response): Promise {
+ const text = await response.text();
+ const parsed = text.trim() ? safeParseJson(text) : {};
+ if (!response.ok) {
+ const message = typeof parsed === "object" && parsed !== null && "error" in parsed
+ ? String((parsed as { error: unknown }).error)
+ : `Upstream importer request failed with ${response.status}`;
+ throw new UpstreamImportRequestError(response.status, message, parsed);
+ }
+ return parsed;
+}
+
+function safeParseJson(text: string): unknown {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+}
diff --git a/cli/src/commands/client/cloud.ts b/cli/src/commands/client/cloud.ts
new file mode 100644
index 00000000000..f87098a0b9b
--- /dev/null
+++ b/cli/src/commands/client/cloud.ts
@@ -0,0 +1,721 @@
+import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto";
+import { createServer, type Server } from "node:http";
+import { URL } from "node:url";
+import { Command } from "commander";
+import pc from "picocolors";
+import type {
+ CompanyPortabilityExportResult,
+ CompanyPortabilityFileEntry,
+ InstanceExperimentalSettings,
+} from "@paperclipai/shared";
+import { openUrl } from "../../client/board-auth.js";
+import { resolvePaperclipInstanceId } from "../../config/home.js";
+import {
+ addCommonClientOptions,
+ handleCommandError,
+ printOutput,
+ resolveCommandContext,
+ type BaseClientOptions,
+} from "./common.js";
+import {
+ buildLocalUpstreamExportBundle,
+ LocalUpstreamPushCoordinator,
+ normalizedContentHash,
+ upstreamTransferSchema,
+ UpstreamImportRequestError,
+ type LocalUpstreamExportBundle,
+ type LocalUpstreamExportEntityInput,
+ type SourceEntityKey,
+ type UpstreamTransferManifestSource,
+ type UpstreamTransferManifestTarget,
+ type UpstreamTransferWarning,
+} from "./cloud-transfer.js";
+import {
+ getCloudConnection,
+ upsertCloudConnection,
+ type CloudConnection,
+ type CloudConnectionTokenRecord,
+} from "./cloud-store.js";
+
+const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2;
+const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3;
+const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
+const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000;
+
+interface CloudConnectOptions extends BaseClientOptions {
+ noBrowser?: boolean;
+}
+
+interface CloudPushOptions extends BaseClientOptions {
+ company?: string;
+ remoteUrl?: string;
+ dryRun?: boolean;
+ maxEntitiesPerChunk?: number;
+}
+
+interface UpstreamDiscovery {
+ schema: string;
+ stack: {
+ id: string;
+ slug?: string;
+ displayName?: string;
+ companyId: string;
+ origin: string;
+ };
+ auth: {
+ pkce?: {
+ authorizeUrl: string;
+ tokenUrl: string;
+ codeChallengeMethod: string;
+ };
+ deviceCode?: {
+ deviceCodeUrl: string;
+ verificationUrl: string;
+ tokenUrl: string;
+ };
+ scopes?: string[];
+ };
+ transfer: {
+ supportedSchemaMajor: number;
+ featureFlags?: string[];
+ };
+}
+
+interface TokenResponse {
+ accessToken: string;
+ token: CloudConnectionTokenRecord;
+ scopes?: string[];
+ expiresAt?: string;
+}
+
+class CloudAuthRequestError extends Error {
+ readonly status: number;
+ readonly body: unknown;
+
+ constructor(status: number, message: string, body: unknown) {
+ super(message);
+ this.status = status;
+ this.body = body;
+ }
+}
+
+export function registerCloudCommands(program: Command): void {
+ const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands");
+
+ addCommonClientOptions(
+ cloud
+ .command("connect")
+ .description("Authorize this local instance to push into a Paperclip Cloud stack")
+ .argument("", "Paperclip Cloud stack URL")
+ .option("--no-browser", "Use the device-code flow instead of opening a browser", false)
+ .action(async (remoteUrl: string, opts: CloudConnectOptions) => {
+ try {
+ await connectCloud(remoteUrl, opts);
+ } catch (err) {
+ handleCommandError(err);
+ }
+ }),
+ );
+
+ addCommonClientOptions(
+ cloud
+ .command("push")
+ .description("Preview or apply a local company push into the connected Paperclip Cloud stack")
+ .requiredOption("--company ", "Local company ID to export")
+ .option("--remote-url ", "Use a specific stored cloud connection")
+ .option("--dry-run", "Preview without applying", false)
+ .option("--max-entities-per-chunk ", "Chunk size for upstream uploads", (value) => Number(value), 100)
+ .action(async (opts: CloudPushOptions) => {
+ try {
+ await pushCloud(opts);
+ } catch (err) {
+ if (isSchemaMismatchError(err)) {
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
+ process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE;
+ return;
+ }
+ handleCommandError(err);
+ }
+ }),
+ );
+}
+
+export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise {
+ const ctx = resolveCommandContext(opts);
+ const discovery = await discoverUpstream(remoteUrl);
+ assertDiscoveryCompatible(discovery);
+ const source = createSourceIdentity();
+ const token = await authorizeConnection(discovery, source, {
+ noBrowser: Boolean(opts.noBrowser),
+ });
+ const targetOrigin = discovery.stack.origin.replace(/\/+$/u, "");
+ const targetHost = new URL(targetOrigin).host;
+ const now = new Date().toISOString();
+ const connection = upsertCloudConnection({
+ id: connectionId(targetOrigin),
+ remoteUrl,
+ targetOrigin,
+ targetHost,
+ stackId: discovery.stack.id,
+ stackSlug: discovery.stack.slug ?? null,
+ stackDisplayName: discovery.stack.displayName ?? null,
+ targetCompanyId: discovery.stack.companyId,
+ accessToken: token.accessToken,
+ token: token.token,
+ privateKeyPem: source.privateKeyPem,
+ sourcePublicKey: source.sourcePublicKey,
+ sourceInstanceId: source.sourceInstanceId,
+ sourceInstanceFingerprint: source.sourceInstanceFingerprint,
+ scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ if (ctx.json) {
+ printOutput(redactConnection(connection), { json: true });
+ } else {
+ console.log(pc.bold("Connected to Paperclip Cloud"));
+ console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`);
+ console.log(`origin=${connection.targetOrigin}`);
+ console.log(`company=${connection.targetCompanyId}`);
+ }
+ return connection;
+}
+
+export async function pushCloud(opts: CloudPushOptions): Promise {
+ const ctx = resolveCommandContext(opts, { requireCompany: false });
+ const localCompanyId = requiredString(opts.company, "--company");
+ await assertCloudSyncEnabled(ctx.api.get("/api/instance/settings/experimental"));
+ const connection = getCloudConnection(opts.remoteUrl);
+ if (!connection) {
+ throw new Error("No cloud connection found. Run `paperclipai cloud connect ` first.");
+ }
+
+ const discovery = await discoverUpstream(connection.targetOrigin);
+ assertDiscoveryCompatible(discovery);
+ const bundle = await buildBundleFromLocalCompany({
+ localCompanyId,
+ connection,
+ discovery,
+ localApi: ctx.api,
+ maxEntitiesPerChunk: opts.maxEntitiesPerChunk,
+ mode: opts.dryRun ? "preview" : "apply",
+ });
+ const coordinator = new LocalUpstreamPushCoordinator({
+ targetOrigin: connection.targetOrigin,
+ paperclipCompanyId: connection.targetCompanyId,
+ headers: ({ method, path }) => cloudProofHeaders(connection, method, path),
+ });
+
+ const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle);
+ const runId = getRunId(result);
+ const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null;
+ const summary = summarizeResult(result);
+ const conflictCount = summary.conflict + summary.staleMapping;
+
+ if (ctx.json) {
+ printOutput({ result, events }, { json: true });
+ } else {
+ console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied"));
+ console.log(`run=${runId ?? "-"}`);
+ console.log(`manifest=${bundle.manifest.manifestHash}`);
+ console.log(
+ `create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` +
+ `skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`,
+ );
+ printWarnings(result);
+ printConflicts(result);
+ printEvents(events);
+ }
+
+ if (conflictCount > 0) {
+ process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE;
+ }
+ return result;
+}
+
+export async function discoverUpstream(remoteUrl: string): Promise {
+ const base = new URL(remoteUrl);
+ const discoveryUrl = new URL("/.well-known/paperclip-upstream", base);
+ return requestCloudJson(discoveryUrl.toString(), { method: "GET" });
+}
+
+export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void {
+ if (discovery.schema !== "paperclip-upstream-discovery-v1") {
+ throw new Error("Remote URL is not a Paperclip Cloud upstream target.");
+ }
+ if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) {
+ throw new Error(
+ `Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`,
+ );
+ }
+ if (!discovery.transfer.featureFlags?.includes("cloud_sync")) {
+ throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag.");
+ }
+}
+
+export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number {
+ const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
+ return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS;
+}
+
+export async function buildBundleFromLocalCompany(input: {
+ localCompanyId: string;
+ connection: CloudConnection;
+ discovery: UpstreamDiscovery;
+ localApi: {
+ post(path: string, body?: unknown): Promise;
+ };
+ maxEntitiesPerChunk?: number;
+ mode: "preview" | "apply";
+}): Promise {
+ const exported = await input.localApi.post(
+ `/api/companies/${input.localCompanyId}/export`,
+ {
+ include: {
+ company: true,
+ agents: true,
+ projects: true,
+ issues: true,
+ skills: true,
+ },
+ expandReferencedSkills: true,
+ },
+ );
+ if (!exported) throw new Error("Local company export returned no data.");
+
+ const sourceHash = normalizedContentHash({
+ manifest: exported.manifest,
+ files: exported.files,
+ });
+ const source: UpstreamTransferManifestSource = {
+ sourceInstanceId: input.connection.sourceInstanceId,
+ sourceCompanyId: input.localCompanyId,
+ sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint,
+ exporterVersion: "paperclipai-cli-cloud-v1",
+ sourceSchemaVersion: "paperclip-local-portability-v1",
+ };
+ const target: UpstreamTransferManifestTarget = {
+ targetStackId: input.discovery.stack.id,
+ targetCompanyId: input.discovery.stack.companyId,
+ targetOrigin: input.discovery.stack.origin,
+ supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor,
+ };
+ const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported);
+ const idempotencyKey = [
+ input.mode,
+ input.connection.sourceInstanceId,
+ input.localCompanyId,
+ input.discovery.stack.id,
+ sourceHash,
+ ].join(":");
+ return buildLocalUpstreamExportBundle({
+ source,
+ target,
+ runId: `local-${input.mode}-${shortHash(idempotencyKey)}`,
+ idempotencyKey,
+ entities,
+ warnings: exported.warnings.map((message): UpstreamTransferWarning => ({
+ code: "local_company_export_warning",
+ severity: "warning",
+ message,
+ })),
+ featureFlags: ["cloud_sync"],
+ maxEntitiesPerChunk: input.maxEntitiesPerChunk,
+ });
+}
+
+async function authorizeConnection(
+ discovery: UpstreamDiscovery,
+ source: ReturnType,
+ opts: { noBrowser: boolean },
+): Promise {
+ if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) {
+ try {
+ return await authorizeWithBrowser(discovery, source);
+ } catch (error) {
+ console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`));
+ }
+ }
+ if (!discovery.auth.deviceCode) {
+ throw new Error("Remote Paperclip Cloud stack does not support device-code authorization.");
+ }
+ return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() });
+}
+
+async function authorizeWithBrowser(
+ discovery: UpstreamDiscovery,
+ source: ReturnType,
+): Promise {
+ const pkce = discovery.auth.pkce;
+ if (!pkce) throw new Error("Remote did not advertise PKCE authorization.");
+ const callback = await startPkceCallbackServer();
+ const verifier = randomBytes(32).toString("base64url");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ const state = randomUUID();
+ const authorizeUrl = new URL(pkce.authorizeUrl);
+ authorizeUrl.searchParams.set("redirectUri", callback.redirectUri);
+ authorizeUrl.searchParams.set("state", state);
+ authorizeUrl.searchParams.set("codeChallenge", challenge);
+ authorizeUrl.searchParams.set("codeChallengeMethod", "S256");
+ authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId);
+ authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint);
+ authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey);
+ authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" "));
+
+ try {
+ console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`);
+ if (!openUrl(authorizeUrl.toString())) {
+ throw new Error("Could not open a browser.");
+ }
+ const code = await callback.waitForCode(state);
+ return requestCloudJson(pkce.tokenUrl, {
+ method: "POST",
+ body: JSON.stringify({
+ grantType: "authorization_code",
+ code,
+ redirectUri: callback.redirectUri,
+ codeVerifier: verifier,
+ }),
+ });
+ } finally {
+ await callback.close();
+ }
+}
+
+async function authorizeWithDeviceCode(
+ discovery: UpstreamDiscovery,
+ source: ReturnType,
+ opts: { openBrowser: boolean },
+): Promise {
+ const device = discovery.auth.deviceCode;
+ if (!device) throw new Error("Remote did not advertise device-code authorization.");
+ const response = await requestCloudJson<{
+ deviceCode: string;
+ userCode: string;
+ verificationUri: string;
+ expiresAt?: string;
+ intervalSeconds?: number;
+ }>(device.deviceCodeUrl, {
+ method: "POST",
+ body: JSON.stringify({
+ stackId: discovery.stack.id,
+ sourceInstanceId: source.sourceInstanceId,
+ sourceInstanceFingerprint: source.sourceInstanceFingerprint,
+ sourcePublicKey: source.sourcePublicKey,
+ scopes: CLOUD_SYNC_SCOPES,
+ }),
+ });
+ console.error(pc.bold("Cloud device authorization required"));
+ console.error(`Open: ${response.verificationUri}`);
+ console.error(`Code: ${response.userCode}`);
+ if (opts.openBrowser) openUrl(response.verificationUri);
+
+ const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt);
+ const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000);
+ while (Date.now() < expiresAt) {
+ await sleep(intervalMs);
+ try {
+ return await requestCloudJson(device.tokenUrl, {
+ method: "POST",
+ body: JSON.stringify({
+ grantType: "device_code",
+ deviceCode: response.deviceCode,
+ }),
+ });
+ } catch (error) {
+ if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") {
+ const code = (error.body as { error?: unknown }).error;
+ if (code === "authorization_pending") continue;
+ }
+ throw error;
+ }
+ }
+ throw new Error("Device-code authorization expired before it was approved.");
+}
+
+function buildEntitiesFromPortableExport(
+ localCompanyId: string,
+ sourceInstanceId: string,
+ exported: CompanyPortabilityExportResult,
+): LocalUpstreamExportEntityInput[] {
+ const companyKey: SourceEntityKey = {
+ sourceInstanceId,
+ sourceCompanyId: localCompanyId,
+ sourceEntityType: "company",
+ sourceEntityId: localCompanyId,
+ sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId,
+ };
+ const entities: LocalUpstreamExportEntityInput[] = [
+ {
+ key: companyKey,
+ body: {
+ kind: "paperclip_company_portability_manifest",
+ manifest: exported.manifest,
+ rootPath: exported.rootPath,
+ paperclipExtensionPath: exported.paperclipExtensionPath,
+ fileCount: Object.keys(exported.files).length,
+ },
+ conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`],
+ },
+ ];
+
+ for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) {
+ entities.push({
+ key: {
+ sourceInstanceId,
+ sourceCompanyId: localCompanyId,
+ sourceEntityType: "company_setting",
+ sourceEntityId: shortHash(filePath),
+ sourceNaturalKey: filePath,
+ },
+ body: {
+ kind: "paperclip_portable_file",
+ path: filePath,
+ entry: normalizePortableFileEntry(entry),
+ },
+ dependencies: [companyKey],
+ conflictKeys: [`portable_file:${filePath}`],
+ });
+ }
+ return entities;
+}
+
+function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record {
+ if (typeof entry === "string") {
+ return { encoding: "utf8", data: entry };
+ }
+ return { ...entry };
+}
+
+async function assertCloudSyncEnabled(settingsPromise: Promise): Promise {
+ const settings = await settingsPromise;
+ if (settings?.enableCloudSync !== true) {
+ throw new Error(
+ "Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.",
+ );
+ }
+}
+
+function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record {
+ const timestamp = new Date().toISOString();
+ const nonce = randomUUID();
+ const payload = [
+ method,
+ connection.targetHost.toLowerCase(),
+ pathAndSearch,
+ connection.token.id,
+ connection.sourceInstanceId,
+ timestamp,
+ nonce,
+ ].join("\n");
+ return {
+ Authorization: `Bearer ${connection.accessToken}`,
+ "X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId,
+ "X-Paperclip-Upstream-Proof-Timestamp": timestamp,
+ "X-Paperclip-Upstream-Proof-Nonce": nonce,
+ "X-Paperclip-Upstream-Proof-Signature": sign(
+ null,
+ Buffer.from(payload, "utf8"),
+ connection.privateKeyPem,
+ ).toString("base64url"),
+ };
+}
+
+async function requestCloudJson(url: string, init: RequestInit): Promise {
+ const headers = new Headers(init.headers);
+ headers.set("accept", "application/json");
+ if (init.body !== undefined && !headers.has("content-type")) {
+ headers.set("content-type", "application/json");
+ }
+ const response = await fetch(url, { ...init, headers });
+ const text = await response.text();
+ const parsed = text.trim() ? JSON.parse(text) as unknown : {};
+ if (!response.ok) {
+ const message = typeof parsed === "object" && parsed !== null && "error" in parsed
+ ? String((parsed as { error: unknown }).error)
+ : `Cloud request failed with ${response.status}`;
+ throw new CloudAuthRequestError(response.status, message, parsed);
+ }
+ return parsed as T;
+}
+
+function createSourceIdentity() {
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
+ const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString();
+ const sourceInstanceFingerprint = `sha256:${createHash("sha256")
+ .update(publicKey.export({ type: "spki", format: "der" }))
+ .digest("hex")}`;
+ return {
+ sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`,
+ sourceInstanceFingerprint,
+ sourcePublicKey,
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
+ };
+}
+
+async function startPkceCallbackServer(): Promise<{
+ redirectUri: string;
+ waitForCode: (state: string) => Promise;
+ close: () => Promise;
+}> {
+ let resolveCode: ((code: string) => void) | null = null;
+ let rejectCode: ((error: Error) => void) | null = null;
+ let expectedState = "";
+ const codePromise = new Promise((resolve, reject) => {
+ resolveCode = resolve;
+ rejectCode = reject;
+ });
+ const server = createServer((req, res) => {
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
+ const code = url.searchParams.get("code");
+ const state = url.searchParams.get("state");
+ if (!code || state !== expectedState) {
+ res.writeHead(400, { "Content-Type": "text/plain" });
+ res.end("Paperclip Cloud authorization failed. You can close this tab.");
+ rejectCode?.(new Error("Authorization callback was missing a valid code or state."));
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "text/plain" });
+ res.end("Paperclip Cloud authorization complete. You can close this tab.");
+ resolveCode?.(code);
+ });
+ await listenOnLoopback(server);
+ const address = server.address();
+ if (typeof address !== "object" || !address?.port) {
+ throw new Error("Failed to start local authorization callback server.");
+ }
+ return {
+ redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`,
+ waitForCode: (state: string) => {
+ expectedState = state;
+ return codePromise;
+ },
+ close: () => closeServer(server),
+ };
+}
+
+function listenOnLoopback(server: Server): Promise {
+ return new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ server.off("error", reject);
+ resolve();
+ });
+ });
+}
+
+function closeServer(server: Server): Promise {
+ return new Promise((resolve, reject) => {
+ server.close((error) => error ? reject(error) : resolve());
+ });
+}
+
+function canOpenBrowser(): boolean {
+ if (process.platform === "darwin" || process.platform === "win32") return true;
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
+}
+
+function summarizeResult(result: unknown): {
+ create: number;
+ update: number;
+ adopt: number;
+ skip: number;
+ conflict: number;
+ staleMapping: number;
+} {
+ const summary = asRecord(asRecord(result)?.summary);
+ return {
+ create: numberValue(summary?.create),
+ update: numberValue(summary?.update),
+ adopt: numberValue(summary?.adopt),
+ skip: numberValue(summary?.skip),
+ conflict: numberValue(summary?.conflict),
+ staleMapping: numberValue(summary?.staleMapping),
+ };
+}
+
+function printWarnings(result: unknown): void {
+ const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : [];
+ for (const warning of warnings) {
+ const record = asRecord(warning);
+ console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim()));
+ }
+}
+
+function printConflicts(result: unknown): void {
+ const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : [];
+ for (const conflict of conflicts.slice(0, 10)) {
+ const record = asRecord(conflict);
+ console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`));
+ }
+ if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`));
+}
+
+function printEvents(events: unknown): void {
+ const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : [];
+ for (const row of rows.slice(-10)) {
+ const event = asRecord(row);
+ console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`));
+ }
+}
+
+function getRunId(result: unknown): string | null {
+ const run = asRecord(asRecord(result)?.run);
+ return typeof run?.id === "string" ? run.id : null;
+}
+
+function redactConnection(connection: CloudConnection): Record {
+ return {
+ id: connection.id,
+ remoteUrl: connection.remoteUrl,
+ targetOrigin: connection.targetOrigin,
+ stackId: connection.stackId,
+ targetCompanyId: connection.targetCompanyId,
+ scopes: connection.scopes,
+ expiresAt: connection.token.expiresAt,
+ };
+}
+
+function connectionId(targetOrigin: string): string {
+ return `cloud-${shortHash(targetOrigin)}`;
+}
+
+function shortHash(value: string): string {
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
+}
+
+function requiredString(value: unknown, label: string): string {
+ if (typeof value === "string" && value.trim()) return value.trim();
+ throw new Error(`${label} is required.`);
+}
+
+function numberValue(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
+
+function asRecord(value: unknown): Record | null {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function isSchemaMismatchError(error: unknown): boolean {
+ if (error instanceof UpstreamImportRequestError) {
+ return JSON.stringify(error.body).toLowerCase().includes("schema");
+ }
+ return error instanceof Error && error.message.toLowerCase().includes("schema mismatch");
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export const cloudCommandExitCodes = {
+ conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE,
+ schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE,
+} as const;
diff --git a/cli/src/index.ts b/cli/src/index.ts
index f1a2084a727..b9fd115972c 100644
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -19,6 +19,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js";
import { registerRoutineCommands } from "./commands/routines.js";
import { registerFeedbackCommands } from "./commands/client/feedback.js";
import { registerSecretCommands } from "./commands/client/secrets.js";
+import { registerCloudCommands } from "./commands/client/cloud.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
@@ -149,6 +150,7 @@ registerDashboardCommands(program);
registerRoutineCommands(program);
registerFeedbackCommands(program);
registerSecretCommands(program);
+registerCloudCommands(program);
registerWorktreeCommands(program);
registerEnvLabCommands(program);
registerPluginCommands(program);
diff --git a/packages/db/src/migrations/0089_cloud_upstreams.sql b/packages/db/src/migrations/0089_cloud_upstreams.sql
new file mode 100644
index 00000000000..caf14522551
--- /dev/null
+++ b/packages/db/src/migrations/0089_cloud_upstreams.sql
@@ -0,0 +1,71 @@
+CREATE TABLE IF NOT EXISTS "cloud_upstream_connections" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "remote_url" text NOT NULL,
+ "source_instance_id" text NOT NULL,
+ "source_instance_fingerprint" text NOT NULL,
+ "source_public_key" text NOT NULL,
+ "private_key_pem" text NOT NULL,
+ "token_status" text NOT NULL,
+ "scopes" text[] DEFAULT '{}' NOT NULL,
+ "authorized_global_user_id" text,
+ "access_token" text,
+ "token_id" text,
+ "token_expires_at" timestamp with time zone,
+ "target_stack_id" text NOT NULL,
+ "target_stack_slug" text,
+ "target_stack_display_name" text,
+ "target_company_id" text NOT NULL,
+ "target_origin" text NOT NULL,
+ "target_primary_host" text NOT NULL,
+ "target_product" text NOT NULL,
+ "target_schema_major" integer NOT NULL,
+ "target_max_chunk_bytes" integer NOT NULL,
+ "pending_state" text,
+ "pending_code_verifier" text,
+ "pending_redirect_uri" text,
+ "pending_token_url" text,
+ "last_run_id" uuid,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "cloud_upstream_runs" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "connection_id" uuid NOT NULL,
+ "company_id" uuid NOT NULL,
+ "remote_run_id" text,
+ "status" text NOT NULL,
+ "active_step" text NOT NULL,
+ "progress_percent" integer DEFAULT 0 NOT NULL,
+ "dry_run" boolean DEFAULT false NOT NULL,
+ "retry_of_run_id" uuid,
+ "summary" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "conflicts" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "events" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "report" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "idempotency_key" text NOT NULL,
+ "manifest_hash" text NOT NULL,
+ "target_url" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "completed_at" timestamp with time zone
+);--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_connections_company_id_companies_id_fk') THEN
+ ALTER TABLE "cloud_upstream_connections" ADD CONSTRAINT "cloud_upstream_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk') THEN
+ ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."cloud_upstream_connections"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_company_id_companies_id_fk') THEN
+ ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "cloud_upstream_connections_company_idx" ON "cloud_upstream_connections" USING btree ("company_id");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_company_created_idx" ON "cloud_upstream_runs" USING btree ("company_id","created_at");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_connection_idx" ON "cloud_upstream_runs" USING btree ("connection_id");
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index 7aaa96d3da3..abaa1136e25 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -624,6 +624,13 @@
"when": 1779446400000,
"tag": "0088_backfill_principal_access_compatibility",
"breakpoints": true
+ },
+ {
+ "idx": 89,
+ "version": "7",
+ "when": 1779129600000,
+ "tag": "0089_cloud_upstreams",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/schema/cloud_upstreams.ts b/packages/db/src/schema/cloud_upstreams.ts
new file mode 100644
index 00000000000..93b4341f7e4
--- /dev/null
+++ b/packages/db/src/schema/cloud_upstreams.ts
@@ -0,0 +1,75 @@
+import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
+import { companies } from "./companies.js";
+
+export const cloudUpstreamConnections = pgTable(
+ "cloud_upstream_connections",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
+ remoteUrl: text("remote_url").notNull(),
+ sourceInstanceId: text("source_instance_id").notNull(),
+ sourceInstanceFingerprint: text("source_instance_fingerprint").notNull(),
+ sourcePublicKey: text("source_public_key").notNull(),
+ // Stored through the Cloud Upstream service as an encrypted credential envelope.
+ privateKeyPem: text("private_key_pem").notNull(),
+ tokenStatus: text("token_status").notNull(),
+ scopes: text("scopes").array().notNull().default([]),
+ authorizedGlobalUserId: text("authorized_global_user_id"),
+ // Stored through the Cloud Upstream service as an encrypted credential envelope.
+ accessToken: text("access_token"),
+ tokenId: text("token_id"),
+ tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
+
+ targetStackId: text("target_stack_id").notNull(),
+ targetStackSlug: text("target_stack_slug"),
+ targetStackDisplayName: text("target_stack_display_name"),
+ targetCompanyId: text("target_company_id").notNull(),
+ targetOrigin: text("target_origin").notNull(),
+ targetPrimaryHost: text("target_primary_host").notNull(),
+ targetProduct: text("target_product").notNull(),
+ targetSchemaMajor: integer("target_schema_major").notNull(),
+ targetMaxChunkBytes: integer("target_max_chunk_bytes").notNull(),
+
+ pendingState: text("pending_state"),
+ pendingCodeVerifier: text("pending_code_verifier"),
+ pendingRedirectUri: text("pending_redirect_uri"),
+ pendingTokenUrl: text("pending_token_url"),
+
+ lastRunId: uuid("last_run_id"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [
+ index("cloud_upstream_connections_company_idx").on(table.companyId),
+ ],
+);
+
+export const cloudUpstreamRuns = pgTable(
+ "cloud_upstream_runs",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ connectionId: uuid("connection_id").notNull().references(() => cloudUpstreamConnections.id, { onDelete: "cascade" }),
+ companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
+ remoteRunId: text("remote_run_id"),
+ status: text("status").notNull(),
+ activeStep: text("active_step").notNull(),
+ progressPercent: integer("progress_percent").notNull().default(0),
+ dryRun: boolean("dry_run").notNull().default(false),
+ retryOfRunId: uuid("retry_of_run_id"),
+ summary: jsonb("summary").$type().notNull().default([]),
+ warnings: jsonb("warnings").$type().notNull().default([]),
+ conflicts: jsonb("conflicts").$type().notNull().default([]),
+ events: jsonb("events").$type().notNull().default([]),
+ report: jsonb("report").$type>().notNull().default({}),
+ idempotencyKey: text("idempotency_key").notNull(),
+ manifestHash: text("manifest_hash").notNull(),
+ targetUrl: text("target_url"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ completedAt: timestamp("completed_at", { withTimezone: true }),
+ },
+ (table) => [
+ index("cloud_upstream_runs_company_created_idx").on(table.companyId, table.createdAt),
+ index("cloud_upstream_runs_connection_idx").on(table.connectionId),
+ ],
+);
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index fdf5f7cb92a..b7630a31656 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -2,6 +2,7 @@ export { companies } from "./companies.js";
export { companyLogos } from "./company_logos.js";
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
export { instanceSettings } from "./instance_settings.js";
+export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.js";
export { instanceUserRoles } from "./instance_user_roles.js";
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
export { agents } from "./agents.js";
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 9afa40f46a3..7f605326998 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -688,6 +688,22 @@ export {
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
} from "./types/instance.js";
+export type {
+ CloudUpstreamConnectStartResponse,
+ CloudUpstreamActivationDecision,
+ CloudUpstreamActivationEntityType,
+ CloudUpstreamConnection,
+ CloudUpstreamConflict,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamRunEvent,
+ CloudUpstreamsState,
+ CloudUpstreamStep,
+ CloudUpstreamSummaryCount,
+ CloudUpstreamTarget,
+ CloudUpstreamWarning,
+} from "./types/cloud-upstream.js";
+
export {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
diff --git a/packages/shared/src/types/cloud-upstream.ts b/packages/shared/src/types/cloud-upstream.ts
new file mode 100644
index 00000000000..211fa25d53d
--- /dev/null
+++ b/packages/shared/src/types/cloud-upstream.ts
@@ -0,0 +1,110 @@
+export type CloudUpstreamStep = "connect" | "scan" | "preview" | "push" | "verify" | "activate";
+
+export type CloudUpstreamRunStatus = "previewed" | "running" | "succeeded" | "failed" | "cancelled";
+
+export type CloudUpstreamActivationEntityType = "agents" | "routines" | "monitors";
+
+export interface CloudUpstreamActivationDecision {
+ entityType: CloudUpstreamActivationEntityType;
+ count: number;
+ status: "paused" | "activated";
+ activatedAt: string | null;
+}
+
+export interface CloudUpstreamTarget {
+ stackId: string;
+ stackSlug: string | null;
+ stackDisplayName: string | null;
+ companyId: string;
+ primaryHost: string;
+ origin: string;
+ product: string;
+ schemaMajor: number;
+ maxChunkBytes: number;
+}
+
+export interface CloudUpstreamConnection {
+ id: string;
+ companyId: string;
+ remoteUrl: string;
+ target: CloudUpstreamTarget;
+ tokenStatus: "pending" | "connected" | "expired" | "revoked";
+ scopes: string[];
+ authorizedGlobalUserId: string | null;
+ expiresAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+ lastRunId: string | null;
+}
+
+export interface CloudUpstreamSummaryCount {
+ key: string;
+ label: string;
+ count: number;
+}
+
+export interface CloudUpstreamWarning {
+ code: string;
+ severity: "warning" | "blocker";
+ title: string;
+ detail: string;
+}
+
+export interface CloudUpstreamConflict {
+ id: string;
+ entityType: string;
+ sourceLabel: string;
+ targetLabel: string;
+ plannedAction: "create" | "update" | "skip" | "blocked";
+ reason: string;
+}
+
+export interface CloudUpstreamPreview {
+ connectionId: string;
+ sourceCompanyId: string;
+ target: CloudUpstreamTarget;
+ schemaCompatible: boolean;
+ summary: CloudUpstreamSummaryCount[];
+ warnings: CloudUpstreamWarning[];
+ conflicts: CloudUpstreamConflict[];
+ generatedAt: string;
+}
+
+export interface CloudUpstreamRunEvent {
+ id: string;
+ at: string;
+ phase: CloudUpstreamStep;
+ type: "created" | "updated" | "skipped" | "conflict" | "retrying" | "failed" | "completed";
+ message: string;
+}
+
+export interface CloudUpstreamRun {
+ id: string;
+ connectionId: string;
+ companyId: string;
+ status: CloudUpstreamRunStatus;
+ activeStep: CloudUpstreamStep;
+ progressPercent: number;
+ dryRun: boolean;
+ summary: CloudUpstreamSummaryCount[];
+ warnings: CloudUpstreamWarning[];
+ conflicts: CloudUpstreamConflict[];
+ events: CloudUpstreamRunEvent[];
+ targetUrl: string | null;
+ report: Record;
+ retryOfRunId: string | null;
+ createdAt: string;
+ updatedAt: string;
+ completedAt: string | null;
+}
+
+export interface CloudUpstreamsState {
+ connections: CloudUpstreamConnection[];
+ runs: CloudUpstreamRun[];
+}
+
+export interface CloudUpstreamConnectStartResponse {
+ pendingConnectionId: string;
+ authorizationUrl: string;
+ connection: CloudUpstreamConnection;
+}
diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts
index ee6a6553470..3be7c6fd6ce 100644
--- a/packages/shared/src/types/instance.ts
+++ b/packages/shared/src/types/instance.ts
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean;
+ enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number;
diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts
index 3415539a2ad..52da4d5f637 100644
--- a/packages/shared/src/validators/instance.ts
+++ b/packages/shared/src/validators/instance.ts
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false),
+ enableCloudSync: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
issueGraphLivenessAutoRecoveryLookbackHours: z
diff --git a/server/src/__tests__/cleanup-removal-service.test.ts b/server/src/__tests__/cleanup-removal-service.test.ts
index 25dc11d679b..e65124cc9d3 100644
--- a/server/src/__tests__/cleanup-removal-service.test.ts
+++ b/server/src/__tests__/cleanup-removal-service.test.ts
@@ -9,6 +9,7 @@ import {
createDb,
documents,
documentRevisions,
+ heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
@@ -42,6 +43,7 @@ describeEmbeddedPostgres("cleanup removal services", () => {
}, 20_000);
afterEach(async () => {
+ await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await db.delete(issueReadStates);
await db.delete(issueComments);
@@ -228,4 +230,32 @@ describeEmbeddedPostgres("cleanup removal services", () => {
await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0);
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
});
+
+ it("removes heartbeat events by run id before deleting company-owned runs", async () => {
+ const { agentId, companyId, runId } = await seedFixture();
+ const otherCompanyId = randomUUID();
+
+ await db.insert(companies).values({
+ id: otherCompanyId,
+ name: "Other Company",
+ issuePrefix: `O${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+
+ await db.insert(heartbeatRunEvents).values({
+ companyId: otherCompanyId,
+ runId,
+ agentId,
+ seq: 1,
+ eventType: "output",
+ message: "event with mismatched company scope",
+ });
+
+ const removed = await companyService(db).remove(companyId);
+
+ expect(removed?.id).toBe(companyId);
+ await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0);
+ await expect(db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).resolves.toHaveLength(0);
+ await expect(db.select().from(companies).where(eq(companies.id, otherCompanyId))).resolves.toHaveLength(1);
+ });
});
diff --git a/server/src/__tests__/cloud-upstreams.test.ts b/server/src/__tests__/cloud-upstreams.test.ts
new file mode 100644
index 00000000000..fce78dfd1ef
--- /dev/null
+++ b/server/src/__tests__/cloud-upstreams.test.ts
@@ -0,0 +1,334 @@
+import { generateKeyPairSync, randomUUID } from "node:crypto";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { companies, cloudUpstreamConnections, cloudUpstreamRuns, companySkills, createDb } from "@paperclipai/db";
+
+import { HttpError } from "../errors.js";
+import {
+ cloudUpstreamRemoteFailureReport,
+ cloudUpstreamService,
+ reconcileCloudUpstreamRunsOnStartup,
+ sealCloudUpstreamCredential,
+ unsealCloudUpstreamCredential,
+} from "../services/cloud-upstreams.js";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+if (!embeddedPostgresSupport.supported) {
+ console.warn(
+ `Skipping embedded Postgres cloud upstream tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
+ );
+}
+
+describe("cloud upstream remote failures", () => {
+ it("preserves the cloud response body and message on run reports", () => {
+ const body = {
+ error: "bad_request",
+ message: "entities[42].body must be an object",
+ errors: [{ path: "entities[42].body" }],
+ };
+
+ expect(cloudUpstreamRemoteFailureReport(new HttpError(400, "bad_request", body))).toEqual({
+ error: "bad_request",
+ errorMessage: "entities[42].body must be an object",
+ details: body,
+ });
+ });
+
+ it("falls back to the thrown error message for non-remote failures", () => {
+ expect(cloudUpstreamRemoteFailureReport(new Error("network failed"))).toEqual({
+ error: "network failed",
+ });
+ });
+});
+
+describe("cloud upstream credential storage", () => {
+ const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
+
+ afterEach(() => {
+ if (previousMasterKey === undefined) {
+ delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
+ } else {
+ process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
+ }
+ });
+
+ it("stores new credentials as encrypted envelopes and preserves legacy plaintext reads", async () => {
+ process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
+ const sealed = await sealCloudUpstreamCredential("cloud-access-token");
+
+ expect(sealed).toMatch(/^paperclip-cloud-credential:/);
+ expect(sealed).not.toContain("cloud-access-token");
+ await expect(unsealCloudUpstreamCredential(sealed)).resolves.toBe("cloud-access-token");
+ await expect(unsealCloudUpstreamCredential("legacy-plaintext-token")).resolves.toBe("legacy-plaintext-token");
+ });
+});
+
+describeEmbeddedPostgres("cloud upstream persistence", () => {
+ let db!: ReturnType;
+ let tempDb: Awaited> | null = null;
+ const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
+
+ beforeAll(async () => {
+ process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-upstreams-");
+ db = createDb(tempDb.connectionString);
+ }, 20_000);
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await db.delete(cloudUpstreamRuns);
+ await db.delete(cloudUpstreamConnections);
+ await db.delete(companySkills);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ if (previousMasterKey === undefined) {
+ delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
+ } else {
+ process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
+ }
+ await tempDb?.cleanup();
+ });
+
+ it("encrypts stored upstream credentials while keeping connection flows usable", async () => {
+ const companyId = randomUUID();
+ await seedCompany(companyId);
+ const tokenUrl = "https://cloud.example.test/oauth/token";
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
+ const url = String(input);
+ if (url.startsWith("https://cloud.example.test/.well-known/paperclip-upstream")) {
+ return jsonResponse({
+ product: "Paperclip Cloud",
+ stack: {
+ id: "stack-1",
+ companyId: "cloud-company-1",
+ origin: "https://cloud.example.test",
+ primaryHost: "cloud.example.test",
+ },
+ transfer: {
+ supportedSchemaMajor: 1,
+ maxChunkBytes: 8192,
+ },
+ auth: {
+ scopes: ["upstream_import:write"],
+ pkce: {
+ authorizeUrl: "https://cloud.example.test/oauth/authorize",
+ tokenUrl,
+ },
+ },
+ });
+ }
+ if (url === tokenUrl && init?.method === "POST") {
+ const payload = JSON.parse(String(init.body));
+ expect(payload.codeVerifier).toEqual(expect.any(String));
+ expect(payload.codeVerifier).not.toContain("paperclip-cloud-credential:");
+ return jsonResponse({
+ accessToken: "cloud-access-token",
+ token: {
+ id: "token-1",
+ expiresAt: "2026-05-22T13:00:00.000Z",
+ globalUserId: "user-1",
+ },
+ });
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ });
+
+ const service = cloudUpstreamService(db, { instanceId: "test" });
+ const started = await service.startConnect({
+ companyId,
+ remoteUrl: "https://cloud.example.test",
+ redirectUri: "http://localhost:3100/callback",
+ });
+ await service.finishConnect({
+ pendingConnectionId: started.pendingConnectionId,
+ code: "auth-code",
+ state: new URL(started.authorizationUrl).searchParams.get("state") ?? "",
+ });
+
+ const [row] = await db.select().from(cloudUpstreamConnections);
+ expect(row.privateKeyPem).toMatch(/^paperclip-cloud-credential:/);
+ expect(row.privateKeyPem).not.toContain("BEGIN PRIVATE KEY");
+ expect(row.accessToken).toMatch(/^paperclip-cloud-credential:/);
+ expect(row.accessToken).not.toContain("cloud-access-token");
+ });
+
+ it("marks orphaned running runs failed during startup reconciliation", async () => {
+ const companyId = randomUUID();
+ const connectionId = randomUUID();
+ const runningRunId = randomUUID();
+ const succeededRunId = randomUUID();
+ const reconciledAt = new Date("2026-05-22T13:00:00.000Z");
+ await seedCompany(companyId);
+ await db.insert(cloudUpstreamConnections).values({
+ id: connectionId,
+ companyId,
+ remoteUrl: "https://cloud.example.test",
+ sourceInstanceId: "source-1",
+ sourceInstanceFingerprint: "sha256:test",
+ sourcePublicKey: "public-key",
+ privateKeyPem: "legacy-private-key",
+ tokenStatus: "connected",
+ scopes: ["upstream_import:write"],
+ authorizedGlobalUserId: "user-1",
+ accessToken: "legacy-token",
+ tokenId: "token-1",
+ targetStackId: "stack-1",
+ targetCompanyId: "cloud-company-1",
+ targetOrigin: "https://cloud.example.test",
+ targetPrimaryHost: "cloud.example.test",
+ targetProduct: "Paperclip Cloud",
+ targetSchemaMajor: 1,
+ targetMaxChunkBytes: 8192,
+ });
+ await db.insert(cloudUpstreamRuns).values([
+ cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
+ cloudRunRow({ id: succeededRunId, connectionId, companyId, status: "succeeded", completedAt: reconciledAt }),
+ ]);
+
+ await expect(reconcileCloudUpstreamRunsOnStartup(db, reconciledAt)).resolves.toEqual({ reconciled: 1 });
+
+ const rows = await db.select().from(cloudUpstreamRuns);
+ const running = rows.find((row) => row.id === runningRunId);
+ const succeeded = rows.find((row) => row.id === succeededRunId);
+ expect(running?.status).toBe("failed");
+ expect(running?.completedAt?.toISOString()).toBe(reconciledAt.toISOString());
+ expect(running?.events.at(-1)?.message).toContain("server startup");
+ expect(running?.report).toMatchObject({
+ error: "orphaned_running_run",
+ reconciledAt: reconciledAt.toISOString(),
+ });
+ expect(succeeded?.status).toBe("succeeded");
+ });
+
+ it("rejects a new run when the connection already has a running run", async () => {
+ const companyId = randomUUID();
+ const connectionId = randomUUID();
+ const runningRunId = randomUUID();
+ await seedCompany(companyId);
+ await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
+ await db.insert(cloudUpstreamRuns).values(
+ cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
+ );
+
+ await expect(cloudUpstreamService(db).createRun({ connectionId, companyId })).rejects.toMatchObject({
+ status: 409,
+ details: { runId: runningRunId },
+ });
+ });
+
+ it("preserves a cancelled run when an in-flight createRun tries to finish", async () => {
+ const companyId = randomUUID();
+ const connectionId = randomUUID();
+ await seedCompany(companyId);
+ await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
+
+ const service = cloudUpstreamService(db);
+ const remoteCalls: string[] = [];
+ globalThis.fetch = vi.fn(async (input) => {
+ const path = new URL(String(input)).pathname;
+ remoteCalls.push(path);
+ if (path.endsWith("/upstream-imports/runs")) {
+ return jsonResponse({ run: { id: "remote-run-1" } });
+ }
+ if (path.endsWith("/chunks")) {
+ const run = await db.select().from(cloudUpstreamRuns).then((rows) => rows[0]);
+ expect(run?.status).toBe("running");
+ await service.cancelRun(connectionId, run.id, companyId);
+ return jsonResponse({ ok: true });
+ }
+ if (path.endsWith("/cancel")) {
+ return jsonResponse({ ok: true });
+ }
+ if (path.endsWith("/apply")) {
+ return jsonResponse({ ok: true });
+ }
+ if (path.endsWith("/events")) {
+ return jsonResponse({ events: [] });
+ }
+ return jsonResponse({ error: "not_found" }, 404);
+ }) as typeof fetch;
+
+ const result = await service.createRun({ connectionId, companyId });
+
+ expect(result.status).toBe("cancelled");
+ expect(remoteCalls.some((path) => path.endsWith("/apply"))).toBe(false);
+ const rows = await db.select().from(cloudUpstreamRuns);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.status).toBe("cancelled");
+ });
+
+ async function seedCompany(companyId: string) {
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+ }
+});
+
+function jsonResponse(body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function cloudConnectionRow(input: { id: string; companyId: string }) {
+ const { privateKey } = generateKeyPairSync("ed25519");
+ return {
+ id: input.id,
+ companyId: input.companyId,
+ remoteUrl: "https://cloud.example.test",
+ sourceInstanceId: "source-1",
+ sourceInstanceFingerprint: "sha256:test",
+ sourcePublicKey: "public-key",
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
+ tokenStatus: "connected",
+ scopes: ["upstream_import:write"],
+ authorizedGlobalUserId: "user-1",
+ accessToken: "legacy-token",
+ tokenId: "token-1",
+ targetStackId: "stack-1",
+ targetCompanyId: "cloud-company-1",
+ targetOrigin: "https://cloud.example.test",
+ targetPrimaryHost: "cloud.example.test",
+ targetProduct: "Paperclip Cloud",
+ targetSchemaMajor: 1,
+ targetMaxChunkBytes: 8192,
+ };
+}
+
+function cloudRunRow(input: {
+ id: string;
+ connectionId: string;
+ companyId: string;
+ status: string;
+ completedAt?: Date;
+}) {
+ return {
+ id: input.id,
+ connectionId: input.connectionId,
+ companyId: input.companyId,
+ status: input.status,
+ activeStep: "push",
+ progressPercent: input.status === "running" ? 45 : 100,
+ dryRun: false,
+ summary: [],
+ warnings: [],
+ conflicts: [],
+ events: [],
+ report: {},
+ idempotencyKey: `key-${input.id}`,
+ manifestHash: `sha256:${input.id.replace(/-/g, "")}`,
+ targetUrl: "https://cloud.example.test",
+ completedAt: input.completedAt,
+ };
+}
diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts
index 41e52190eb8..5d324cd4b02 100644
--- a/server/src/__tests__/instance-settings-routes.test.ts
+++ b/server/src/__tests__/instance-settings-routes.test.ts
@@ -64,6 +64,7 @@ describe("instance settings routes", () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableEnvironments: false,
enableIsolatedWorkspaces: false,
+ enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -81,6 +82,7 @@ describe("instance settings routes", () => {
experimental: {
enableEnvironments: true,
enableIsolatedWorkspaces: true,
+ enableCloudSync: true,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -123,6 +125,7 @@ describe("instance settings routes", () => {
expect(getRes.body).toEqual({
enableEnvironments: false,
enableIsolatedWorkspaces: false,
+ enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts
new file mode 100644
index 00000000000..6669414d697
--- /dev/null
+++ b/server/src/__tests__/instance-settings-service.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { normalizeExperimentalSettings } from "../services/instance-settings.js";
+
+describe("instance settings service", () => {
+ it("ignores retired experimental flags without resetting current settings", () => {
+ expect(normalizeExperimentalSettings({
+ enableEnvironments: true,
+ enableIsolatedWorkspaces: true,
+ enableCloudSync: true,
+ autoRestartDevServerWhenIdle: true,
+ enableIssueGraphLivenessAutoRecovery: true,
+ issueGraphLivenessAutoRecoveryLookbackHours: 48,
+ enableNewestFirstIssueThread: true,
+ })).toEqual({
+ enableEnvironments: true,
+ enableIsolatedWorkspaces: true,
+ enableCloudSync: true,
+ autoRestartDevServerWhenIdle: true,
+ enableIssueGraphLivenessAutoRecovery: true,
+ issueGraphLivenessAutoRecoveryLookbackHours: 48,
+ });
+ });
+});
diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts
index f1023a91533..3cdab7c4ac8 100644
--- a/server/src/__tests__/server-startup-feedback-export.test.ts
+++ b/server/src/__tests__/server-startup-feedback-export.test.ts
@@ -166,6 +166,7 @@ vi.mock("../services/index.js", () => ({
},
})),
})),
+ reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })),
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
routineService: vi.fn(() => ({
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
diff --git a/server/src/index.ts b/server/src/index.ts
index bb89a46e567..d9c52d27d65 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -33,6 +33,7 @@ import {
backfillPrincipalAccessCompatibility,
heartbeatService,
instanceSettingsService,
+ reconcileCloudUpstreamRunsOnStartup,
reconcilePersistedRuntimeServicesOnStartup,
routineService,
} from "./services/index.js";
@@ -699,6 +700,19 @@ export async function startServer(): Promise {
.catch((err) => {
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
});
+
+ void reconcileCloudUpstreamRunsOnStartup(db as any)
+ .then((result) => {
+ if (result.reconciled > 0) {
+ logger.warn(
+ { reconciled: result.reconciled },
+ "reconciled cloud upstream runs from a previous server process",
+ );
+ }
+ })
+ .catch((err) => {
+ logger.error({ err }, "startup reconciliation of cloud upstream runs failed");
+ });
if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any, { pluginWorkerManager });
diff --git a/server/src/routes/cloud-upstreams.ts b/server/src/routes/cloud-upstreams.ts
new file mode 100644
index 00000000000..085afe470db
--- /dev/null
+++ b/server/src/routes/cloud-upstreams.ts
@@ -0,0 +1,118 @@
+import { Router } from "express";
+import type { Db } from "@paperclipai/db";
+import { badRequest, notFound } from "../errors.js";
+import { assertBoardOrgAccess } from "./authz.js";
+import { cloudUpstreamService, instanceSettingsService } from "../services/index.js";
+
+export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {}) {
+ const router = Router();
+ const service = cloudUpstreamService(db, options);
+ const settings = instanceSettingsService(db);
+
+ async function assertEnabled() {
+ const experimental = await settings.getExperimental();
+ if (experimental.enableCloudSync !== true) {
+ throw notFound("Cloud sync is not enabled");
+ }
+ }
+
+ router.get("/cloud-upstreams", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ const companyId = stringQuery(req.query.companyId, "companyId");
+ res.json(await service.list(companyId));
+ });
+
+ router.post("/cloud-upstreams/connect/start", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ const companyId = stringBody(req.body, "companyId");
+ const remoteUrl = stringBody(req.body, "remoteUrl");
+ const redirectUri = stringBody(req.body, "redirectUri");
+ res.json(await service.startConnect({ companyId, remoteUrl, redirectUri }));
+ });
+
+ router.post("/cloud-upstreams/connect/finish", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.finishConnect({
+ pendingConnectionId: stringBody(req.body, "pendingConnectionId"),
+ code: stringBody(req.body, "code"),
+ state: stringBody(req.body, "state"),
+ }));
+ });
+
+ router.post("/cloud-upstreams/:connectionId/push-runs/preview", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.preview(req.params.connectionId, stringBody(req.body, "companyId")));
+ });
+
+ router.post("/cloud-upstreams/:connectionId/push-runs", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.createRun({
+ connectionId: req.params.connectionId,
+ companyId: stringBody(req.body, "companyId"),
+ retryOfRunId: optionalString(req.body?.retryOfRunId),
+ }));
+ });
+
+ router.get("/cloud-upstreams/:connectionId/push-runs/:runId", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.readRun(req.params.connectionId, req.params.runId, stringQuery(req.query.companyId, "companyId")));
+ });
+
+ router.post("/cloud-upstreams/:connectionId/push-runs/:runId/cancel", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.cancelRun(req.params.connectionId, req.params.runId, stringBody(req.body, "companyId")));
+ });
+
+ router.post("/cloud-upstreams/:connectionId/push-runs/:runId/activation", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.activateRunEntities({
+ connectionId: req.params.connectionId,
+ runId: req.params.runId,
+ companyId: stringBody(req.body, "companyId"),
+ entityType: activationEntityTypeBody(req.body),
+ }));
+ });
+
+ return router;
+}
+
+function stringQuery(value: unknown, label: string): string {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw badRequest(`${label} is required`);
+ }
+ return value;
+}
+
+function stringBody(body: unknown, key: string): string {
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ throw badRequest(`${key} is required`);
+ }
+ const value = (body as Record)[key];
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw badRequest(`${key} is required`);
+ }
+ return value;
+}
+
+function optionalString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function activationEntityTypeBody(body: unknown): "agents" | "routines" | "monitors" {
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ throw badRequest("entityType is required");
+ }
+ const value = (body as Record).entityType;
+ if (value !== "agents" && value !== "routines" && value !== "monitors") {
+ throw badRequest("entityType must be agents, routines, or monitors");
+ }
+ return value;
+}
diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts
index 983562e265a..74987ed4d61 100644
--- a/server/src/routes/index.ts
+++ b/server/src/routes/index.ts
@@ -19,3 +19,4 @@ export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
+export { cloudUpstreamRoutes } from "./cloud-upstreams.js";
diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts
new file mode 100644
index 00000000000..765dd4329b3
--- /dev/null
+++ b/server/src/services/cloud-upstreams.ts
@@ -0,0 +1,1298 @@
+import crypto, { sign } from "node:crypto";
+import { and, count, desc, eq, sql } from "drizzle-orm";
+import type {
+ CloudUpstreamConnectStartResponse,
+ CloudUpstreamActivationDecision,
+ CloudUpstreamActivationEntityType,
+ CloudUpstreamConnection,
+ CloudUpstreamConflict,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamRunEvent,
+ CloudUpstreamsState,
+ CloudUpstreamSummaryCount,
+ CloudUpstreamTarget,
+ CloudUpstreamWarning,
+ CompanyPortabilityExportResult,
+ CompanyPortabilityFileEntry,
+} from "@paperclipai/shared";
+import type { Db } from "@paperclipai/db";
+import {
+ agents,
+ cloudUpstreamConnections,
+ cloudUpstreamRuns,
+ companies,
+ goals,
+ issueComments,
+ issues,
+ projects,
+ routines,
+} from "@paperclipai/db";
+import { badRequest, conflict, HttpError, notFound } from "../errors.js";
+import { companyPortabilityService } from "./company-portability.js";
+import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
+
+const DEFAULT_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
+const TRANSFER_SCHEMA = {
+ family: "paperclip-upstream-transfer",
+ version: "1.0.0",
+ major: 1,
+ minor: 0,
+} as const;
+const DEFAULT_MAX_ENTITIES_PER_CHUNK = 100;
+const DISCOVERY_FETCH_TIMEOUT_MS = 30_000;
+const REMOTE_FETCH_TIMEOUT_MS = 120_000;
+const CLOUD_CREDENTIAL_PREFIX = "paperclip-cloud-credential:";
+
+type NormalizedSha256 = `sha256:${string}`;
+
+type SourceEntityKey = {
+ sourceInstanceId: string;
+ sourceCompanyId: string;
+ sourceEntityType: string;
+ sourceEntityId: string;
+ sourceNaturalKey?: string;
+};
+
+type UpstreamTransferWarning = {
+ code: string;
+ severity: "info" | "warning" | "blocker";
+ message: string;
+ entity?: SourceEntityKey;
+};
+
+type UpstreamTransferEntityRecord = {
+ key: SourceEntityKey;
+ contentHash: NormalizedSha256;
+ dependencies: SourceEntityKey[];
+ warnings: UpstreamTransferWarning[];
+};
+
+type LocalUpstreamExportEntity = {
+ record: UpstreamTransferEntityRecord;
+ body: Record;
+ conflictKeys?: string[];
+};
+
+type LocalUpstreamExportChunk = {
+ chunkIndex: number;
+ totalChunks: number;
+ byteLength: number;
+ sha256: NormalizedSha256;
+ payload: {
+ entityKeys: SourceEntityKey[];
+ };
+};
+
+type UpstreamTransferManifest = {
+ schema: typeof TRANSFER_SCHEMA;
+ source: {
+ sourceInstanceId: string;
+ sourceCompanyId: string;
+ sourceInstanceKeyFingerprint: string;
+ exporterVersion: string;
+ sourceSchemaVersion: string;
+ };
+ target: {
+ targetStackId: string;
+ targetCompanyId: string;
+ targetOrigin: string;
+ supportedSchemaMajor: number;
+ };
+ runId: string;
+ idempotencyKey: string;
+ generatedAt: string;
+ entityCount: number;
+ entities: UpstreamTransferEntityRecord[];
+ chunks: Array & { manifestHash: NormalizedSha256 }>;
+ warnings: UpstreamTransferWarning[];
+ featureFlags: string[];
+ manifestHash: NormalizedSha256;
+};
+
+type LocalUpstreamExportBundle = {
+ manifest: UpstreamTransferManifest;
+ entities: LocalUpstreamExportEntity[];
+ chunks: LocalUpstreamExportChunk[];
+};
+
+type ConnectionRow = typeof cloudUpstreamConnections.$inferSelect;
+type RunRow = typeof cloudUpstreamRuns.$inferSelect;
+
+export function cloudUpstreamService(db: Db, options: { instanceId?: string } = {}) {
+ const sourceInstanceId = `paperclip-local-${options.instanceId ?? "default"}`;
+ const portability = companyPortabilityService(db);
+
+ return {
+ list: async (companyId: string): Promise => {
+ const [connectionRows, runRows] = await Promise.all([
+ db
+ .select()
+ .from(cloudUpstreamConnections)
+ .where(eq(cloudUpstreamConnections.companyId, companyId))
+ .orderBy(desc(cloudUpstreamConnections.updatedAt)),
+ db
+ .select()
+ .from(cloudUpstreamRuns)
+ .where(eq(cloudUpstreamRuns.companyId, companyId))
+ .orderBy(desc(cloudUpstreamRuns.createdAt))
+ .limit(50),
+ ]);
+ return {
+ connections: connectionRows.map(connectionFromRow),
+ runs: runRows.map(runFromRow),
+ };
+ },
+
+ startConnect: async (input: {
+ companyId: string;
+ remoteUrl: string;
+ redirectUri: string;
+ }): Promise => {
+ await requireCompany(input.companyId);
+ const remoteUrl = input.remoteUrl.trim();
+ if (!remoteUrl) throw badRequest("Remote URL is required");
+
+ const discovery = await fetchDiscovery(remoteUrl);
+ const target = targetFromDiscovery(discovery);
+ const connectionId = crypto.randomUUID();
+ const state = crypto.randomBytes(24).toString("base64url");
+ const codeVerifier = crypto.randomBytes(32).toString("base64url");
+ const codeChallenge = crypto.createHash("sha256").update(codeVerifier, "utf8").digest("base64url");
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
+ const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString();
+ const sourceInstanceFingerprint = `sha256:${crypto
+ .createHash("sha256")
+ .update(publicKey.export({ type: "spki", format: "der" }))
+ .digest("hex")}`;
+
+ const [row] = await db.insert(cloudUpstreamConnections).values({
+ id: connectionId,
+ companyId: input.companyId,
+ remoteUrl,
+ sourceInstanceId,
+ sourceInstanceFingerprint,
+ sourcePublicKey,
+ privateKeyPem: await sealCloudUpstreamCredential(privateKey.export({ type: "pkcs8", format: "pem" }).toString()),
+ tokenStatus: "pending",
+ scopes: scopesFromDiscovery(discovery),
+ targetStackId: target.stackId,
+ targetStackSlug: target.stackSlug,
+ targetStackDisplayName: target.stackDisplayName,
+ targetCompanyId: target.companyId,
+ targetOrigin: target.origin,
+ targetPrimaryHost: target.primaryHost,
+ targetProduct: target.product,
+ targetSchemaMajor: target.schemaMajor,
+ targetMaxChunkBytes: target.maxChunkBytes,
+ pendingState: state,
+ pendingCodeVerifier: await sealCloudUpstreamCredential(codeVerifier),
+ pendingRedirectUri: input.redirectUri,
+ pendingTokenUrl: tokenUrlFromDiscovery(discovery),
+ }).returning();
+ if (!row) throw badRequest("Failed to create cloud upstream connection");
+
+ const authorizationUrl = new URL(consentUrlFromDiscovery(discovery));
+ authorizationUrl.searchParams.set("stackId", target.stackId);
+ authorizationUrl.searchParams.set("redirectUri", input.redirectUri);
+ authorizationUrl.searchParams.set("state", state);
+ authorizationUrl.searchParams.set("codeChallenge", codeChallenge);
+ authorizationUrl.searchParams.set("codeChallengeMethod", "S256");
+ authorizationUrl.searchParams.set("sourceInstanceId", sourceInstanceId);
+ authorizationUrl.searchParams.set("sourceInstanceFingerprint", sourceInstanceFingerprint);
+ authorizationUrl.searchParams.set("sourcePublicKey", sourcePublicKey);
+ authorizationUrl.searchParams.set("scopes", row.scopes.join(" "));
+
+ return {
+ pendingConnectionId: row.id,
+ authorizationUrl: authorizationUrl.toString(),
+ connection: connectionFromRow(row),
+ };
+ },
+
+ finishConnect: async (input: {
+ pendingConnectionId: string;
+ code: string;
+ state: string;
+ }): Promise => {
+ const pending = await getConnectionRow(input.pendingConnectionId);
+ if (!pending.pendingState || !pending.pendingCodeVerifier || !pending.pendingRedirectUri || !pending.pendingTokenUrl) {
+ throw notFound("Pending cloud upstream connection was not found");
+ }
+ if (input.state !== pending.pendingState) throw badRequest("Cloud upstream state did not match");
+ const tokenResponse = await postJson>(pending.pendingTokenUrl, {
+ grantType: "authorization_code",
+ code: input.code,
+ redirectUri: pending.pendingRedirectUri,
+ codeVerifier: await unsealCloudUpstreamCredential(pending.pendingCodeVerifier),
+ });
+ const accessToken = stringField(tokenResponse, "accessToken");
+ const token = objectField(tokenResponse, "token");
+ const expiresAt = optionalString(token.expiresAt) ?? optionalString(tokenResponse.expiresAt);
+ const [updated] = await db
+ .update(cloudUpstreamConnections)
+ .set({
+ tokenStatus: "connected",
+ authorizedGlobalUserId: optionalString(token.globalUserId),
+ accessToken: await sealCloudUpstreamCredential(accessToken),
+ tokenId: optionalString(token.id),
+ tokenExpiresAt: expiresAt ? new Date(expiresAt) : null,
+ pendingState: null,
+ pendingCodeVerifier: null,
+ pendingRedirectUri: null,
+ pendingTokenUrl: null,
+ updatedAt: new Date(),
+ })
+ .where(eq(cloudUpstreamConnections.id, pending.id))
+ .returning();
+ if (!updated) throw notFound("Cloud upstream connection was not found");
+ return connectionFromRow(updated);
+ },
+
+ preview: async (connectionId: string, companyId: string): Promise => {
+ const connection = await getConnectionRow(connectionId, companyId);
+ const basePreview = await localPreview(connection);
+ if (!basePreview.schemaCompatible || connection.tokenStatus !== "connected") {
+ return basePreview;
+ }
+
+ const bundle = await buildBundle(connection, "preview");
+ const conflictKeysBySource: Record = {};
+ for (const entity of bundle.entities) {
+ if (!entity.conflictKeys || entity.conflictKeys.length === 0) continue;
+ conflictKeysBySource[sourceEntityKeyString(entity.record.key)] = [...entity.conflictKeys];
+ }
+ const remotePreview = await remotePost(connection, `/api/companies/${encodeURIComponent(connection.targetCompanyId)}/upstream-imports/preview`, {
+ manifest: bundle.manifest,
+ previewShape: "manifest_only",
+ conflictKeysBySource,
+ });
+ return {
+ ...basePreview,
+ warnings: mergeWarnings(basePreview.warnings, warningsFromRemote(remotePreview)),
+ conflicts: conflictsFromRemote(remotePreview),
+ };
+ },
+
+ createRun: async (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }): Promise => {
+ const connection = await getConnectionRow(input.connectionId, input.companyId);
+ if (connection.tokenStatus !== "connected") {
+ throw badRequest("Cloud upstream connection is not connected");
+ }
+ await assertNoRunningRun(input.connectionId, input.companyId, db);
+ const preview = await localPreview(connection);
+ if (!preview.schemaCompatible) {
+ throw badRequest("Cloud stack schema is not compatible with this local Paperclip version");
+ }
+
+ const bundle = await buildBundle(connection, "apply");
+ const runId = crypto.randomUUID();
+ const now = new Date();
+ const initialEvents = [
+ event(now.toISOString(), "connect", "completed", "Connected to the target Paperclip Cloud stack."),
+ event(now.toISOString(), "scan", "completed", "Scanned the local company inventory."),
+ event(now.toISOString(), "preview", "completed", "Generated the transfer manifest."),
+ ...(input.retryOfRunId
+ ? [event(now.toISOString(), "push", "retrying", `Retrying run ${input.retryOfRunId} with the same import ledger idempotency key.`)]
+ : []),
+ ];
+ const created = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select ${cloudUpstreamConnections.id} from ${cloudUpstreamConnections} where ${cloudUpstreamConnections.id} = ${connection.id} and ${cloudUpstreamConnections.companyId} = ${connection.companyId} for update`,
+ );
+ await assertNoRunningRun(input.connectionId, input.companyId, tx);
+ const [row] = await tx.insert(cloudUpstreamRuns).values({
+ id: runId,
+ connectionId: connection.id,
+ companyId: connection.companyId,
+ status: "running",
+ activeStep: "push",
+ progressPercent: 45,
+ dryRun: false,
+ retryOfRunId: input.retryOfRunId ?? null,
+ summary: preview.summary,
+ warnings: preview.warnings,
+ conflicts: preview.conflicts,
+ events: initialEvents,
+ report: {},
+ idempotencyKey: bundle.manifest.idempotencyKey,
+ manifestHash: bundle.manifest.manifestHash,
+ targetUrl: connection.targetOrigin,
+ createdAt: now,
+ updatedAt: now,
+ }).returning();
+ return row;
+ });
+ if (!created) throw badRequest("Failed to create cloud upstream run");
+
+ try {
+ const remoteRun = await remotePost(connection, `/api/companies/${encodeURIComponent(connection.targetCompanyId)}/upstream-imports/runs`, {
+ mode: "apply",
+ manifest: bundle.manifest,
+ entities: bundle.entities,
+ });
+ const remoteRunId = remoteRunIdFromResponse(remoteRun);
+ const pushedRun = await updateRunIfRunning(runId, {
+ remoteRunId,
+ activeStep: "push",
+ progressPercent: 60,
+ events: [
+ ...initialEvents,
+ event(new Date().toISOString(), "push", "updated", "Created or resumed the cloud import ledger run."),
+ ],
+ });
+ if (pushedRun.status !== "running") return pushedRun;
+
+ for (const chunk of bundle.chunks) {
+ await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/chunks`, chunk);
+ }
+ const verifiedRun = await updateRunIfRunning(runId, {
+ activeStep: "verify",
+ progressPercent: 82,
+ events: [
+ ...initialEvents,
+ event(new Date().toISOString(), "push", "completed", `Uploaded ${bundle.chunks.length} manifest chunk${bundle.chunks.length === 1 ? "" : "s"}.`),
+ ],
+ });
+ if (verifiedRun.status !== "running") return verifiedRun;
+
+ const applied = await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/apply`, {});
+ const remoteEvents = await remoteGet(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/events`).catch(() => null);
+ const completedAt = new Date();
+ const finalEvents = [
+ ...initialEvents,
+ event(completedAt.toISOString(), "push", "completed", "Pushed mapped objects without duplicate creation."),
+ event(completedAt.toISOString(), "verify", "completed", "Verified the cloud import ledger and generated a run report."),
+ event(completedAt.toISOString(), "activate", "completed", "Activation checklist is ready for manual unpause decisions."),
+ ...eventsFromRemote(remoteEvents),
+ ];
+ const finalRun = await updateRunIfRunning(runId, {
+ remoteRunId,
+ status: "succeeded",
+ activeStep: "activate",
+ progressPercent: 100,
+ warnings: mergeWarnings(preview.warnings, warningsFromRemote(applied)),
+ conflicts: conflictsFromRemote(applied),
+ events: finalEvents,
+ report: {
+ runId,
+ remoteRunId,
+ target: targetFromConnectionRow(connection),
+ manifestHash: bundle.manifest.manifestHash,
+ idempotencyKey: bundle.manifest.idempotencyKey,
+ retryOfRunId: input.retryOfRunId ?? null,
+ result: applied,
+ events: remoteEvents,
+ },
+ completedAt,
+ });
+ if (finalRun.status === "succeeded") {
+ await db
+ .update(cloudUpstreamConnections)
+ .set({ lastRunId: finalRun.id, updatedAt: new Date() })
+ .where(eq(cloudUpstreamConnections.id, connection.id));
+ }
+ return finalRun;
+ } catch (error) {
+ const failedAt = new Date();
+ const failure = cloudUpstreamRemoteFailureReport(error);
+ return updateRunIfRunning(runId, {
+ status: "failed",
+ activeStep: "push",
+ progressPercent: 100,
+ events: [
+ ...initialEvents,
+ event(failedAt.toISOString(), "push", "failed", failure.errorMessage ?? failure.error),
+ ],
+ report: {
+ runId,
+ target: targetFromConnectionRow(connection),
+ manifestHash: bundle.manifest.manifestHash,
+ idempotencyKey: bundle.manifest.idempotencyKey,
+ retryOfRunId: input.retryOfRunId ?? null,
+ ...failure,
+ },
+ completedAt: failedAt,
+ });
+ }
+ },
+
+ readRun: async (connectionId: string, runId: string, companyId: string): Promise => {
+ const row = await getRunRow(connectionId, runId, companyId);
+ return runFromRow(row);
+ },
+
+ cancelRun: async (connectionId: string, runId: string, companyId: string): Promise => {
+ const row = await getRunRow(connectionId, runId, companyId);
+ if (row.status !== "running") return runFromRow(row);
+ const connection = await getConnectionRow(connectionId, companyId);
+ if (row.remoteRunId) {
+ await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(row.remoteRunId)}/cancel`, {}).catch(() => null);
+ }
+ return updateRun(row.id, {
+ status: "cancelled",
+ activeStep: "push",
+ progressPercent: 100,
+ completedAt: new Date(),
+ events: [
+ ...row.events,
+ event(new Date().toISOString(), "push", "failed", "Push cancelled locally before remote apply completed."),
+ ],
+ });
+ },
+
+ activateRunEntities: async (input: {
+ connectionId: string;
+ runId: string;
+ companyId: string;
+ entityType: CloudUpstreamActivationEntityType;
+ }): Promise => {
+ const row = await getRunRow(input.connectionId, input.runId, input.companyId);
+ assertActivationEntityType(input.entityType);
+ if (row.status !== "succeeded") {
+ throw badRequest("Only succeeded cloud upstream runs can activate imported entities");
+ }
+
+ const activatedAt = new Date().toISOString();
+ const count = summaryCount(row.summary, input.entityType);
+ const nextDecision: CloudUpstreamActivationDecision = {
+ entityType: input.entityType,
+ count,
+ status: "activated",
+ activatedAt,
+ };
+ const report = asRecord(row.report);
+ const activationChecklist = activationChecklistFromReport(report);
+ const label = activationEntityLabel(input.entityType, count);
+
+ return updateRun(row.id, {
+ report: {
+ ...report,
+ activationChecklist: {
+ ...activationChecklist,
+ [input.entityType]: nextDecision,
+ },
+ },
+ events: [
+ ...row.events,
+ event(activatedAt, "activate", "completed", `Activated ${count} imported ${label}.`),
+ ],
+ });
+ },
+ };
+
+ async function requireCompany(companyId: string) {
+ const row = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).then((rows) => rows[0]);
+ if (!row) throw notFound("Company was not found");
+ }
+
+ async function getConnectionRow(connectionId: string, companyId?: string): Promise {
+ const row = await db
+ .select()
+ .from(cloudUpstreamConnections)
+ .where(companyId
+ ? and(eq(cloudUpstreamConnections.id, connectionId), eq(cloudUpstreamConnections.companyId, companyId))
+ : eq(cloudUpstreamConnections.id, connectionId))
+ .then((rows) => rows[0]);
+ if (!row) throw notFound("Cloud upstream connection was not found");
+ return row;
+ }
+
+ async function getRunRow(connectionId: string, runId: string, companyId: string): Promise {
+ const row = await db
+ .select()
+ .from(cloudUpstreamRuns)
+ .where(and(
+ eq(cloudUpstreamRuns.id, runId),
+ eq(cloudUpstreamRuns.connectionId, connectionId),
+ eq(cloudUpstreamRuns.companyId, companyId),
+ ))
+ .then((rows) => rows[0]);
+ if (!row) throw notFound("Cloud upstream run was not found");
+ return row;
+ }
+
+ async function assertNoRunningRun(
+ connectionId: string,
+ companyId: string,
+ database: Pick,
+ ) {
+ const [running] = await database
+ .select({ id: cloudUpstreamRuns.id })
+ .from(cloudUpstreamRuns)
+ .where(and(
+ eq(cloudUpstreamRuns.connectionId, connectionId),
+ eq(cloudUpstreamRuns.companyId, companyId),
+ eq(cloudUpstreamRuns.status, "running"),
+ ))
+ .limit(1);
+ if (running) {
+ throw conflict("A cloud upstream run is already running for this connection", { runId: running.id });
+ }
+ }
+
+ async function updateRun(runId: string, patch: Partial): Promise {
+ const [updated] = await db
+ .update(cloudUpstreamRuns)
+ .set({ ...patch, updatedAt: new Date() })
+ .where(eq(cloudUpstreamRuns.id, runId))
+ .returning();
+ if (!updated) throw notFound("Cloud upstream run was not found");
+ return runFromRow(updated);
+ }
+
+ async function updateRunIfRunning(runId: string, patch: Partial): Promise {
+ const [updated] = await db
+ .update(cloudUpstreamRuns)
+ .set({ ...patch, updatedAt: new Date() })
+ .where(and(eq(cloudUpstreamRuns.id, runId), eq(cloudUpstreamRuns.status, "running")))
+ .returning();
+ if (updated) return runFromRow(updated);
+
+ const [current] = await db
+ .select()
+ .from(cloudUpstreamRuns)
+ .where(eq(cloudUpstreamRuns.id, runId))
+ .limit(1);
+ if (!current) throw notFound("Cloud upstream run was not found");
+ return runFromRow(current);
+ }
+
+ async function localPreview(connection: ConnectionRow): Promise {
+ return {
+ connectionId: connection.id,
+ sourceCompanyId: connection.companyId,
+ target: targetFromConnectionRow(connection),
+ schemaCompatible: connection.targetSchemaMajor === TRANSFER_SCHEMA.major,
+ summary: await buildSummary(connection.companyId),
+ warnings: buildWarnings(connection.targetSchemaMajor),
+ conflicts: [],
+ generatedAt: new Date().toISOString(),
+ };
+ }
+
+ async function buildSummary(companyId: string): Promise {
+ const [agentCount, projectCount, goalCount, issueCount, commentCount, routineCount] = await Promise.all([
+ db.select({ count: count() }).from(agents).where(eq(agents.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(projects).where(eq(projects.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(goals).where(eq(goals.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(issues).where(eq(issues.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(issueComments).where(eq(issueComments.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(routines).where(eq(routines.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ ]);
+ return [
+ { key: "companies", label: "Companies", count: 1 },
+ { key: "goals", label: "Goals", count: goalCount },
+ { key: "projects", label: "Projects", count: projectCount },
+ { key: "agents", label: "Agents", count: agentCount },
+ { key: "issues", label: "Issues", count: issueCount },
+ { key: "comments", label: "Comments", count: commentCount },
+ { key: "routines", label: "Routines", count: routineCount },
+ { key: "warnings", label: "Warnings", count: buildWarnings(TRANSFER_SCHEMA.major).length },
+ ];
+ }
+
+ async function buildBundle(connection: ConnectionRow, mode: "preview" | "apply"): Promise {
+ const exported = await portability.exportBundle(connection.companyId, {
+ include: {
+ company: true,
+ agents: true,
+ projects: true,
+ issues: true,
+ skills: true,
+ },
+ expandReferencedSkills: true,
+ });
+ const sourceHash = normalizedContentHash({
+ manifest: exported.manifest,
+ files: exported.files,
+ });
+ const source = {
+ sourceInstanceId: connection.sourceInstanceId,
+ sourceCompanyId: connection.companyId,
+ sourceInstanceKeyFingerprint: connection.sourceInstanceFingerprint,
+ exporterVersion: "paperclip-local-cloud-ui-v1",
+ sourceSchemaVersion: TRANSFER_SCHEMA.version,
+ };
+ const target = {
+ targetStackId: connection.targetStackId,
+ targetCompanyId: connection.targetCompanyId,
+ targetOrigin: connection.targetOrigin,
+ supportedSchemaMajor: connection.targetSchemaMajor,
+ };
+ const idempotencyKey = [
+ mode,
+ connection.sourceInstanceId,
+ connection.companyId,
+ connection.targetStackId,
+ sourceHash,
+ ].join(":");
+ return buildLocalUpstreamExportBundle({
+ source,
+ target,
+ runId: `local-${mode}-${shortHash(idempotencyKey)}`,
+ idempotencyKey,
+ entities: buildEntitiesFromPortableExport(connection.companyId, connection.sourceInstanceId, exported),
+ warnings: exported.warnings.map((message): UpstreamTransferWarning => ({
+ code: "local_company_export_warning",
+ severity: "warning",
+ message,
+ })),
+ featureFlags: ["cloud_sync"],
+ maxEntitiesPerChunk: DEFAULT_MAX_ENTITIES_PER_CHUNK,
+ });
+ }
+}
+
+async function fetchDiscovery(remoteUrl: string): Promise> {
+ const parsed = new URL(remoteUrl);
+ if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
+ throw badRequest("Cloud upstream targets require HTTPS except localhost development");
+ }
+ const stackId = firstPathSegment(parsed.pathname);
+ const discoveryUrl = new URL("/.well-known/paperclip-upstream", parsed.origin);
+ if (stackId) {
+ discoveryUrl.searchParams.set("stackId", stackId);
+ }
+ const response = await fetchWithTimeout(discoveryUrl, undefined, DISCOVERY_FETCH_TIMEOUT_MS);
+ if (!response.ok) {
+ throw badRequest(`Cloud upstream discovery failed: ${response.status}`);
+ }
+ return await response.json() as Record;
+}
+
+export async function reconcileCloudUpstreamRunsOnStartup(db: Db, now = new Date()): Promise<{ reconciled: number }> {
+ const runningRows = await db
+ .select()
+ .from(cloudUpstreamRuns)
+ .where(eq(cloudUpstreamRuns.status, "running"));
+ if (runningRows.length === 0) return { reconciled: 0 };
+
+ for (const row of runningRows) {
+ const report = asRecord(row.report);
+ await db
+ .update(cloudUpstreamRuns)
+ .set({
+ status: "failed",
+ activeStep: row.activeStep,
+ progressPercent: 100,
+ completedAt: now,
+ updatedAt: now,
+ events: [
+ ...safeRunEvents(row.events),
+ event(
+ now.toISOString(),
+ cloudUpstreamStep(row.activeStep),
+ "failed",
+ "Marked failed on server startup because the previous process stopped while the cloud upstream run was in progress.",
+ ),
+ ],
+ report: {
+ ...report,
+ error: optionalString(report.error) ?? "orphaned_running_run",
+ errorMessage: optionalString(report.errorMessage)
+ ?? "The server restarted while this cloud upstream run was running, so Paperclip marked it failed instead of leaving it stuck.",
+ reconciledAt: now.toISOString(),
+ },
+ })
+ .where(eq(cloudUpstreamRuns.id, row.id));
+ }
+
+ return { reconciled: runningRows.length };
+}
+
+function firstPathSegment(pathname: string): string | null {
+ const segment = pathname.split("/").find(Boolean);
+ return segment && segment.toLowerCase() !== "dashboard" ? segment : null;
+}
+
+function targetFromDiscovery(discovery: Record): CloudUpstreamTarget {
+ const stack = objectField(discovery, "stack");
+ const transfer = objectField(discovery, "transfer");
+ const schema = optionalObject(transfer.schema);
+ const origin = stringField(stack, "origin");
+ return {
+ stackId: stringField(stack, "id"),
+ stackSlug: optionalString(stack.slug),
+ stackDisplayName: optionalString(stack.displayName),
+ companyId: stringField(stack, "companyId"),
+ primaryHost: optionalString(stack.primaryHost) ?? new URL(origin).host,
+ origin,
+ product: optionalString(discovery.product) ?? "Paperclip Cloud",
+ schemaMajor: optionalNumber(schema?.major) ?? numberField(transfer, "supportedSchemaMajor"),
+ maxChunkBytes: optionalNumber(transfer.maxChunkBytes) ?? 8 * 1024 * 1024,
+ };
+}
+
+function targetFromConnectionRow(row: ConnectionRow): CloudUpstreamTarget {
+ return {
+ stackId: row.targetStackId,
+ stackSlug: row.targetStackSlug,
+ stackDisplayName: row.targetStackDisplayName,
+ companyId: row.targetCompanyId,
+ primaryHost: row.targetPrimaryHost,
+ origin: row.targetOrigin,
+ product: row.targetProduct,
+ schemaMajor: row.targetSchemaMajor,
+ maxChunkBytes: row.targetMaxChunkBytes,
+ };
+}
+
+function connectionFromRow(row: ConnectionRow): CloudUpstreamConnection {
+ return {
+ id: row.id,
+ companyId: row.companyId,
+ remoteUrl: row.remoteUrl,
+ target: targetFromConnectionRow(row),
+ tokenStatus: cloudUpstreamTokenStatus(row.tokenStatus),
+ scopes: row.scopes,
+ authorizedGlobalUserId: row.authorizedGlobalUserId,
+ expiresAt: row.tokenExpiresAt?.toISOString() ?? null,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ lastRunId: row.lastRunId,
+ };
+}
+
+function runFromRow(row: RunRow): CloudUpstreamRun {
+ return {
+ id: row.id,
+ connectionId: row.connectionId,
+ companyId: row.companyId,
+ status: cloudUpstreamRunStatus(row.status),
+ activeStep: cloudUpstreamStep(row.activeStep),
+ progressPercent: row.progressPercent,
+ dryRun: row.dryRun,
+ summary: row.summary,
+ warnings: row.warnings,
+ conflicts: row.conflicts,
+ events: row.events,
+ targetUrl: row.targetUrl,
+ report: row.report,
+ retryOfRunId: row.retryOfRunId,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ completedAt: row.completedAt?.toISOString() ?? null,
+ };
+}
+
+function scopesFromDiscovery(discovery: Record): string[] {
+ const auth = objectField(discovery, "auth");
+ const scopes = Array.isArray(auth.scopes) ? auth.scopes.map(String).filter(Boolean) : [];
+ return scopes.length > 0 ? scopes : [...DEFAULT_SCOPES];
+}
+
+function consentUrlFromDiscovery(discovery: Record): string {
+ const pkce = objectField(objectField(discovery, "auth"), "pkce");
+ return optionalString(pkce.consentUrl) ?? stringField(pkce, "authorizeUrl");
+}
+
+function tokenUrlFromDiscovery(discovery: Record): string {
+ return stringField(objectField(objectField(discovery, "auth"), "pkce"), "tokenUrl");
+}
+
+function buildWarnings(schemaMajor: number): CloudUpstreamWarning[] {
+ const warnings: CloudUpstreamWarning[] = [
+ {
+ code: "imported_automations_paused",
+ severity: "warning",
+ title: "Automations stay paused",
+ detail: "Imported agents, routines, and monitors require explicit activation after the push.",
+ },
+ {
+ code: "unmatched_users_import_as_historical_authors",
+ severity: "warning",
+ title: "Unmatched users become historical authors",
+ detail: "Invite now remains a secondary action after the transfer is complete.",
+ },
+ {
+ code: "secret_values_redacted",
+ severity: "warning",
+ title: "Secret values are not transferred",
+ detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.",
+ },
+ ];
+ if (schemaMajor !== TRANSFER_SCHEMA.major) {
+ warnings.unshift({
+ code: "schema_mismatch",
+ severity: "blocker",
+ title: "Cloud stack upgrade required",
+ detail: `This local build uses upstream schema ${TRANSFER_SCHEMA.major}, but the cloud stack reports schema ${schemaMajor}.`,
+ });
+ }
+ return warnings;
+}
+
+type LocalUpstreamExportEntityInput = {
+ key: SourceEntityKey;
+ body: Record;
+ dependencies?: SourceEntityKey[];
+ warnings?: UpstreamTransferWarning[];
+ conflictKeys?: string[];
+};
+
+function buildEntitiesFromPortableExport(
+ localCompanyId: string,
+ sourceInstanceId: string,
+ exported: CompanyPortabilityExportResult,
+): LocalUpstreamExportEntityInput[] {
+ const companyKey: SourceEntityKey = {
+ sourceInstanceId,
+ sourceCompanyId: localCompanyId,
+ sourceEntityType: "company",
+ sourceEntityId: localCompanyId,
+ sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId,
+ };
+ const entities: LocalUpstreamExportEntityInput[] = [
+ {
+ key: companyKey,
+ body: {
+ kind: "paperclip_company_portability_manifest",
+ manifest: exported.manifest,
+ rootPath: exported.rootPath,
+ paperclipExtensionPath: exported.paperclipExtensionPath,
+ fileCount: Object.keys(exported.files).length,
+ },
+ conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`],
+ },
+ ];
+
+ for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) {
+ entities.push({
+ key: {
+ sourceInstanceId,
+ sourceCompanyId: localCompanyId,
+ sourceEntityType: "company_setting",
+ sourceEntityId: shortHash(filePath),
+ sourceNaturalKey: filePath,
+ },
+ body: {
+ kind: "paperclip_portable_file",
+ path: filePath,
+ entry: normalizePortableFileEntry(entry),
+ },
+ dependencies: [companyKey],
+ conflictKeys: [`portable_file:${filePath}`],
+ });
+ }
+ return entities;
+}
+
+function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record {
+ if (typeof entry === "string") {
+ return { encoding: "utf8", data: entry };
+ }
+ return { ...entry };
+}
+
+function buildLocalUpstreamExportBundle(input: {
+ source: UpstreamTransferManifest["source"];
+ target: UpstreamTransferManifest["target"];
+ runId: string;
+ idempotencyKey: string;
+ entities: LocalUpstreamExportEntityInput[];
+ warnings?: UpstreamTransferWarning[];
+ featureFlags?: string[];
+ maxEntitiesPerChunk?: number;
+}): LocalUpstreamExportBundle {
+ const entities = input.entities.map((entity) => ({
+ record: {
+ key: entity.key,
+ contentHash: normalizedContentHash(entity.body),
+ dependencies: entity.dependencies ?? [],
+ warnings: entity.warnings ?? [],
+ },
+ body: entity.body,
+ conflictKeys: entity.conflictKeys,
+ }));
+ const chunksWithoutManifestHash = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? DEFAULT_MAX_ENTITIES_PER_CHUNK);
+ const manifestWithoutHash = {
+ schema: TRANSFER_SCHEMA,
+ source: input.source,
+ target: input.target,
+ runId: input.runId,
+ idempotencyKey: input.idempotencyKey,
+ generatedAt: new Date(0).toISOString(),
+ entityCount: entities.length,
+ entities: entities.map((entity) => entity.record),
+ chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => chunk),
+ warnings: input.warnings ?? [],
+ featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(),
+ };
+ const manifestHash = normalizedContentHash(manifestWithoutHash);
+ return {
+ manifest: {
+ ...manifestWithoutHash,
+ chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => ({ ...chunk, manifestHash })),
+ manifestHash,
+ },
+ entities,
+ chunks: chunksWithoutManifestHash,
+ };
+}
+
+function buildLocalChunks(entities: LocalUpstreamExportEntity[], maxEntitiesPerChunk: number): LocalUpstreamExportChunk[] {
+ if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) {
+ throw new Error("maxEntitiesPerChunk must be a positive integer");
+ }
+ if (entities.length === 0) return [];
+
+ const groups: LocalUpstreamExportEntity[][] = [];
+ for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) {
+ groups.push(entities.slice(index, index + maxEntitiesPerChunk));
+ }
+
+ return groups.map((group, index) => {
+ const payload = {
+ entityKeys: group.map((entity) => entity.record.key),
+ };
+ return {
+ chunkIndex: index,
+ totalChunks: groups.length,
+ byteLength: Buffer.byteLength(canonicalJson(payload)),
+ sha256: normalizedContentHash(payload),
+ payload,
+ };
+ });
+}
+
+async function remoteGet(connection: ConnectionRow, path: string): Promise {
+ const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, {
+ method: "GET",
+ headers: await proofHeaders(connection, "GET", path),
+ }, REMOTE_FETCH_TIMEOUT_MS);
+ return parseRemoteResponse(response);
+}
+
+async function remotePost(connection: ConnectionRow, path: string, body: unknown): Promise {
+ const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...await proofHeaders(connection, "POST", path),
+ },
+ body: JSON.stringify(body),
+ }, REMOTE_FETCH_TIMEOUT_MS);
+ return parseRemoteResponse(response);
+}
+
+async function proofHeaders(connection: ConnectionRow, method: string, pathAndSearch: string): Promise> {
+ if (!connection.accessToken || !connection.tokenId) {
+ throw badRequest("Cloud upstream connection is missing an import token");
+ }
+ const accessToken = await unsealCloudUpstreamCredential(connection.accessToken);
+ const privateKeyPem = await unsealCloudUpstreamCredential(connection.privateKeyPem);
+ const timestamp = new Date().toISOString();
+ const nonce = crypto.randomUUID();
+ const payload = [
+ method,
+ new URL(connection.targetOrigin).host.toLowerCase(),
+ pathAndSearch,
+ connection.tokenId,
+ connection.sourceInstanceId,
+ timestamp,
+ nonce,
+ ].join("\n");
+ return {
+ Authorization: `Bearer ${accessToken}`,
+ "X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId,
+ "X-Paperclip-Upstream-Proof-Timestamp": timestamp,
+ "X-Paperclip-Upstream-Proof-Nonce": nonce,
+ "X-Paperclip-Upstream-Proof-Signature": sign(
+ null,
+ Buffer.from(payload, "utf8"),
+ privateKeyPem,
+ ).toString("base64url"),
+ };
+}
+
+async function parseRemoteResponse(response: Response): Promise {
+ const text = await response.text();
+ const parsed = text.trim() ? safeParseJson(text) : {};
+ if (!response.ok) {
+ const message = typeof parsed === "object" && parsed !== null && "error" in parsed
+ ? String((parsed as { error: unknown }).error)
+ : `Cloud upstream request failed: ${response.status}`;
+ throw badRequest(message, parsed);
+ }
+ return parsed;
+}
+
+async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit | undefined, timeoutMs: number): Promise {
+ return fetch(input, {
+ ...init,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+}
+
+export async function sealCloudUpstreamCredential(value: string): Promise {
+ const prepared = await localEncryptedProvider.createSecret({ value });
+ return `${CLOUD_CREDENTIAL_PREFIX}${JSON.stringify(prepared.material)}`;
+}
+
+export async function unsealCloudUpstreamCredential(value: string): Promise {
+ if (!value.startsWith(CLOUD_CREDENTIAL_PREFIX)) return value;
+ const encoded = value.slice(CLOUD_CREDENTIAL_PREFIX.length);
+ const parsed = safeParseJson(encoded);
+ const material = optionalObject(parsed);
+ if (!material) {
+ throw badRequest("Invalid encrypted cloud upstream credential material");
+ }
+ return localEncryptedProvider.resolveVersion({
+ material,
+ externalRef: null,
+ });
+}
+
+export function cloudUpstreamRemoteFailureReport(error: unknown): {
+ error: string;
+ errorMessage?: string;
+ details?: unknown;
+} {
+ const fallback = error instanceof Error ? error.message : String(error);
+ if (!(error instanceof HttpError)) {
+ return { error: fallback };
+ }
+ const remote = remoteErrorBody(error.details);
+ return {
+ error: remote.error ?? error.message,
+ ...(remote.message ? { errorMessage: remote.message } : {}),
+ ...(error.details !== undefined ? { details: error.details } : {}),
+ };
+}
+
+function remoteErrorBody(details: unknown): { error?: string; message?: string } {
+ const record = optionalObject(details);
+ if (!record) return {};
+ return {
+ error: optionalString(record.error) ?? undefined,
+ message: optionalString(record.message) ?? undefined,
+ };
+}
+
+async function postJson(url: string, body: unknown): Promise {
+ const response = await fetchWithTimeout(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }, DISCOVERY_FETCH_TIMEOUT_MS);
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ throw badRequest((payload as { error?: string } | null)?.error ?? `Cloud upstream request failed: ${response.status}`);
+ }
+ return payload as T;
+}
+
+function remoteRunIdFromResponse(value: unknown): string {
+ const record = asRecord(value);
+ const run = asRecord(record.run);
+ const id = optionalString(run.id);
+ if (!id) throw badRequest("Remote upstream importer did not return a run id");
+ return id;
+}
+
+function warningsFromRemote(value: unknown): CloudUpstreamWarning[] {
+ const record = asRecord(value);
+ const warnings = Array.isArray(record.warnings) ? record.warnings : [];
+ return warnings.map((warning, index): CloudUpstreamWarning => {
+ const item = asRecord(warning);
+ const code = optionalString(item.code) ?? `remote_warning_${index}`;
+ const severity = item.severity === "blocker" ? "blocker" : "warning";
+ const message = optionalString(item.message) ?? optionalString(item.detail) ?? "Remote importer warning.";
+ return {
+ code,
+ severity,
+ title: titleFromCode(code),
+ detail: message,
+ };
+ });
+}
+
+function conflictsFromRemote(value: unknown): CloudUpstreamConflict[] {
+ const record = asRecord(value);
+ const conflicts = Array.isArray(record.conflicts) ? record.conflicts : [];
+ return conflicts.map((conflict, index): CloudUpstreamConflict => {
+ const item = asRecord(conflict);
+ const source = asRecord(item.source);
+ return {
+ id: optionalString(item.id) ?? `remote-conflict-${index}`,
+ entityType: optionalString(item.entityType) ?? optionalString(source.sourceEntityType) ?? "entity",
+ sourceLabel: optionalString(item.sourceLabel) ?? optionalString(source.sourceNaturalKey) ?? optionalString(source.sourceEntityId) ?? "Source entity",
+ targetLabel: optionalString(item.targetLabel) ?? optionalString(item.targetEntityId) ?? "Cloud entity",
+ plannedAction: "blocked",
+ reason: optionalString(item.reason) ?? "Remote importer reported a conflict.",
+ };
+ });
+}
+
+function eventsFromRemote(value: unknown): CloudUpstreamRunEvent[] {
+ const record = asRecord(value);
+ const events = Array.isArray(record.events) ? record.events : [];
+ return events.slice(-25).map((remote, index) => {
+ const item = asRecord(remote);
+ const action = optionalString(item.action) ?? "updated";
+ return event(
+ optionalString(item.createdAt) ?? new Date().toISOString(),
+ "verify",
+ action.includes("created") ? "created" : "updated",
+ `Cloud importer ${action.replace(/_/g, " ")}${index >= 0 ? "." : "."}`,
+ );
+ });
+}
+
+function safeRunEvents(value: unknown): CloudUpstreamRunEvent[] {
+ return Array.isArray(value) ? value as CloudUpstreamRunEvent[] : [];
+}
+
+function assertActivationEntityType(value: string): asserts value is CloudUpstreamActivationEntityType {
+ if (value !== "agents" && value !== "routines" && value !== "monitors") {
+ throw badRequest("entityType must be agents, routines, or monitors");
+ }
+}
+
+function summaryCount(summary: unknown, key: CloudUpstreamActivationEntityType): number {
+ if (!Array.isArray(summary)) return 0;
+ const item = summary.find((entry) => asRecord(entry).key === key);
+ const count = asRecord(item).count;
+ return typeof count === "number" && Number.isFinite(count) ? count : 0;
+}
+
+function activationChecklistFromReport(report: Record): Record {
+ const value = asRecord(report.activationChecklist);
+ const decisions: Record = {};
+ for (const [key, decision] of Object.entries(value)) {
+ if (key !== "agents" && key !== "routines" && key !== "monitors") continue;
+ const item = asRecord(decision);
+ decisions[key] = {
+ entityType: key,
+ count: typeof item.count === "number" && Number.isFinite(item.count) ? item.count : 0,
+ status: item.status === "activated" ? "activated" : "paused",
+ activatedAt: optionalString(item.activatedAt),
+ };
+ }
+ return decisions;
+}
+
+function activationEntityLabel(entityType: CloudUpstreamActivationEntityType, count: number): string {
+ const singular = entityType === "agents" ? "agent" : entityType === "routines" ? "routine" : "monitor";
+ return `${singular}${count === 1 ? "" : "s"}`;
+}
+
+function mergeWarnings(base: CloudUpstreamWarning[], extra: CloudUpstreamWarning[]): CloudUpstreamWarning[] {
+ const byCode = new Map();
+ for (const warning of [...base, ...extra]) byCode.set(warning.code, warning);
+ return [...byCode.values()];
+}
+
+function event(
+ at: string,
+ phase: CloudUpstreamRunEvent["phase"],
+ type: CloudUpstreamRunEvent["type"],
+ message: string,
+): CloudUpstreamRunEvent {
+ return {
+ id: crypto.randomUUID(),
+ at,
+ phase,
+ type,
+ message,
+ };
+}
+
+function normalizedContentHash(value: unknown): NormalizedSha256 {
+ return `sha256:${crypto.createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
+}
+
+function canonicalJson(value: unknown): string {
+ return JSON.stringify(sortJson(value));
+}
+
+function sortJson(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sortJson);
+ if (typeof value !== "object" || value === null) return value;
+ return Object.fromEntries(
+ Object.entries(value as Record)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, entry]) => [key, sortJson(entry)]),
+ );
+}
+
+function shortHash(value: string): string {
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
+}
+
+function sourceEntityKeyString(key: SourceEntityKey): string {
+ return [key.sourceInstanceId, key.sourceCompanyId, key.sourceEntityType, key.sourceEntityId]
+ .map((part) => encodeURIComponent(part))
+ .join("/");
+}
+
+function titleFromCode(code: string): string {
+ return code
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
+}
+
+function objectField(value: Record, key: string): Record {
+ const field = value[key];
+ if (!field || typeof field !== "object" || Array.isArray(field)) {
+ throw badRequest(`Cloud upstream discovery missing ${key}`);
+ }
+ return field as Record;
+}
+
+function stringField(value: Record, key: string): string {
+ const field = value[key];
+ if (typeof field !== "string" || field.length === 0) {
+ throw badRequest(`Cloud upstream discovery missing ${key}`);
+ }
+ return field;
+}
+
+function numberField(value: Record, key: string): number {
+ const field = value[key];
+ if (typeof field !== "number" || !Number.isFinite(field)) {
+ throw badRequest(`Cloud upstream discovery missing ${key}`);
+ }
+ return field;
+}
+
+function optionalString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function optionalNumber(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function optionalObject(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
+}
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
+}
+
+function safeParseJson(text: string): unknown {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+}
+
+function cloudUpstreamTokenStatus(value: string): CloudUpstreamConnection["tokenStatus"] {
+ return value === "connected" || value === "expired" || value === "revoked" ? value : "pending";
+}
+
+function cloudUpstreamRunStatus(value: string): CloudUpstreamRun["status"] {
+ return value === "previewed" || value === "running" || value === "succeeded" || value === "failed" || value === "cancelled"
+ ? value
+ : "failed";
+}
+
+function cloudUpstreamStep(value: string): CloudUpstreamRun["activeStep"] {
+ return value === "connect" || value === "scan" || value === "preview" || value === "push" || value === "verify" || value === "activate"
+ ? value
+ : "push";
+}
diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts
index 17905f11ee0..8704b986cd2 100644
--- a/server/src/services/companies.ts
+++ b/server/src/services/companies.ts
@@ -265,7 +265,17 @@ export function companyService(db: Db) {
remove: (id: string) =>
db.transaction(async (tx) => {
// Delete from child tables in dependency order
+ const companyRunIds = await tx
+ .select({ id: heartbeatRuns.id })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.companyId, id));
+
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
+ if (companyRunIds.length > 0) {
+ await tx
+ .delete(heartbeatRunEvents)
+ .where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
+ }
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index a8570d2d27f..18857b7ca93 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -59,6 +59,7 @@ export type {
} from "./authorization.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
+export { cloudUpstreamService, reconcileCloudUpstreamRunsOnStartup } from "./cloud-upstreams.js";
export { companyPortabilityService } from "./company-portability.js";
export { environmentService } from "./environments.js";
export { executionWorkspaceService } from "./execution-workspaces.js";
diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts
index c447a920efd..8b02f57c39d 100644
--- a/server/src/services/instance-settings.ts
+++ b/server/src/services/instance-settings.ts
@@ -15,9 +15,11 @@ import {
import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
+const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip();
+const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip();
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
- const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
+ const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
@@ -35,12 +37,13 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
};
}
-function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
- const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
+export function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
+ const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
enableEnvironments: parsed.data.enableEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
+ enableCloudSync: parsed.data.enableCloudSync ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
issueGraphLivenessAutoRecoveryLookbackHours:
@@ -51,6 +54,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
return {
enableEnvironments: false,
enableIsolatedWorkspaces: false,
+ enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours:
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index abc17fc2487..905804a9738 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -32,6 +32,8 @@ import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
+import { CloudUpstream } from "./pages/CloudUpstream";
+import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills";
import { Secrets } from "./pages/Secrets";
@@ -72,6 +74,7 @@ function boardRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -279,6 +282,7 @@ export function App() {
} />
} />
} />
+ } />
}>
} />
diff --git a/ui/src/api/cloudUpstreams.ts b/ui/src/api/cloudUpstreams.ts
new file mode 100644
index 00000000000..48ddff5ced6
--- /dev/null
+++ b/ui/src/api/cloudUpstreams.ts
@@ -0,0 +1,40 @@
+import type {
+ CloudUpstreamActivationEntityType,
+ CloudUpstreamConnectStartResponse,
+ CloudUpstreamConnection,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamsState,
+} from "@paperclipai/shared";
+import { api } from "./client";
+
+export const cloudUpstreamsApi = {
+ list: (companyId: string) =>
+ api.get(`/cloud-upstreams?companyId=${encodeURIComponent(companyId)}`),
+ startConnect: (input: { companyId: string; remoteUrl: string; redirectUri: string }) =>
+ api.post("/cloud-upstreams/connect/start", input),
+ finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) =>
+ api.post("/cloud-upstreams/connect/finish", input),
+ preview: (connectionId: string, input: { companyId: string }) =>
+ api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, input),
+ createRun: (connectionId: string, input: { companyId: string; retryOfRunId?: string | null }) =>
+ api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}),
+ getRun: (connectionId: string, runId: string, companyId: string) =>
+ api.get(
+ `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}?companyId=${encodeURIComponent(companyId)}`,
+ ),
+ cancelRun: (connectionId: string, runId: string, input: { companyId: string }) =>
+ api.post(
+ `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`,
+ input,
+ ),
+ activateEntities: (
+ connectionId: string,
+ runId: string,
+ input: { companyId: string; entityType: CloudUpstreamActivationEntityType },
+ ) =>
+ api.post(
+ `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/activation`,
+ input,
+ ),
+};
diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx
index 6514cd4a99b..cdff2d8bce6 100644
--- a/ui/src/components/CompanySettingsSidebar.test.tsx
+++ b/ui/src/components/CompanySettingsSidebar.test.tsx
@@ -11,6 +11,9 @@ const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
+const mockInstanceSettingsApi = vi.hoisted(() => ({
+ getExperimental: vi.fn(),
+}));
vi.mock("@/lib/router", () => ({
Link: ({
@@ -66,6 +69,10 @@ vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
+vi.mock("@/api/instanceSettings", () => ({
+ instanceSettingsApi: mockInstanceSettingsApi,
+}));
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -93,6 +100,9 @@ describe("CompanySettingsSidebar", () => {
isLoading: false,
errorMessage: null,
});
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({
+ enableCloudSync: false,
+ });
});
afterEach(() => {
@@ -121,6 +131,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
expect(container.textContent).toContain("Members");
+ expect(container.textContent).not.toContain("Cloud upstream");
expect(container.textContent).toContain("Invites");
expect(container.textContent).toContain("Secrets");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
@@ -210,4 +221,36 @@ describe("CompanySettingsSidebar", () => {
root.unmount();
});
});
+
+ it("shows cloud upstream only when cloud sync is enabled", async () => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({
+ enableCloudSync: true,
+ });
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(container.textContent).toContain("Cloud upstream");
+ expect(sidebarNavItemMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: "/company/settings/cloud-upstream",
+ label: "Cloud upstream",
+ end: true,
+ }),
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
});
diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx
index ed84e9baed3..ad05f4b1d9c 100644
--- a/ui/src/components/CompanySettingsSidebar.tsx
+++ b/ui/src/components/CompanySettingsSidebar.tsx
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
-import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
+import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
+import { instanceSettingsApi } from "@/api/instanceSettings";
import { ApiError } from "@/api/client";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
@@ -35,6 +36,11 @@ export function CompanySettingsSidebar() {
retry: false,
refetchInterval: 15_000,
});
+ const { data: experimentalSettings } = useQuery({
+ queryKey: queryKeys.instance.experimentalSettings,
+ queryFn: () => instanceSettingsApi.getExperimental(),
+ });
+ const showCloudUpstream = experimentalSettings?.enableCloudSync === true;
return (
@@ -66,6 +72,14 @@ export function CompanySettingsSidebar() {
icon={MonitorCog}
end
/>
+ {showCloudUpstream ? (
+
+ ) : null}
["cloud-upstreams", companyId] as const,
health: ["health"] as const,
secrets: {
list: (companyId: string) => ["secrets", companyId] as const,
diff --git a/ui/src/pages/CloudUpstream.test.tsx b/ui/src/pages/CloudUpstream.test.tsx
new file mode 100644
index 00000000000..cb6d0c0a835
--- /dev/null
+++ b/ui/src/pages/CloudUpstream.test.tsx
@@ -0,0 +1,408 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { CloudUpstream, buildActivationRows } from "./CloudUpstream";
+
+const mockCloudUpstreamsApi = vi.hoisted(() => ({
+ list: vi.fn(),
+ startConnect: vi.fn(),
+ finishConnect: vi.fn(),
+ preview: vi.fn(),
+ createRun: vi.fn(),
+ getRun: vi.fn(),
+ cancelRun: vi.fn(),
+ activateEntities: vi.fn(),
+}));
+const mockInstanceSettingsApi = vi.hoisted(() => ({
+ getExperimental: vi.fn(),
+}));
+const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
+const mockCompanyState = vi.hoisted(() => ({
+ selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" } as
+ | { id: string; name: string; issuePrefix: string | null }
+ | null,
+ selectedCompanyId: "company-1" as string | null,
+}));
+const mockLocationState = vi.hoisted(() => ({
+ pathname: "/PAP/company/settings/cloud-upstream",
+ search: "",
+}));
+
+vi.mock("@/api/cloudUpstreams", () => ({
+ cloudUpstreamsApi: mockCloudUpstreamsApi,
+}));
+
+vi.mock("@/api/instanceSettings", () => ({
+ instanceSettingsApi: mockInstanceSettingsApi,
+}));
+
+vi.mock("@/context/BreadcrumbContext", () => ({
+ useBreadcrumbs: () => ({
+ setBreadcrumbs: mockSetBreadcrumbs,
+ }),
+}));
+
+vi.mock("@/context/CompanyContext", () => ({
+ useCompany: () => ({
+ selectedCompany: mockCompanyState.selectedCompany,
+ selectedCompanyId: mockCompanyState.selectedCompanyId,
+ }),
+}));
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to, className }: { children: React.ReactNode; to: string; className?: string }) => (
+
+ {children}
+
+ ),
+ useLocation: () => ({ pathname: mockLocationState.pathname, search: mockLocationState.search }),
+}));
+
+// 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));
+ });
+}
+
+describe("CloudUpstream", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ mockCompanyState.selectedCompany = { id: "company-1", name: "Paperclip", issuePrefix: "PAP" };
+ mockCompanyState.selectedCompanyId = "company-1";
+ mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
+ mockLocationState.search = "";
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCloudSync: true });
+ mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "succeeded" })));
+ mockCloudUpstreamsApi.activateEntities.mockImplementation((_connectionId, _runId, input) =>
+ Promise.resolve(buildRun({
+ status: "succeeded",
+ report: {
+ activationChecklist: {
+ [input.entityType]: {
+ entityType: input.entityType,
+ count: input.entityType === "agents" ? 2 : 1,
+ status: "activated",
+ activatedAt: "2026-05-18T19:00:00.000Z",
+ },
+ },
+ },
+ })),
+ );
+ mockCloudUpstreamsApi.createRun.mockResolvedValue(buildRun({ status: "running" }));
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ });
+
+ it("binds the succeeded run activation checklist to imported category counts", async () => {
+ const root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ expect(container.textContent).toContain("Re-run");
+ expect(container.textContent).not.toContain("Retry");
+ expect(container.textContent).toContain("Activation checklist");
+ expect(container.textContent).toContain("2 paused");
+ expect(container.textContent).toContain("1 paused");
+ expect(container.textContent).toContain("0 imported monitors in this run.");
+ expect(container.textContent).toContain("Keep paused");
+
+ const activateButton = Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent?.trim() === "Activate") as HTMLButtonElement | undefined;
+ expect(activateButton).toBeTruthy();
+
+ await act(async () => {
+ activateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ expect(mockCloudUpstreamsApi.activateEntities).toHaveBeenCalledWith(
+ "connection-1",
+ "run-1",
+ { companyId: "company-1", entityType: "agents" },
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("sends a company-prefixed redirectUri when starting Connect", async () => {
+ mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
+ mockCloudUpstreamsApi.startConnect.mockResolvedValue({
+ pendingConnectionId: "pending-1",
+ authorizationUrl: "https://cloud.example/upstream-consent?state=abc",
+ });
+ const root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const input = container.querySelector("input[aria-label='Paperclip Cloud stack URL']");
+ expect(input).toBeTruthy();
+ await act(async () => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
+ setter.call(input!, "https://cloud.example/PAP/dashboard");
+ input!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await flushReact();
+
+ const connectButton = Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent?.trim() === "Connect") as HTMLButtonElement | undefined;
+ expect(connectButton).toBeTruthy();
+
+ await act(async () => {
+ connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ expect(mockCloudUpstreamsApi.startConnect).toHaveBeenCalledWith({
+ companyId: "company-1",
+ remoteUrl: "https://cloud.example/PAP/dashboard",
+ redirectUri: `${window.location.origin}/PAP/company/settings/cloud-upstream`,
+ });
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("uses the URL pathname prefix when cleaning up the callback URL with no company context", async () => {
+ mockCompanyState.selectedCompany = null;
+ mockCompanyState.selectedCompanyId = null;
+ mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
+ mockLocationState.search = "?code=cb-code&state=cb-state";
+ mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
+ mockCloudUpstreamsApi.finishConnect.mockResolvedValue({
+ id: "connection-1",
+ companyId: "company-1",
+ remoteUrl: "https://cloud.example/PAP",
+ target: {
+ stackId: "stack-1",
+ stackSlug: "stack",
+ stackDisplayName: "Paperclip Cloud",
+ companyId: "cloud-company-1",
+ primaryHost: "cloud.example",
+ origin: "https://cloud.example",
+ product: "Paperclip Cloud",
+ schemaMajor: 1,
+ maxChunkBytes: 1024,
+ },
+ tokenStatus: "connected",
+ scopes: ["upstream_import:write"],
+ authorizedGlobalUserId: "user-1",
+ expiresAt: null,
+ createdAt: "2026-05-18T18:00:00.000Z",
+ updatedAt: "2026-05-18T18:00:00.000Z",
+ lastRunId: null,
+ });
+ window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
+ const replaceStateSpy = vi.spyOn(window.history, "replaceState");
+
+ try {
+ const root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledWith({
+ pendingConnectionId: "pending-1",
+ code: "cb-code",
+ state: "cb-state",
+ });
+ expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/PAP/company/settings/cloud-upstream");
+
+ await act(async () => {
+ root.unmount();
+ });
+ } finally {
+ replaceStateSpy.mockRestore();
+ window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
+ }
+ });
+
+ it("does not retry the OAuth callback finish mutation after an error", async () => {
+ mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
+ mockLocationState.search = "?code=cb-code&state=cb-state";
+ mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
+ mockCloudUpstreamsApi.finishConnect.mockRejectedValue(new Error("state expired"));
+ window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
+
+ try {
+ const root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+ await flushReact();
+
+ expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledTimes(1);
+ expect(container.textContent).toContain("state expired");
+
+ await act(async () => {
+ root.unmount();
+ });
+ } finally {
+ window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
+ }
+ });
+
+ it("keeps retry only for failed or cancelled runs", async () => {
+ mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "failed" })));
+ const root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ expect(container.textContent).toContain("Retry");
+ expect(container.textContent).not.toContain("Re-run");
+ expect(container.textContent).not.toContain("Activation checklist");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});
+
+describe("buildActivationRows", () => {
+ it("reads activation decisions from the run report", () => {
+ const rows = buildActivationRows(buildRun({
+ status: "succeeded",
+ report: {
+ activationChecklist: {
+ agents: {
+ entityType: "agents",
+ count: 2,
+ status: "activated",
+ activatedAt: "2026-05-18T19:00:00.000Z",
+ },
+ },
+ },
+ }));
+
+ expect(rows[0]).toMatchObject({ key: "agents", count: 2, status: "activated", statusLabel: "2 activated" });
+ expect(rows[2]).toMatchObject({ key: "monitors", count: 0, status: "paused", statusLabel: "0 imported" });
+ });
+});
+
+function stateWithRun(run: CloudUpstreamRun): CloudUpstreamsState {
+ return {
+ connections: [
+ {
+ id: "connection-1",
+ companyId: "company-1",
+ remoteUrl: "https://paperclip.example/PAP",
+ target: {
+ stackId: "stack-1",
+ stackSlug: "stack",
+ stackDisplayName: "Paperclip Cloud",
+ companyId: "cloud-company-1",
+ primaryHost: "paperclip.example",
+ origin: "https://paperclip.example",
+ product: "Paperclip Cloud",
+ schemaMajor: 1,
+ maxChunkBytes: 1024,
+ },
+ tokenStatus: "connected",
+ scopes: ["upstream_import:write"],
+ authorizedGlobalUserId: "user-1",
+ expiresAt: null,
+ createdAt: "2026-05-18T18:00:00.000Z",
+ updatedAt: "2026-05-18T18:00:00.000Z",
+ lastRunId: run.id,
+ },
+ ],
+ runs: [run],
+ };
+}
+
+function buildRun(input: {
+ status: CloudUpstreamRun["status"];
+ report?: Record;
+}): CloudUpstreamRun {
+ return {
+ id: "run-1",
+ connectionId: "connection-1",
+ companyId: "company-1",
+ status: input.status,
+ activeStep: input.status === "succeeded" ? "activate" : "push",
+ progressPercent: input.status === "running" ? 70 : 100,
+ dryRun: false,
+ summary: [
+ { key: "agents", label: "Agents", count: 2 },
+ { key: "routines", label: "Routines", count: 1 },
+ { key: "issues", label: "Issues", count: 7 },
+ ],
+ warnings: [],
+ conflicts: [],
+ events: [
+ {
+ id: "event-1",
+ at: "2026-05-18T18:30:00.000Z",
+ phase: input.status === "succeeded" ? "activate" : "push",
+ type: input.status === "failed" ? "failed" : "completed",
+ message: input.status === "failed" ? "Push failed." : "Activation checklist is ready.",
+ },
+ ],
+ targetUrl: "https://paperclip.example",
+ report: input.report ?? {},
+ retryOfRunId: null,
+ createdAt: "2026-05-18T18:00:00.000Z",
+ updatedAt: "2026-05-18T18:30:00.000Z",
+ completedAt: input.status === "running" ? null : "2026-05-18T18:30:00.000Z",
+ };
+}
diff --git a/ui/src/pages/CloudUpstream.tsx b/ui/src/pages/CloudUpstream.tsx
new file mode 100644
index 00000000000..1b23a0e545a
--- /dev/null
+++ b/ui/src/pages/CloudUpstream.tsx
@@ -0,0 +1,646 @@
+import { useEffect, useMemo, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ CloudUpload,
+ ExternalLink,
+ FileJson,
+ History,
+ Loader2,
+ RefreshCcw,
+ ShieldAlert,
+} from "lucide-react";
+import type {
+ CloudUpstreamActivationDecision,
+ CloudUpstreamActivationEntityType,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamStep,
+} from "@paperclipai/shared";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cloudUpstreamsApi } from "@/api/cloudUpstreams";
+import { instanceSettingsApi } from "@/api/instanceSettings";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useCompany } from "@/context/CompanyContext";
+import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
+import { Link, useLocation } from "@/lib/router";
+import { queryKeys } from "@/lib/queryKeys";
+
+const PENDING_CONNECTION_KEY = "paperclip-cloud-upstream-pending-connection";
+const STEPS: Array<{ key: CloudUpstreamStep; label: string }> = [
+ { key: "connect", label: "Connect" },
+ { key: "scan", label: "Scan" },
+ { key: "preview", label: "Preview" },
+ { key: "push", label: "Push" },
+ { key: "verify", label: "Verify" },
+ { key: "activate", label: "Activate" },
+];
+const ACTIVATION_CATEGORIES: Array<{
+ key: CloudUpstreamActivationEntityType;
+ label: string;
+ singular: string;
+ detail: string;
+}> = [
+ {
+ key: "agents",
+ label: "Agents",
+ singular: "agent",
+ detail: "Confirm cloud secrets and adapter credentials before unpausing imported agents.",
+ },
+ {
+ key: "routines",
+ label: "Routines",
+ singular: "routine",
+ detail: "Review schedules and trigger settings before enabling imported routines.",
+ },
+ {
+ key: "monitors",
+ label: "Monitors",
+ singular: "monitor",
+ detail: "Activate after the target stack has been smoke tested.",
+ },
+];
+
+export function CloudUpstream() {
+ const { selectedCompany, selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const queryClient = useQueryClient();
+ const location = useLocation();
+ const [remoteUrl, setRemoteUrl] = useState("");
+ const [preview, setPreview] = useState(null);
+ const [activeRun, setActiveRun] = useState(null);
+ const [notice, setNotice] = useState(null);
+ const [actionError, setActionError] = useState(null);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: selectedCompany?.name ?? "Company", href: "/dashboard" },
+ { label: "Settings", href: "/company/settings" },
+ { label: "Cloud upstream" },
+ ]);
+ }, [selectedCompany?.name, setBreadcrumbs]);
+
+ const experimentalQuery = useQuery({
+ queryKey: queryKeys.instance.experimentalSettings,
+ queryFn: () => instanceSettingsApi.getExperimental(),
+ });
+ const cloudSyncEnabled = experimentalQuery.data?.enableCloudSync === true;
+
+ const upstreamQuery = useQuery({
+ queryKey: selectedCompanyId ? queryKeys.cloudUpstreams(selectedCompanyId) : ["cloud-upstreams", "__disabled__"],
+ queryFn: () => cloudUpstreamsApi.list(selectedCompanyId!),
+ enabled: !!selectedCompanyId && cloudSyncEnabled,
+ });
+
+ const connection = upstreamQuery.data?.connections[0] ?? null;
+ const latestRun = activeRun ?? upstreamQuery.data?.runs[0] ?? null;
+
+ const callbackParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
+ const code = callbackParams.get("code");
+ const state = callbackParams.get("state");
+ const callbackError = callbackParams.get("error");
+
+ const settingsPath = useMemo(() => {
+ const pathPrefix = extractCompanyPrefixFromPath(location.pathname);
+ return applyCompanyPrefix("/company/settings/cloud-upstream", pathPrefix ?? selectedCompany?.issuePrefix ?? null);
+ }, [location.pathname, selectedCompany?.issuePrefix]);
+
+ const finishMutation = useMutation({
+ mutationFn: (input: { pendingConnectionId: string; code: string; state: string }) =>
+ cloudUpstreamsApi.finishConnect(input),
+ onSuccess: async () => {
+ localStorage.removeItem(PENDING_CONNECTION_KEY);
+ setNotice("Cloud upstream connection approved.");
+ setActionError(null);
+ await invalidateUpstreams();
+ window.history.replaceState(null, "", settingsPath);
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to finish connection."),
+ });
+ const {
+ mutate: finishConnect,
+ isError: finishConnectFailed,
+ isPending: finishConnectPending,
+ isSuccess: finishConnectSucceeded,
+ } = finishMutation;
+
+ useEffect(() => {
+ if (!cloudSyncEnabled || !code || !state || finishConnectPending || finishConnectSucceeded || finishConnectFailed) return;
+ const pendingConnectionId = localStorage.getItem(PENDING_CONNECTION_KEY);
+ if (!pendingConnectionId) {
+ setActionError("No pending cloud upstream connection was found. Start the connection again.");
+ return;
+ }
+ finishConnect({ pendingConnectionId, code, state });
+ }, [cloudSyncEnabled, code, finishConnect, finishConnectFailed, finishConnectPending, finishConnectSucceeded, state]);
+
+ useEffect(() => {
+ if (callbackError) {
+ setActionError(`Cloud upstream connection was not approved: ${callbackError}`);
+ }
+ }, [callbackError]);
+
+ const startMutation = useMutation({
+ mutationFn: () =>
+ cloudUpstreamsApi.startConnect({
+ companyId: selectedCompanyId!,
+ remoteUrl,
+ redirectUri: `${window.location.origin}${settingsPath}`,
+ }),
+ onSuccess: (result) => {
+ localStorage.setItem(PENDING_CONNECTION_KEY, result.pendingConnectionId);
+ setActionError(null);
+ window.location.assign(result.authorizationUrl);
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to start connection."),
+ });
+
+ const previewMutation = useMutation({
+ mutationFn: (input: { connectionId: string; companyId: string }) =>
+ cloudUpstreamsApi.preview(input.connectionId, { companyId: input.companyId }),
+ onSuccess: (nextPreview) => {
+ setPreview(nextPreview);
+ setActionError(null);
+ },
+ onError: (error) => setActionError(previewErrorMessage(error)),
+ });
+
+ const runMutation = useMutation({
+ mutationFn: (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }) =>
+ cloudUpstreamsApi.createRun(input.connectionId, {
+ companyId: input.companyId,
+ retryOfRunId: input.retryOfRunId ?? null,
+ }),
+ onSuccess: async (run) => {
+ setActiveRun(run);
+ setNotice(run.status === "succeeded"
+ ? "Push run completed. Review activation before unpausing automations."
+ : "Push run failed. Review the run events and retry after correcting the issue.");
+ setActionError(null);
+ await invalidateUpstreams();
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to run push."),
+ });
+ const activationMutation = useMutation({
+ mutationFn: (input: { run: CloudUpstreamRun; entityType: CloudUpstreamActivationEntityType }) =>
+ cloudUpstreamsApi.activateEntities(input.run.connectionId, input.run.id, {
+ companyId: input.run.companyId,
+ entityType: input.entityType,
+ }),
+ onSuccess: async (run) => {
+ setActiveRun(run);
+ setNotice("Activation checklist updated.");
+ setActionError(null);
+ await invalidateUpstreams();
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to activate imported entities."),
+ });
+
+ async function invalidateUpstreams() {
+ if (!selectedCompanyId) return;
+ await queryClient.invalidateQueries({ queryKey: queryKeys.cloudUpstreams(selectedCompanyId) });
+ }
+
+ if (!selectedCompanyId || !selectedCompany) {
+ return Select a company to configure cloud upstream.
;
+ }
+
+ if (experimentalQuery.isLoading) {
+ return Loading experimental settings...
;
+ }
+
+ if (!cloudSyncEnabled) {
+ return (
+
+
+
+
Cloud upstream
+
+
+ Cloud sync is disabled. Enable it in{" "}
+
+ Instance Settings
+ {" "}
+ to show upstream connection and push tools.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Cloud upstream
+
+
+ Push {selectedCompany.name} into a Paperclip Cloud stack. Automations stay paused until activation.
+
+
+ {connection?.target.origin ? (
+
+
+
+ Open cloud
+
+
+ ) : null}
+
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+
+
+
+ Connection
+
+ {connection ? (
+
+
+
+ {connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
+
+
+ {connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
+
+
+ Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
+
+
+
+
previewMutation.mutate({ connectionId: connection.id, companyId: connection.companyId })}
+ disabled={previewMutation.isPending || connection.tokenStatus !== "connected"}
+ >
+ {previewMutation.isPending ? : }
+ Preview push
+
+ {previewMutation.isPending ?
: null}
+
+
+ ) : (
+
+ setRemoteUrl(event.target.value)}
+ placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
+ aria-label="Paperclip Cloud stack URL"
+ />
+ startMutation.mutate()} disabled={startMutation.isPending || !remoteUrl.trim()}>
+ {startMutation.isPending ? : }
+ Connect
+
+
+ )}
+
+
+
+ {preview ? (
+
+
+
Preview
+
runMutation.mutate({ connectionId: preview.connectionId, companyId: preview.sourceCompanyId })}
+ disabled={runMutation.isPending || !preview.schemaCompatible}
+ >
+ {runMutation.isPending ? : }
+ Push to cloud
+
+
+
+
+
+
+ ) : null}
+
+ {latestRun ? (
+
+
+
Progress and finish
+
+ downloadRunReport(latestRun)}>
+
+ Download report
+
+ {latestRun.status === "failed" || latestRun.status === "cancelled" ? (
+ runMutation.mutate({
+ connectionId: latestRun.connectionId,
+ companyId: latestRun.companyId,
+ retryOfRunId: latestRun.id,
+ })}
+ disabled={runMutation.isPending}
+ >
+
+ Retry
+
+ ) : latestRun.status === "succeeded" ? (
+ runMutation.mutate({ connectionId: latestRun.connectionId, companyId: latestRun.companyId })}
+ disabled={runMutation.isPending}
+ >
+
+ Re-run
+
+ ) : null}
+
+
+
+
+
+
{latestRun.status}
+
+ Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt ? `completed ${formatDate(latestRun.completedAt)}` : "in progress"}
+
+
+
{latestRun.progressPercent}%
+
+
+
+ {latestRun.events.map((event) => (
+
+ {formatDate(event.at)}
+ {event.phase}
+ {event.message}
+
+ ))}
+
+
+
+ {latestRun.status === "succeeded" ? (
+ activationMutation.mutate({ run: latestRun, entityType })}
+ />
+ ) : null}
+
+ ) : null}
+
+ {upstreamQuery.data?.runs.length ? (
+
+
+
+ History
+
+
+ {upstreamQuery.data.runs.map((run) => (
+ setActiveRun(run)}
+ >
+ Run {run.id.slice(0, 8)} · {run.status}
+ {formatDate(run.createdAt)}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function PreviewProgressHint() {
+ const [elapsed, setElapsed] = useState(0);
+ useEffect(() => {
+ const startedAt = Date.now();
+ const interval = window.setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
+ return () => window.clearInterval(interval);
+ }, []);
+ const message = elapsed < 15
+ ? "Building manifest..."
+ : elapsed < 45
+ ? `Building manifest... ${elapsed}s. Large companies can take up to a minute.`
+ : `Still building manifest... ${elapsed}s. PAP-scale companies routinely take ~60s.`;
+ return {message}
;
+}
+
+function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
+ const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
+ return (
+
+ {STEPS.map((step, index) => {
+ const complete = index < activeIndex;
+ const active = index === activeIndex;
+ return (
+
+ {complete ? (
+
+ ) : (
+
+ )}
+ {step.label}
+
+ );
+ })}
+
+ );
+}
+
+function SummaryGrid({ summary }: { summary: CloudUpstreamPreview["summary"] }) {
+ return (
+
+ {summary.map((item) => (
+
+
{item.count}
+
{item.label}
+
+ ))}
+
+ );
+}
+
+function WarningsPanel({ warnings }: { warnings: CloudUpstreamPreview["warnings"] }) {
+ return (
+
+
+
+ Warnings
+
+
+ {warnings.map((warning) => (
+
+
+
{warning.title}
+
{warning.detail}
+
+ ))}
+
+
+ );
+}
+
+function ConflictTable({ conflicts }: { conflicts: CloudUpstreamPreview["conflicts"] }) {
+ return (
+
+
Conflicts
+ {conflicts.length === 0 ? (
+
No target conflicts detected for this preview.
+ ) : (
+
+ {conflicts.map((conflict) => (
+
+ {conflict.entityType}
+ {conflict.sourceLabel}
+ {conflict.targetLabel}
+ {conflict.plannedAction}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ActivationChecklist({
+ run,
+ pendingEntityType,
+ isPending,
+ onActivate,
+}: {
+ run: CloudUpstreamRun;
+ pendingEntityType: CloudUpstreamActivationEntityType | null;
+ isPending: boolean;
+ onActivate: (entityType: CloudUpstreamActivationEntityType) => void;
+}) {
+ const rows = buildActivationRows(run);
+ return (
+
+
Activation checklist
+
+ {rows.map((row) => {
+ const pending = isPending && pendingEntityType === row.key;
+ const activated = row.status === "activated";
+ return (
+
+
+
{row.label}
+
{row.statusLabel}
+
+
+ {row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail}
+
+
+ onActivate(row.key)}
+ disabled={row.count === 0 || activated || isPending}
+ >
+ {pending ? : null}
+ {activated ? "Activated" : "Activate"}
+
+
+ Keep paused
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+export function buildActivationRows(run: CloudUpstreamRun) {
+ const activationChecklist = activationChecklistFromReport(run.report);
+ return ACTIVATION_CATEGORIES.map((category) => {
+ const decision = activationChecklist[category.key];
+ const count = summaryCount(run.summary, category.key);
+ const status = decision?.status === "activated" ? "activated" : "paused";
+ const pluralLabel = `${category.singular}${count === 1 ? "" : "s"}`;
+ return {
+ ...category,
+ count,
+ pluralLabel,
+ status,
+ detail: `${count} imported ${pluralLabel} are paused by default. ${category.detail}`,
+ statusLabel: status === "activated"
+ ? `${count} activated`
+ : count === 0
+ ? "0 imported"
+ : `${count} paused`,
+ };
+ });
+}
+
+function summaryCount(summary: CloudUpstreamRun["summary"], key: CloudUpstreamActivationEntityType): number {
+ return summary.find((item) => item.key === key)?.count ?? 0;
+}
+
+function activationChecklistFromReport(report: CloudUpstreamRun["report"]): Partial> {
+ const value = optionalRecord(report.activationChecklist);
+ const decisions: Partial> = {};
+ for (const key of ["agents", "routines", "monitors"] as const) {
+ const item = optionalRecord(value[key]);
+ if (!item) continue;
+ decisions[key] = {
+ entityType: key,
+ count: typeof item.count === "number" ? item.count : 0,
+ status: item.status === "activated" ? "activated" : "paused",
+ activatedAt: typeof item.activatedAt === "string" ? item.activatedAt : null,
+ };
+ }
+ return decisions;
+}
+
+function optionalRecord(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
+}
+
+function downloadRunReport(run: CloudUpstreamRun) {
+ const blob = new Blob([JSON.stringify(run.report, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `cloud-upstream-run-${run.id}.json`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
+
+function formatDate(value: string) {
+ return new Date(value).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function formatBytes(value: number) {
+ if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MiB`;
+ if (value >= 1024) return `${Math.round(value / 1024)} KiB`;
+ return `${value} B`;
+}
+
+function previewErrorMessage(error: unknown): string {
+ const code = error instanceof Error ? error.message : null;
+ if (code === "payload_too_large" || code === "bad_request") {
+ return "Local company is too large to preview as a single request. Click Push to continue (the Push step uploads in chunks), or see the docs for chunked-preview options.";
+ }
+ return code ?? "Failed to preview push.";
+}
diff --git a/ui/src/pages/CloudUpstreamUxLab.tsx b/ui/src/pages/CloudUpstreamUxLab.tsx
new file mode 100644
index 00000000000..976c3d865a1
--- /dev/null
+++ b/ui/src/pages/CloudUpstreamUxLab.tsx
@@ -0,0 +1,822 @@
+import { useMemo } from "react";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ CloudUpload,
+ ExternalLink,
+ FileJson,
+ History,
+ Loader2,
+ RefreshCcw,
+ ShieldAlert,
+} from "lucide-react";
+import type {
+ CloudUpstreamActivationDecision,
+ CloudUpstreamActivationEntityType,
+ CloudUpstreamConflict,
+ CloudUpstreamConnection,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamStep,
+ CloudUpstreamSummaryCount,
+ CloudUpstreamWarning,
+} from "@paperclipai/shared";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useLocation } from "@/lib/router";
+
+type FixtureStateKey =
+ | "settings-pane"
+ | "connect-wizard"
+ | "schema-mismatch"
+ | "preview"
+ | "preview-clean"
+ | "progress"
+ | "retry"
+ | "finish";
+
+const STEPS: Array<{ key: CloudUpstreamStep; label: string }> = [
+ { key: "connect", label: "Connect" },
+ { key: "scan", label: "Scan" },
+ { key: "preview", label: "Preview" },
+ { key: "push", label: "Push" },
+ { key: "verify", label: "Verify" },
+ { key: "activate", label: "Activate" },
+];
+
+const ACTIVATION_CATEGORIES: Array<{
+ key: CloudUpstreamActivationEntityType;
+ label: string;
+ singular: string;
+ detail: string;
+}> = [
+ {
+ key: "agents",
+ label: "Agents",
+ singular: "agent",
+ detail: "Keep paused until cloud secrets and adapter credentials are verified.",
+ },
+ {
+ key: "routines",
+ label: "Routines",
+ singular: "routine",
+ detail: "Review schedules before enabling triggers.",
+ },
+ {
+ key: "monitors",
+ label: "Monitors",
+ singular: "monitor",
+ detail: "Activate after the target instance has been smoke tested.",
+ },
+];
+
+const FIXTURE_LABELS: Record = {
+ "settings-pane": "1 · Settings → Cloud upstream pane (enabled)",
+ "connect-wizard": "2 · Connect wizard — remote URL entry + PKCE launch",
+ "schema-mismatch": "3 · Connect wizard — schema-mismatch hard block",
+ preview: "4 · Preview — conflicts, warnings, planned actions",
+ "preview-clean": "5 · Preview — clean run with no conflicts",
+ progress: "6 · Durable progress — mid-run from run events",
+ retry: "7 · Retry without duplicating ledger entries",
+ finish: "8 · Finish / activation checklist with run report",
+};
+
+const PARSE_ORDER: FixtureStateKey[] = [
+ "settings-pane",
+ "connect-wizard",
+ "schema-mismatch",
+ "preview",
+ "preview-clean",
+ "progress",
+ "retry",
+ "finish",
+];
+
+export function CloudUpstreamUxLab() {
+ const location = useLocation();
+ const { state, showChrome } = useMemo(() => {
+ const params = new URLSearchParams(location.search);
+ const raw = (params.get("state") ?? "settings-pane") as FixtureStateKey;
+ return {
+ state: PARSE_ORDER.includes(raw) ? raw : "settings-pane",
+ showChrome: params.get("chrome") === "on",
+ };
+ }, [location.search]);
+
+ const fixture = useMemo(() => buildFixture(state), [state]);
+
+ return (
+
+ {showChrome ? : null}
+
+
+ );
+}
+
+function FixtureNav({ active }: { active: FixtureStateKey }) {
+ return (
+
+
UX lab · cloud upstream
+
+
+ );
+}
+
+interface Fixture {
+ selectedCompanyName: string;
+ connection: CloudUpstreamConnection | null;
+ preview: CloudUpstreamPreview | null;
+ latestRun: CloudUpstreamRun | null;
+ history: CloudUpstreamRun[];
+ notice: string | null;
+ actionError: string | null;
+}
+
+function CloudUpstreamRender({ fixture }: { fixture: Fixture }) {
+ const { connection, preview, latestRun, history, notice, actionError, selectedCompanyName } = fixture;
+ const activeStep: CloudUpstreamStep = latestRun?.activeStep
+ ?? (preview ? "preview" : connection?.tokenStatus === "connected" ? "scan" : "connect");
+ return (
+
+
+
+
+
+
Cloud upstream
+
+
+ Push {selectedCompanyName} into a Paperclip Cloud stack. Automations stay paused until activation.
+
+
+ {connection?.target.origin ? (
+
+
+
+ Open cloud
+
+
+ ) : null}
+
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+
+
+
+ Connection
+
+ {connection ? (
+
+
+
+ {connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
+
+
+ {connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
+
+
+ Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
+
+
+
+
+ Preview push
+
+
+ ) : (
+
+
+
+
+ Discovering
+
+
+ )}
+
+
+
+ {preview ? (
+
+
+
Preview
+
+
+ Push to cloud
+
+
+
+
+
+
+ ) : null}
+
+ {latestRun ? (
+
+
+
Progress and finish
+
+
+
+ Download report
+
+ {latestRun.status === "failed" || latestRun.status === "cancelled" ? (
+
+
+ Retry
+
+ ) : latestRun.status === "succeeded" ? (
+
+
+ Re-run
+
+ ) : null}
+
+
+
+
+
+
{latestRun.status}
+
+ Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt
+ ? `completed ${formatDate(latestRun.completedAt)}`
+ : latestRun.status === "running"
+ ? "in progress"
+ : "in progress"}
+
+
+
{latestRun.progressPercent}%
+
+
+
+ {latestRun.events.map((event) => (
+
+ {formatDate(event.at)}
+ {event.phase}
+ {event.message}
+
+ ))}
+
+
+
+ {latestRun.status === "succeeded" ? : null}
+
+ ) : null}
+
+ {history.length ? (
+
+
+
+ History
+
+
+ {history.map((run) => (
+
+ Run {run.id.slice(0, 8)} · {run.status}
+ {formatDate(run.createdAt)}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
+ const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
+ return (
+
+ {STEPS.map((step, index) => {
+ const complete = index < activeIndex;
+ const active = index === activeIndex;
+ return (
+
+ {complete ? (
+
+ ) : (
+
+ )}
+ {step.label}
+
+ );
+ })}
+
+ );
+}
+
+function SummaryGrid({ summary }: { summary: CloudUpstreamSummaryCount[] }) {
+ return (
+
+ {summary.map((item) => (
+
+
{item.count}
+
{item.label}
+
+ ))}
+
+ );
+}
+
+function WarningsPanel({ warnings }: { warnings: CloudUpstreamWarning[] }) {
+ return (
+
+
+
+ Warnings
+
+
+ {warnings.map((warning) => (
+
+
+
{warning.title}
+
{warning.detail}
+
+ ))}
+
+
+ );
+}
+
+function ConflictTable({ conflicts }: { conflicts: CloudUpstreamConflict[] }) {
+ return (
+
+
Conflicts
+ {conflicts.length === 0 ? (
+
No target conflicts detected for this preview.
+ ) : (
+
+ {conflicts.map((conflict) => (
+
+ {conflict.entityType}
+ {conflict.sourceLabel}
+ {conflict.targetLabel}
+ {conflict.plannedAction}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ActivationChecklist({ run }: { run: CloudUpstreamRun }) {
+ const rows = buildActivationRows(run);
+ return (
+
+
Activation checklist
+
+ {rows.map((row) => {
+ const activated = row.status === "activated";
+ return (
+
+
+
{row.label}
+
{row.statusLabel}
+
+
+ {row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail}
+
+
+
+ {activated ? "Activated" : "Activate"}
+
+
+ Keep paused
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function buildActivationRows(run: CloudUpstreamRun) {
+ const decisions = decisionsFromReport(run.report);
+ return ACTIVATION_CATEGORIES.map((category) => {
+ const decision = decisions[category.key];
+ const count = summaryCount(run.summary, category.key);
+ const status = decision?.status === "activated" ? "activated" : "paused";
+ const pluralLabel = `${category.singular}${count === 1 ? "" : "s"}`;
+ return {
+ ...category,
+ count,
+ pluralLabel,
+ status,
+ detail: `${count} imported ${pluralLabel} are paused by default. ${category.detail}`,
+ statusLabel: status === "activated"
+ ? `${count} activated`
+ : count === 0
+ ? "0 imported"
+ : `${count} paused`,
+ };
+ });
+}
+
+function decisionsFromReport(report: Record): Partial> {
+ const value = optionalRecord(report.activationChecklist);
+ const decisions: Partial> = {};
+ for (const key of ["agents", "routines", "monitors"] as const) {
+ const item = optionalRecord(value[key]);
+ if (!item) continue;
+ decisions[key] = {
+ entityType: key,
+ count: typeof item.count === "number" ? item.count : 0,
+ status: item.status === "activated" ? "activated" : "paused",
+ activatedAt: typeof item.activatedAt === "string" ? item.activatedAt : null,
+ };
+ }
+ return decisions;
+}
+
+function summaryCount(summary: CloudUpstreamSummaryCount[], key: CloudUpstreamActivationEntityType): number {
+ return summary.find((item) => item.key === key)?.count ?? 0;
+}
+
+function optionalRecord(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
+}
+
+function formatDate(value: string) {
+ return new Date(value).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: "UTC",
+ });
+}
+
+function formatBytes(value: number) {
+ if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MiB`;
+ if (value >= 1024) return `${Math.round(value / 1024)} KiB`;
+ return `${value} B`;
+}
+
+const STACK_TARGET = {
+ stackId: "stk_2vKqz9D8mNFqQ7Rp",
+ stackSlug: "paperclip-prod",
+ stackDisplayName: "Paperclip Prod",
+ companyId: "co_4hT2yX",
+ primaryHost: "paperclip.paperclip.app",
+ origin: "https://paperclip.paperclip.app",
+ product: "paperclip-cloud",
+ schemaMajor: 7,
+ maxChunkBytes: 5 * 1024 * 1024,
+};
+
+const STACK_TARGET_SCHEMA_BEHIND = {
+ ...STACK_TARGET,
+ schemaMajor: 5,
+};
+
+function connectedConnection(target = STACK_TARGET): CloudUpstreamConnection {
+ return {
+ id: "cu_conn_8d3f1b6a",
+ companyId: "co_4hT2yX",
+ remoteUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
+ target,
+ tokenStatus: "connected",
+ scopes: ["upstream.push", "upstream.preview"],
+ authorizedGlobalUserId: "user_9pXqYzAbCdEf",
+ expiresAt: "2026-08-18T19:00:00.000Z",
+ createdAt: "2026-05-18T18:45:00.000Z",
+ updatedAt: "2026-05-18T19:02:18.000Z",
+ lastRunId: null,
+ };
+}
+
+const PREVIEW_SUMMARY: CloudUpstreamSummaryCount[] = [
+ { key: "users", label: "Users", count: 14 },
+ { key: "agents", label: "Agents", count: 6 },
+ { key: "routines", label: "Routines", count: 4 },
+ { key: "monitors", label: "Monitors", count: 2 },
+];
+
+const PREVIEW_WARNINGS_NORMAL: CloudUpstreamWarning[] = [
+ {
+ code: "imported_automations_paused",
+ severity: "warning",
+ title: "Automations stay paused",
+ detail: "Imported agents, routines, and monitors require explicit activation after the push.",
+ },
+ {
+ code: "unmatched_users_import_as_historical_authors",
+ severity: "warning",
+ title: "Unmatched users become historical authors",
+ detail: "Invite now remains a secondary action after the transfer is complete.",
+ },
+ {
+ code: "secret_values_redacted",
+ severity: "warning",
+ title: "Secret values are not transferred",
+ detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.",
+ },
+];
+
+const PREVIEW_WARNINGS_SCHEMA: CloudUpstreamWarning[] = [
+ {
+ code: "schema_mismatch",
+ severity: "blocker",
+ title: "Cloud stack upgrade required",
+ detail: "This local build uses upstream schema 7, but the cloud stack reports schema 5.",
+ },
+ ...PREVIEW_WARNINGS_NORMAL,
+];
+
+const PREVIEW_CONFLICTS: CloudUpstreamConflict[] = [
+ {
+ id: "conflict_user_serena",
+ entityType: "user",
+ sourceLabel: "serena@magicmachine.co (unmatched)",
+ targetLabel: "→ historical author Serena R.",
+ plannedAction: "create",
+ reason: "Target stack has no matching identity. Will arrive as historical author; invite available after push.",
+ },
+ {
+ id: "conflict_user_dotta",
+ entityType: "user",
+ sourceLabel: "dotta@magicmachine.co",
+ targetLabel: "↦ dotta@magicmachine.co (cloud)",
+ plannedAction: "update",
+ reason: "Existing cloud identity matches local user; will be merged.",
+ },
+ {
+ id: "conflict_agent_qa",
+ entityType: "agent",
+ sourceLabel: "QA · qa-bot",
+ targetLabel: "↦ QA · qa-bot (cloud)",
+ plannedAction: "update",
+ reason: "Mapped to existing cloud agent. Imported run history will be appended.",
+ },
+ {
+ id: "conflict_routine_nightly_reports",
+ entityType: "routine",
+ sourceLabel: "Nightly status report",
+ targetLabel: "(new in cloud)",
+ plannedAction: "create",
+ reason: "Routine does not exist in the target stack and will be created in paused state.",
+ },
+];
+
+function basePreview(): CloudUpstreamPreview {
+ return {
+ connectionId: "cu_conn_8d3f1b6a",
+ sourceCompanyId: "co_local_pc521d",
+ target: STACK_TARGET,
+ schemaCompatible: true,
+ summary: PREVIEW_SUMMARY,
+ warnings: PREVIEW_WARNINGS_NORMAL,
+ conflicts: PREVIEW_CONFLICTS,
+ generatedAt: "2026-05-18T19:03:14.000Z",
+ };
+}
+
+function schemaMismatchPreview(): CloudUpstreamPreview {
+ return {
+ ...basePreview(),
+ target: STACK_TARGET_SCHEMA_BEHIND,
+ schemaCompatible: false,
+ summary: [],
+ conflicts: [],
+ warnings: PREVIEW_WARNINGS_SCHEMA,
+ };
+}
+
+function cleanPreview(): CloudUpstreamPreview {
+ return {
+ ...basePreview(),
+ conflicts: [],
+ warnings: PREVIEW_WARNINGS_NORMAL.slice(0, 1),
+ };
+}
+
+const PROGRESS_EVENTS = [
+ { id: "evt_01", at: "2026-05-18T19:10:02.000Z", phase: "scan" as CloudUpstreamStep, type: "completed" as const, message: "Scanned 14 users, 6 agents, 4 routines, 2 monitors." },
+ { id: "evt_02", at: "2026-05-18T19:10:11.000Z", phase: "preview" as CloudUpstreamStep, type: "completed" as const, message: "Preview generated with 4 conflicts and 3 warnings." },
+ { id: "evt_03", at: "2026-05-18T19:10:31.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "users · 8 created, 6 mapped to existing identities." },
+ { id: "evt_04", at: "2026-05-18T19:10:48.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "agents · 4 created paused, 2 updated paused." },
+ { id: "evt_05", at: "2026-05-18T19:10:58.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "routines · 3 created paused, 1 updated." },
+ { id: "evt_06", at: "2026-05-18T19:11:09.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "monitors · 2 created paused." },
+ { id: "evt_07", at: "2026-05-18T19:11:18.000Z", phase: "verify" as CloudUpstreamStep, type: "updated" as const, message: "Verifying transferred ledger checksums…" },
+];
+
+function runningRun(): CloudUpstreamRun {
+ return {
+ id: "run_3kQ8mNpW9bX2zL4Y",
+ connectionId: "cu_conn_8d3f1b6a",
+ companyId: "co_local_pc521d",
+ status: "running",
+ activeStep: "push",
+ progressPercent: 62,
+ dryRun: false,
+ summary: PREVIEW_SUMMARY,
+ warnings: PREVIEW_WARNINGS_NORMAL,
+ conflicts: PREVIEW_CONFLICTS,
+ events: PROGRESS_EVENTS,
+ targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
+ report: {},
+ retryOfRunId: null,
+ createdAt: "2026-05-18T19:10:01.000Z",
+ updatedAt: "2026-05-18T19:11:18.000Z",
+ completedAt: null,
+ };
+}
+
+function failedRun(): CloudUpstreamRun {
+ return {
+ id: "run_5fXqR2bT7aD8zP1K",
+ connectionId: "cu_conn_8d3f1b6a",
+ companyId: "co_local_pc521d",
+ status: "failed",
+ activeStep: "push",
+ progressPercent: 78,
+ dryRun: false,
+ summary: PREVIEW_SUMMARY,
+ warnings: PREVIEW_WARNINGS_NORMAL,
+ conflicts: PREVIEW_CONFLICTS,
+ events: [
+ ...PROGRESS_EVENTS,
+ {
+ id: "evt_08",
+ at: "2026-05-18T19:11:30.000Z",
+ phase: "push",
+ type: "failed",
+ message: "Apply rejected: cloud rejected chunk 4 of 6 (HTTP 502). Ledger entries from chunks 1–3 retained; chunk 4 not committed.",
+ },
+ ],
+ targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
+ report: { ledgerCheckpoint: "chunk-3" },
+ retryOfRunId: null,
+ createdAt: "2026-05-18T19:10:01.000Z",
+ updatedAt: "2026-05-18T19:11:30.000Z",
+ completedAt: null,
+ };
+}
+
+function succeededRun(): CloudUpstreamRun {
+ return {
+ id: "run_7aBcD9eFgH2iJ3kL",
+ connectionId: "cu_conn_8d3f1b6a",
+ companyId: "co_local_pc521d",
+ status: "succeeded",
+ activeStep: "activate",
+ progressPercent: 100,
+ dryRun: false,
+ summary: PREVIEW_SUMMARY,
+ warnings: PREVIEW_WARNINGS_NORMAL,
+ conflicts: PREVIEW_CONFLICTS,
+ events: [
+ ...PROGRESS_EVENTS,
+ {
+ id: "evt_08",
+ at: "2026-05-18T19:11:25.000Z",
+ phase: "verify",
+ type: "completed",
+ message: "Ledger checksums match. Push committed.",
+ },
+ {
+ id: "evt_09",
+ at: "2026-05-18T19:11:31.000Z",
+ phase: "activate",
+ type: "completed",
+ message: "Activation checklist pending operator approval — automations remain paused.",
+ },
+ ],
+ targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
+ report: {
+ activationChecklist: {
+ agents: { count: 6, status: "paused", activatedAt: null },
+ routines: { count: 4, status: "paused", activatedAt: null },
+ monitors: { count: 2, status: "paused", activatedAt: null },
+ },
+ },
+ retryOfRunId: null,
+ createdAt: "2026-05-18T19:10:01.000Z",
+ updatedAt: "2026-05-18T19:11:31.000Z",
+ completedAt: "2026-05-18T19:11:31.000Z",
+ };
+}
+
+function buildFixture(state: FixtureStateKey): Fixture {
+ switch (state) {
+ case "settings-pane":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: connectedConnection(),
+ preview: null,
+ latestRun: null,
+ history: [],
+ notice: "Cloud upstream connection approved.",
+ actionError: null,
+ };
+ case "connect-wizard":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: null,
+ preview: null,
+ latestRun: null,
+ history: [],
+ notice: null,
+ actionError: null,
+ };
+ case "schema-mismatch":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: connectedConnection(STACK_TARGET_SCHEMA_BEHIND),
+ preview: schemaMismatchPreview(),
+ latestRun: null,
+ history: [],
+ notice: null,
+ actionError: "Cloud stack is on schema 5 but this local build pushes schema 7. Upgrade the cloud stack to continue.",
+ };
+ case "preview":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: connectedConnection(),
+ preview: basePreview(),
+ latestRun: null,
+ history: [],
+ notice: null,
+ actionError: null,
+ };
+ case "preview-clean":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: connectedConnection(),
+ preview: cleanPreview(),
+ latestRun: null,
+ history: [],
+ notice: "Preview completed. No target conflicts detected.",
+ actionError: null,
+ };
+ case "progress":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: connectedConnection(),
+ preview: null,
+ latestRun: runningRun(),
+ history: [],
+ notice: null,
+ actionError: null,
+ };
+ case "retry":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: connectedConnection(),
+ preview: null,
+ latestRun: failedRun(),
+ history: [
+ { ...failedRun(), id: "run_9pYqXwVtSrQ" },
+ ],
+ notice: null,
+ actionError: "Push run failed. Review the events. Retry resumes from ledger checkpoint chunk-3 — chunks 1–3 will not be re-applied.",
+ };
+ case "finish":
+ return {
+ selectedCompanyName: "Paperclip · PC521D",
+ connection: connectedConnection(),
+ preview: null,
+ latestRun: succeededRun(),
+ history: [
+ { ...succeededRun(), id: "run_aZcXvBnMqWeR" },
+ ],
+ notice: "Push run completed. Review activation before unpausing automations.",
+ actionError: null,
+ };
+ }
+}
diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx
index 0deecb2c980..41af7db309f 100644
--- a/ui/src/pages/CompanySettings.tsx
+++ b/ui/src/pages/CompanySettings.tsx
@@ -1,5 +1,5 @@
import { ChangeEvent, useEffect, useState } from "react";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
@@ -9,9 +9,10 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
+import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
-import { Settings, Check, Download, Upload } from "lucide-react";
+import { Settings, Check, CloudUpload, Download, Upload } from "lucide-react";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import {
Field,
@@ -37,6 +38,10 @@ export function CompanySettings() {
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
+ const { data: experimentalSettings } = useQuery({
+ queryKey: queryKeys.instance.experimentalSettings,
+ queryFn: () => instanceSettingsApi.getExperimental(),
+ });
// General settings local state
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
@@ -65,6 +70,7 @@ export function CompanySettings() {
Number.isInteger(attachmentMaxBytes)
&& attachmentMaxBytes >= BYTES_PER_MIB
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
+ const cloudSyncEnabled = experimentalSettings?.enableCloudSync === true;
const generalDirty =
!!selectedCompany &&
@@ -520,7 +526,15 @@ export function CompanySettings() {
Import and export have moved to dedicated pages accessible from the{" "}
Org Chart header.
-
+
+
+
+
+
Cloud Sync
+
+ Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.
+ Saved connections and run history are preserved when this is disabled.
+
+
+
toggleMutation.mutate({ enableCloudSync: !enableCloudSync })}
+ disabled={toggleMutation.isPending}
+ aria-label="Toggle cloud sync experimental setting"
+ />
+
+
+
From ad6effa65c6cb17266ba392e614dc8ea6916ce7b Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 22 May 2026 09:57:22 -0500
Subject: [PATCH 4/4] [codex] Improve runtime and import reliability (#6549)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip coordinates autonomous company work through local and
hosted runtime surfaces.
> - Local embedded Postgres and tenant import/export paths are
foundational reliability pieces.
> - A runtime failure in either path can stop agents or imports before
useful work begins.
> - The branch included remaining fixes for embedded native library
bootstrap and async tenant import handling.
> - This pull request groups those runtime/import reliability changes
into one standalone PR.
> - The benefit is a more robust local runtime and safer cloud tenant
import behavior.
## What Changed
- Prepared embedded Postgres native runtime before startup in
CLI/server/test entrypoints.
- Added embedded Postgres native bootstrap coverage.
- Added async tenant import job handling and deferred validation
coverage.
- Kept the runtime/import changes based directly on current
`origin/master` after related upstream PRs had already merged.
## Verification
- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run
packages/db/src/embedded-postgres-native.test.ts
server/src/__tests__/company-portability-routes.test.ts`
## Risks
- Medium-low: this touches startup/import paths, but the branch is small
and covered by targeted tests.
- The embedded Postgres change depends on platform-specific
native-library behavior, so CI and follow-up checks should still verify
supported runners.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
cli/src/commands/routines.ts | 2 +
cli/src/commands/worktree.ts | 2 +
.../db/src/embedded-postgres-native.test.ts | 43 ++++
packages/db/src/embedded-postgres-native.ts | 85 +++++++
packages/db/src/index.ts | 4 +
packages/db/src/migration-runtime.ts | 2 +
packages/db/src/test-embedded-postgres.ts | 2 +
.../company-portability-routes.test.ts | 164 +++++++++++++
server/src/index.ts | 2 +
server/src/routes/companies.ts | 224 ++++++++++++++++--
10 files changed, 511 insertions(+), 19 deletions(-)
create mode 100644 packages/db/src/embedded-postgres-native.test.ts
create mode 100644 packages/db/src/embedded-postgres-native.ts
diff --git a/cli/src/commands/routines.ts b/cli/src/commands/routines.ts
index 4c5a3ca9677..63ff89d81f3 100644
--- a/cli/src/commands/routines.ts
+++ b/cli/src/commands/routines.ts
@@ -9,6 +9,7 @@ import {
createEmbeddedPostgresLogBuffer,
ensurePostgresDatabase,
formatEmbeddedPostgresError,
+ prepareEmbeddedPostgresNativeRuntime,
routines,
} from "@paperclipai/db";
import { eq, inArray } from "drizzle-orm";
@@ -116,6 +117,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
+ await prepareEmbeddedPostgresNativeRuntime();
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts
index fea6f2c3ec8..db0ca008147 100644
--- a/cli/src/commands/worktree.ts
+++ b/cli/src/commands/worktree.ts
@@ -45,6 +45,7 @@ import {
runDatabaseRestore,
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,
+ prepareEmbeddedPostgresNativeRuntime,
} from "@paperclipai/db";
import type { Command } from "commander";
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
@@ -1059,6 +1060,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
+ await prepareEmbeddedPostgresNativeRuntime();
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
diff --git a/packages/db/src/embedded-postgres-native.test.ts b/packages/db/src/embedded-postgres-native.test.ts
new file mode 100644
index 00000000000..7335f2c6492
--- /dev/null
+++ b/packages/db/src/embedded-postgres-native.test.ts
@@ -0,0 +1,43 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { ensureLinuxSharedLibraryAliases } from "./embedded-postgres-native.js";
+
+describe("embedded Postgres native runtime", () => {
+ const tempDirs: string[] = [];
+
+ afterEach(() => {
+ for (const tempDir of tempDirs.splice(0)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it.runIf(process.platform !== "win32")("creates soname aliases for bundled patch-level shared libraries", async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-"));
+ tempDirs.push(tempDir);
+ fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), "");
+ fs.writeFileSync(path.join(tempDir, "libicui18n.so.60.2"), "");
+ fs.writeFileSync(path.join(tempDir, "README.md"), "");
+
+ const created = await ensureLinuxSharedLibraryAliases(tempDir);
+
+ expect(created.map((file) => path.basename(file)).sort()).toEqual([
+ "libicui18n.so.60",
+ "libicuuc.so.60",
+ ]);
+ expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
+ });
+
+ it.runIf(process.platform !== "win32")("is idempotent when aliases already exist", async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-"));
+ tempDirs.push(tempDir);
+ fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), "");
+
+ await ensureLinuxSharedLibraryAliases(tempDir);
+ const second = await ensureLinuxSharedLibraryAliases(tempDir);
+
+ expect(second).toEqual([]);
+ expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
+ });
+});
diff --git a/packages/db/src/embedded-postgres-native.ts b/packages/db/src/embedded-postgres-native.ts
new file mode 100644
index 00000000000..a304f8a18ec
--- /dev/null
+++ b/packages/db/src/embedded-postgres-native.ts
@@ -0,0 +1,85 @@
+import { promises as fs } from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+
+const require = createRequire(import.meta.url);
+
+function resolveNativePackageName(): string | null {
+ if (process.platform !== "linux") return null;
+
+ switch (process.arch) {
+ case "arm64":
+ return "linux-arm64";
+ case "arm":
+ return "linux-arm";
+ case "ia32":
+ return "linux-ia32";
+ case "ppc64":
+ return "linux-ppc64";
+ case "x64":
+ return "linux-x64";
+ default:
+ return null;
+ }
+}
+
+async function pathExists(value: string): Promise {
+ try {
+ await fs.stat(value);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function resolveEmbeddedPostgresPackageRoot(): string | null {
+ try {
+ const entry = require.resolve("embedded-postgres");
+ return path.dirname(path.dirname(entry));
+ } catch {
+ return null;
+ }
+}
+
+function prependPathEnv(name: string, value: string): void {
+ const current = process.env[name] ?? "";
+ const parts = current.split(path.delimiter).filter(Boolean);
+ if (parts.includes(value)) return;
+ process.env[name] = [value, ...parts].join(path.delimiter);
+}
+
+export async function ensureLinuxSharedLibraryAliases(libDir: string): Promise {
+ const entries = await fs.readdir(libDir, { withFileTypes: true });
+ const created: string[] = [];
+
+ for (const entry of entries) {
+ if (!entry.isFile()) continue;
+ const match = entry.name.match(/^(lib.+\.so\.\d+)\.\d+(?:\.\d+)?$/);
+ if (!match) continue;
+
+ const aliasName = match[1];
+ const aliasPath = path.join(libDir, aliasName);
+ try {
+ await fs.symlink(entry.name, aliasPath);
+ created.push(aliasPath);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
+ throw error;
+ }
+ }
+
+ return created;
+}
+
+export async function prepareEmbeddedPostgresNativeRuntime(): Promise {
+ const nativePackageName = resolveNativePackageName();
+ const packageRoot = resolveEmbeddedPostgresPackageRoot();
+ if (!nativePackageName || !packageRoot) return;
+
+ const nativeRoot = path.resolve(packageRoot, "..", "@embedded-postgres", nativePackageName);
+ const libDir = path.join(nativeRoot, "native", "lib");
+ if (!(await pathExists(libDir))) return;
+
+ prependPathEnv("LD_LIBRARY_PATH", libDir);
+ await ensureLinuxSharedLibraryAliases(libDir);
+}
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 3bf4d22b57c..a1c5bce8b26 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -30,6 +30,10 @@ export {
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,
} from "./embedded-postgres-error.js";
+export {
+ ensureLinuxSharedLibraryAliases,
+ prepareEmbeddedPostgresNativeRuntime,
+} from "./embedded-postgres-native.js";
export { issueRelations } from "./schema/issue_relations.js";
export { issueReferenceMentions } from "./schema/issue_reference_mentions.js";
export * from "./schema/index.js";
diff --git a/packages/db/src/migration-runtime.ts b/packages/db/src/migration-runtime.ts
index 5aa2b6a27d0..3823b6d02e0 100644
--- a/packages/db/src/migration-runtime.ts
+++ b/packages/db/src/migration-runtime.ts
@@ -3,6 +3,7 @@ import { createServer } from "node:net";
import path from "node:path";
import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js";
import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js";
+import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
type EmbeddedPostgresInstance = {
@@ -92,6 +93,7 @@ async function ensureEmbeddedPostgresConnection(
preferredPort: number,
): Promise {
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
+ await prepareEmbeddedPostgresNativeRuntime();
const selectedPort = await findAvailablePort(preferredPort);
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const pgVersionFile = path.resolve(dataDir, "PG_VERSION");
diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts
index 87db5cde962..929d17c5f7e 100644
--- a/packages/db/src/test-embedded-postgres.ts
+++ b/packages/db/src/test-embedded-postgres.ts
@@ -3,6 +3,7 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js";
+import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
type EmbeddedPostgresInstance = {
initialise(): Promise;
@@ -48,6 +49,7 @@ function getReservedTestPorts(): Set {
async function getEmbeddedPostgresCtor(): Promise {
const mod = await import("embedded-postgres");
+ await prepareEmbeddedPostgresNativeRuntime();
return mod.default as EmbeddedPostgresCtor;
}
diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts
index 26f6df16807..4259052537a 100644
--- a/server/src/__tests__/company-portability-routes.test.ts
+++ b/server/src/__tests__/company-portability-routes.test.ts
@@ -139,6 +139,58 @@ function createExportResult() {
};
}
+const importRequest = {
+ source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
+ include: { company: true, agents: true, projects: false, issues: false },
+ target: { mode: "existing_company", companyId },
+ collisionStrategy: "rename",
+};
+
+const cloudHeaders = {
+ "x-paperclip-cloud-stack-id": "stack-alpha",
+ "x-paperclip-cloud-paperclip-company-id": companyId,
+};
+
+function cloudTenantActor() {
+ return {
+ type: "board",
+ userId: "cloud-user-1",
+ userName: "Cloud User",
+ userEmail: "cloud-user@example.com",
+ companyIds: [companyId],
+ memberships: [{ companyId, membershipRole: "owner", status: "active" }],
+ isInstanceAdmin: true,
+ source: "cloud_tenant",
+ };
+}
+
+function createImportResult(action = "updated") {
+ return {
+ company: { id: companyId, action },
+ agents: [{ id: "agent-1" }],
+ warnings: [],
+ };
+}
+
+async function waitForImportJobStatus(app: express.Express, statusUrl: string, status: string) {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ const res = await request(app).get(statusUrl).set(cloudHeaders);
+ if (res.body.job?.status === status) {
+ return res;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ throw new Error(`Timed out waiting for import job to reach ${status}`);
+}
+
+async function waitForCondition(condition: () => boolean, label: string) {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ if (condition()) return;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ throw new Error(`Timed out waiting for ${label}`);
+}
+
describe.sequential("company portability routes", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -426,4 +478,116 @@ describe.sequential("company portability routes", () => {
expect(res.body.error).toContain("Instance admin");
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
+
+ it.sequential("accepts trusted Cloud async import jobs and reports success by job id", async () => {
+ let resolveImport: (value: ReturnType) => void = () => undefined;
+ const pendingImport = new Promise>((resolve) => {
+ resolveImport = resolve;
+ });
+ mockCompanyPortabilityService.importBundle.mockReturnValueOnce(pendingImport);
+ const app = await createApp(cloudTenantActor());
+
+ const accepted = await request(app)
+ .post("/api/companies/import")
+ .set("x-paperclip-cloud-async-import", "1")
+ .set(cloudHeaders)
+ .send(importRequest);
+
+ expect(accepted.status).toBe(202);
+ expect(accepted.body.job.status).toBe("running");
+ expect(accepted.body.statusUrl).toMatch(/^\/api\/companies\/import\/jobs\/tenant-import-/);
+ expect(accepted.body.retryAfterMs).toBe(1000);
+ await waitForCondition(() => mockCompanyPortabilityService.importBundle.mock.calls.length === 1, "import job start");
+ expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1");
+ expect(mockLogActivity).not.toHaveBeenCalled();
+
+ resolveImport(createImportResult("updated"));
+ const succeeded = await waitForImportJobStatus(app, accepted.body.statusUrl, "succeeded");
+
+ expect(succeeded.status).toBe(200);
+ expect(succeeded.body.job.status).toBe("succeeded");
+ expect(succeeded.body.job.result.companyId).toBe(companyId);
+ expect(succeeded.body.retryAfterMs).toBeUndefined();
+ expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
+ action: "company.imported",
+ companyId,
+ details: expect.objectContaining({
+ agentCount: 1,
+ warningCount: 0,
+ companyAction: "updated",
+ }),
+ }));
+
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(succeeded.body.job.completedAt) + (5 * 60 * 1000) + 1);
+ try {
+ const expired = await request(app).get(accepted.body.statusUrl).set(cloudHeaders);
+ expect(expired.status).toBe(404);
+ expect(expired.body.error).toBe("Import job not found");
+ } finally {
+ nowSpy.mockRestore();
+ }
+ });
+
+ it.sequential("reports trusted Cloud async import job failures with the tenant error message", async () => {
+ mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("tenant import exploded"));
+ const app = await createApp(cloudTenantActor());
+
+ const accepted = await request(app)
+ .post("/api/companies/import")
+ .set("x-paperclip-cloud-async-import", "1")
+ .set(cloudHeaders)
+ .send(importRequest);
+
+ expect(accepted.status).toBe(202);
+ const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed");
+
+ expect(failed.status).toBe(200);
+ expect(failed.body.job.status).toBe("failed");
+ expect(failed.body.job.error.message).toBe("tenant import exploded");
+ expect(failed.body.retryAfterMs).toBeUndefined();
+ expect(failed.body.message).toBe("tenant import exploded");
+ expect(mockLogActivity).not.toHaveBeenCalled();
+ });
+
+ it.sequential("accepts trusted Cloud async import jobs before validating the full import payload", async () => {
+ const app = await createApp(cloudTenantActor());
+
+ const accepted = await request(app)
+ .post("/api/companies/import")
+ .set("x-paperclip-cloud-async-import", "1")
+ .set(cloudHeaders)
+ .send({ target: { mode: "existing_company", companyId } });
+
+ expect(accepted.status).toBe(202);
+ expect(accepted.body.job.status).toBe("running");
+ expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
+
+ const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed");
+
+ expect(failed.status).toBe(200);
+ expect(failed.body.job.status).toBe("failed");
+ expect(failed.body.job.error.message).toEqual(expect.any(String));
+ expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
+ expect(mockLogActivity).not.toHaveBeenCalled();
+ });
+
+ it.sequential("keeps global import apply synchronous when Cloud async opt-in is absent", async () => {
+ mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created"));
+ const app = await createApp(cloudTenantActor());
+
+ const res = await request(app)
+ .post("/api/companies/import")
+ .set(cloudHeaders)
+ .send(importRequest);
+
+ expect(res.status).toBe(200);
+ expect(res.body.company.id).toBe(companyId);
+ expect(res.body.company.action).toBe("created");
+ expect(res.body.job).toBeUndefined();
+ expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1");
+ expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
+ action: "company.imported",
+ companyId,
+ }));
+ });
});
diff --git a/server/src/index.ts b/server/src/index.ts
index d9c52d27d65..38caf44a518 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -15,6 +15,7 @@ import {
inspectMigrations,
applyPendingMigrations,
createEmbeddedPostgresLogBuffer,
+ prepareEmbeddedPostgresNativeRuntime,
reconcilePendingMigrationHistory,
formatDatabaseBackupResult,
runDatabaseBackup,
@@ -318,6 +319,7 @@ export async function startServer(): Promise {
"Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.",
);
}
+ await prepareEmbeddedPostgresNativeRuntime();
const dataDir = resolve(config.embeddedPostgresDataDir);
const configuredPort = config.embeddedPostgresPort;
diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts
index 2de26afbe13..3fe9664fcd7 100644
--- a/server/src/routes/companies.ts
+++ b/server/src/routes/companies.ts
@@ -1,3 +1,4 @@
+import { randomUUID } from "node:crypto";
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
@@ -35,6 +36,8 @@ export function companyRoutes(db: Db, storage?: StorageService) {
const access = accessService(db);
const budgets = budgetService(db);
const feedback = feedbackService(db);
+ const importJobs = new Map();
+ const importJobTerminalRetentionMs = 5 * 60 * 1000;
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
@@ -177,27 +180,47 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.json(preview);
});
- router.post(COMPANY_IMPORT_ROUTE_PATH, validate(companyPortabilityImportSchema), async (req, res) => {
+ router.get("/import/jobs/:jobId", async (req, res) => {
+ assertCloudTenantCaller(req);
+ cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
+ const job = importJobs.get(req.params.jobId as string);
+ if (!job || job.cloudTenantKey !== cloudTenantRequestKey(req)) {
+ res.status(404).json({ error: "Import job not found" });
+ return;
+ }
+ res.json(importJobResponse(job));
+ });
+
+ router.post(COMPANY_IMPORT_ROUTE_PATH, async (req, res) => {
assertBoard(req);
- assertImportTargetAccess(req, req.body.target);
+ const rawImportBody: unknown = req.body;
const actor = getActorInfo(req);
- const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
- await logActivity(db, {
- companyId: result.company.id,
- actorType: actor.actorType,
- actorId: actor.actorId,
- action: "company.imported",
- entityType: "company",
- entityId: result.company.id,
- agentId: actor.agentId,
- runId: actor.runId,
- details: {
- include: req.body.include ?? null,
- agentCount: result.agents.length,
- warningCount: result.warnings.length,
- companyAction: result.company.action,
- },
- });
+ const boardUserId = req.actor.type === "board" ? req.actor.userId : null;
+ if (req.header("x-paperclip-cloud-async-import") === "1") {
+ assertCloudTenantCaller(req);
+ cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
+ const job = createImportJob(cloudTenantRequestKey(req));
+ importJobs.set(job.id, job);
+ const operation = async () => {
+ const importBody = companyPortabilityImportSchema.parse(rawImportBody);
+ assertImportTargetAccess(req, importBody.target);
+ const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
+ const result = await portability.importBundle(importBody, boardUserId);
+ await logImportedCompanyActivity(db, activity, result);
+ return result;
+ };
+ res.status(202).json(importJobAcceptedResponse(job));
+ setImmediate(() => {
+ void runImportJob(job, operation);
+ });
+ return;
+ }
+
+ const importBody = companyPortabilityImportSchema.parse(rawImportBody);
+ assertImportTargetAccess(req, importBody.target);
+ const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
+ const result = await portability.importBundle(importBody, boardUserId);
+ await logImportedCompanyActivity(db, activity, result);
res.json(result);
});
@@ -419,3 +442,166 @@ export function companyRoutes(db: Db, storage?: StorageService) {
return router;
}
+
+type CompanyImportResult = {
+ company: { id: string; action: unknown };
+ agents: unknown[];
+ warnings: unknown[];
+};
+
+interface ImportJobRecord {
+ id: string;
+ cloudTenantKey: string;
+ status: "running" | "succeeded" | "failed";
+ createdAt: string;
+ updatedAt: string;
+ completedAt?: string;
+ error?: { message: string };
+ result?: {
+ companyId: string;
+ agentCount: number;
+ warningCount: number;
+ companyAction: unknown;
+ };
+}
+
+interface ImportedCompanyActivityContext {
+ actorType: "user" | "agent";
+ actorId: string;
+ agentId: string | null;
+ runId: string | null;
+ include: unknown;
+}
+
+function assertCloudTenantCaller(req: Request) {
+ if (req.actor.source !== "cloud_tenant") {
+ throw forbidden("Trusted Cloud tenant access required");
+ }
+}
+
+function cloudTenantRequestKey(req: Request) {
+ return [
+ req.actor.userId ?? "",
+ req.header("x-paperclip-cloud-stack-id")?.trim() ?? "",
+ req.header("x-paperclip-cloud-paperclip-company-id")?.trim() ?? "",
+ ].join(":");
+}
+
+function createImportJob(cloudTenantKey: string): ImportJobRecord {
+ const now = new Date().toISOString();
+ return {
+ id: `tenant-import-${randomUUID()}`,
+ cloudTenantKey,
+ status: "running",
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+async function runImportJob(
+ job: ImportJobRecord,
+ operation: () => Promise,
+) {
+ try {
+ const result = await operation();
+ const now = new Date().toISOString();
+ job.status = "succeeded";
+ job.updatedAt = now;
+ job.completedAt = now;
+ job.result = {
+ companyId: result.company.id,
+ agentCount: result.agents.length,
+ warningCount: result.warnings.length,
+ companyAction: result.company.action,
+ };
+ } catch (error) {
+ const now = new Date().toISOString();
+ job.status = "failed";
+ job.updatedAt = now;
+ job.completedAt = now;
+ job.error = { message: errorMessage(error) };
+ }
+}
+
+function importedCompanyActivityContext(
+ actor: ReturnType,
+ include: unknown,
+): ImportedCompanyActivityContext {
+ return {
+ actorType: actor.actorType,
+ actorId: actor.actorId,
+ agentId: actor.agentId,
+ runId: actor.runId,
+ include,
+ };
+}
+
+async function logImportedCompanyActivity(
+ db: Db,
+ activity: ImportedCompanyActivityContext,
+ result: CompanyImportResult,
+) {
+ await logActivity(db, {
+ companyId: result.company.id,
+ actorType: activity.actorType,
+ actorId: activity.actorId,
+ action: "company.imported",
+ entityType: "company",
+ entityId: result.company.id,
+ agentId: activity.agentId,
+ runId: activity.runId,
+ details: {
+ include: activity.include,
+ agentCount: result.agents.length,
+ warningCount: result.warnings.length,
+ companyAction: result.company.action,
+ },
+ });
+}
+
+function importJobAcceptedResponse(job: ImportJobRecord) {
+ return {
+ job: {
+ id: job.id,
+ status: job.status,
+ },
+ statusUrl: `/api/companies/import/jobs/${encodeURIComponent(job.id)}`,
+ retryAfterMs: 1000,
+ };
+}
+
+function importJobResponse(job: ImportJobRecord) {
+ const isTerminal = job.status === "succeeded" || job.status === "failed";
+ const response: Record = {
+ job: {
+ id: job.id,
+ status: job.status,
+ createdAt: job.createdAt,
+ updatedAt: job.updatedAt,
+ ...(job.completedAt ? { completedAt: job.completedAt } : {}),
+ ...(job.error ? { error: job.error } : {}),
+ ...(job.result ? { result: job.result } : {}),
+ },
+ ...(isTerminal ? {} : { retryAfterMs: 1000 }),
+ };
+ if (job.error?.message) {
+ response.error = job.error.message;
+ response.message = job.error.message;
+ response.reason = job.error.message;
+ }
+ return response;
+}
+
+function cleanupTerminalImportJobs(importJobs: Map, terminalRetentionMs: number) {
+ const now = Date.now();
+ for (const [jobId, job] of importJobs) {
+ if (job.status === "running" || !job.completedAt) continue;
+ if (now - Date.parse(job.completedAt) > terminalRetentionMs) {
+ importJobs.delete(jobId);
+ }
+ }
+}
+
+function errorMessage(error: unknown) {
+ return error instanceof Error && error.message.trim() ? error.message : String(error);
+}