[codex] Add agent permissions and controls plan (#6386)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-22 08:12:52 -05:00 committed by GitHub
parent c91a062326
commit 38c185fb8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6744 additions and 395 deletions

View file

@ -118,6 +118,7 @@ Paperclips 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 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 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 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 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. - 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.

View file

@ -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 | | Company model | Company is first-order; all business entities are company-scoped |
| Board | Single human board operator per deployment | | Board | Single human board operator per deployment |
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | | 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) | | Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | 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`) | | 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 | | Report cost | yes | yes |
| Set company budget | yes | no | | Set company budget | yes | no |
| Set subordinate budget | yes | yes (manager subtree only) | | 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:<projectId>"]`.
- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:<agentId>"]`.
- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:<agentId>"]`.
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) ## 10. API Contract (REST)

View file

@ -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. **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. 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 ### Cross-Team Work

View file

@ -364,13 +364,16 @@ export interface PaperclipPluginManifestV1 {
| "contextMenuItem" | "contextMenuItem"
| "commentAnnotation" | "commentAnnotation"
| "commentContextMenuItem" | "commentContextMenuItem"
| "settingsPage"; | "settingsPage"
| "companySettingsPage";
id: string; id: string;
displayName: string; displayName: string;
/** Which export name in the UI bundle provides this component */ /** Which export name in the UI bundle provides this component */
exportName: string; exportName: string;
/** For detailTab: which entity types this tab appears on */ /** For detailTab: which entity types this tab appears on */
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">; 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. 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 ## 20. Local Tooling
Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations. 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. 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 ## 25. Uninstall And Data Lifecycle
When a plugin is uninstalled, the host must handle plugin-owned data explicitly. When a plugin is uninstalled, the host must handle plugin-owned data explicitly.

View file

@ -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 });
}
});
});

View file

@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true }); await fs.mkdir(path.dirname(target), { recursive: true });
} }
async function isExpectedSymlink(target: string, source: string): Promise<boolean> {
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<void> {
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<void> { async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null); const existing = await fs.lstat(target).catch(() => null);
if (!existing) { if (!existing) {
await ensureParentDir(target); await ensureParentDir(target);
await fs.symlink(source, target); await createExpectedSymlink(target, source);
return; return;
} }
@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise<void> {
return; return;
} }
const linkedPath = await fs.readlink(target).catch(() => null); if (await isExpectedSymlink(target, source)) return;
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
await fs.unlink(target); await fs.unlink(target);
await fs.symlink(source, target); await createExpectedSymlink(target, source);
} }
async function ensureCopiedFile(target: string, source: string): Promise<void> { async function ensureCopiedFile(target: string, source: string): Promise<void> {

View file

@ -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;

View file

@ -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;

View file

@ -610,6 +610,20 @@
"when": 1778976000000, "when": 1778976000000,
"tag": "0086_routine_env_runtime_contract", "tag": "0086_routine_env_runtime_contract",
"breakpoints": true "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
} }
] ]
} }

View file

@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink";
export const SLOT_IDS = { export const SLOT_IDS = {
page: "kitchen-sink-page", page: "kitchen-sink-page",
settingsPage: "kitchen-sink-settings-page", settingsPage: "kitchen-sink-settings-page",
companySettingsPage: "kitchen-sink-company-settings-page",
dashboardWidget: "kitchen-sink-dashboard-widget", dashboardWidget: "kitchen-sink-dashboard-widget",
sidebar: "kitchen-sink-sidebar-link", sidebar: "kitchen-sink-sidebar-link",
sidebarPanel: "kitchen-sink-sidebar-panel", sidebarPanel: "kitchen-sink-sidebar-panel",
@ -23,6 +24,7 @@ export const SLOT_IDS = {
export const EXPORT_NAMES = { export const EXPORT_NAMES = {
page: "KitchenSinkPage", page: "KitchenSinkPage",
settingsPage: "KitchenSinkSettingsPage", settingsPage: "KitchenSinkSettingsPage",
companySettingsPage: "KitchenSinkCompanySettingsPage",
dashboardWidget: "KitchenSinkDashboardWidget", dashboardWidget: "KitchenSinkDashboardWidget",
sidebar: "KitchenSinkSidebarLink", sidebar: "KitchenSinkSidebarLink",
sidebarPanel: "KitchenSinkSidebarPanel", sidebarPanel: "KitchenSinkSidebarPanel",

View file

@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = {
displayName: "Kitchen Sink Settings", displayName: "Kitchen Sink Settings",
exportName: EXPORT_NAMES.settingsPage, exportName: EXPORT_NAMES.settingsPage,
}, },
{
type: "companySettingsPage",
id: SLOT_IDS.companySettingsPage,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.companySettingsPage,
routePath: "kitchen-sink",
},
{ {
type: "dashboardWidget", type: "dashboardWidget",
id: SLOT_IDS.dashboardWidget, id: SLOT_IDS.dashboardWidget,

View file

@ -10,6 +10,7 @@ import {
usePluginToast, usePluginToast,
type PluginCommentAnnotationProps, type PluginCommentAnnotationProps,
type PluginCommentContextMenuItemProps, type PluginCommentContextMenuItemProps,
type PluginCompanySettingsPageProps,
type PluginDetailTabProps, type PluginDetailTabProps,
type PluginPageProps, type PluginPageProps,
type PluginProjectSidebarItemProps, 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 (
<div style={layoutStack}>
<Section title="Company Settings Slot">
<div style={subtleCardStyle}>
<div style={{ display: "grid", gap: "8px" }}>
<strong>Mounted inside company settings</strong>
<div style={mutedTextStyle}>
This fixture proves a ready plugin can add a settings sidebar item and render with company context.
</div>
<JsonBlock value={{
companyId: context.companyId,
companyPrefix: context.companyPrefix,
route: href,
pluginId: overview.data?.pluginId ?? PLUGIN_ID,
}} />
</div>
</div>
</Section>
</div>
);
}
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) { export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
const hostNavigation = useHostNavigation(); const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId); const overview = usePluginOverview(context.companyId);

View file

@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url);
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. | | `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. | | `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. **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. **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 ## Scheduled (recurring) jobs
@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `activity.read` | | | `activity.read` |
| | `costs.read` | | | `costs.read` |
| | `issues.orchestration.read` | | | `issues.orchestration.read` |
| | `access.members.read` |
| | `access.invites.read` |
| | `authorization.grants.read` |
| | `authorization.policies.read` |
| | `authorization.audit.read` |
| | `database.namespace.read` | | | `database.namespace.read` |
| | `issues.create` | | | `issues.create` |
| | `issues.update` | | | `issues.update` |
@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `local.folders` | | | `local.folders` |
| **Agent** | `agent.tools.register` | | **Agent** | `agent.tools.register` |
| | `agents.invoke` | | | `agents.invoke` |
| | `access.members.write` |
| | `access.invites.write` |
| | `authorization.grants.write` |
| | `authorization.policies.write` |
| | `agent.sessions.create` | | | `agent.sessions.create` |
| | `agent.sessions.list` | | | `agent.sessions.list` |
| | `agent.sessions.send` | | | `agent.sessions.send` |

View file

@ -49,7 +49,7 @@
*/ */
import type { PluginCapability } from "@paperclipai/shared"; 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"; import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error {
} }
} }
/**
* Thrown when a workerhost 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 // Host service interfaces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -257,6 +270,28 @@ export interface HostServices {
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>; create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>; update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
}; };
/** Provides `access.members.*` and `access.invites.*`. */
access: {
listMembers(params: WorkerToHostMethods["access.members.list"][0]): Promise<WorkerToHostMethods["access.members.list"][1]>;
getMember(params: WorkerToHostMethods["access.members.get"][0]): Promise<WorkerToHostMethods["access.members.get"][1]>;
updateMember(params: WorkerToHostMethods["access.members.update"][0]): Promise<WorkerToHostMethods["access.members.update"][1]>;
listInvites(params: WorkerToHostMethods["access.invites.list"][0]): Promise<WorkerToHostMethods["access.invites.list"][1]>;
createInvite(params: WorkerToHostMethods["access.invites.create"][0]): Promise<WorkerToHostMethods["access.invites.create"][1]>;
revokeInvite(params: WorkerToHostMethods["access.invites.revoke"][0]): Promise<WorkerToHostMethods["access.invites.revoke"][1]>;
};
/** Provides authorization grant, policy, preview, and audit helpers. */
authorization: {
listGrants(params: WorkerToHostMethods["authorization.grants.list"][0]): Promise<WorkerToHostMethods["authorization.grants.list"][1]>;
setGrants(params: WorkerToHostMethods["authorization.grants.set"][0]): Promise<WorkerToHostMethods["authorization.grants.set"][1]>;
policySummary(params: WorkerToHostMethods["authorization.policies.summary"][0]): Promise<WorkerToHostMethods["authorization.policies.summary"][1]>;
getPolicy(params: WorkerToHostMethods["authorization.policies.get"][0]): Promise<WorkerToHostMethods["authorization.policies.get"][1]>;
updatePolicy(params: WorkerToHostMethods["authorization.policies.update"][0]): Promise<WorkerToHostMethods["authorization.policies.update"][1]>;
previewAssignment(params: WorkerToHostMethods["authorization.policies.previewAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.previewAssignment"][1]>;
explainAssignment(params: WorkerToHostMethods["authorization.policies.explainAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.explainAssignment"][1]>;
searchAudit(params: WorkerToHostMethods["authorization.audit.search"][0]): Promise<WorkerToHostMethods["authorization.audit.search"][1]>;
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -292,6 +327,7 @@ export interface HostClientFactoryOptions {
*/ */
type HostHandler<M extends WorkerToHostMethodName> = ( type HostHandler<M extends WorkerToHostMethodName> = (
params: WorkerToHostMethods[M][0], params: WorkerToHostMethods[M][0],
context?: WorkerHostCallContext,
) => Promise<WorkerToHostMethods[M][1]>; ) => Promise<WorkerToHostMethods[M][1]>;
/** /**
@ -431,6 +467,24 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"goals.get": "goals.read", "goals.get": "goals.read",
"goals.create": "goals.create", "goals.create": "goals.create",
"goals.update": "goals.update", "goals.update": "goals.update",
// Access
"access.members.list": "access.members.read",
"access.members.get": "access.members.read",
"access.members.update": "access.members.write",
"access.invites.list": "access.invites.read",
"access.invites.create": "access.invites.write",
"access.invites.revoke": "access.invites.write",
// Authorization
"authorization.grants.list": "authorization.grants.read",
"authorization.grants.set": "authorization.grants.write",
"authorization.policies.summary": "authorization.policies.read",
"authorization.policies.get": "authorization.policies.read",
"authorization.policies.update": "authorization.policies.write",
"authorization.policies.previewAssignment": "authorization.policies.read",
"authorization.policies.explainAssignment": "authorization.policies.read",
"authorization.audit.search": "authorization.audit.read",
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -461,6 +515,81 @@ export function createHostClientHandlers(
const { pluginId, services } = options; const { pluginId, services } = options;
const capabilitySet = new Set<PluginCapability>(options.capabilities); const capabilitySet = new Set<PluginCapability>(options.capabilities);
type CompanyScopeRequest =
| { kind: "none" }
| { kind: "single"; companyId: string }
| { kind: "all" };
const noCompanyScope: CompanyScopeRequest = { kind: "none" };
function isRecord(value: unknown): value is Record<string, unknown> {
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. * Assert that the plugin has the required capability for a method.
* Throws `CapabilityDeniedError` if the capability is missing. * Throws `CapabilityDeniedError` if the capability is missing.
@ -485,9 +614,10 @@ export function createHostClientHandlers(
method: M, method: M,
handler: HostHandler<M>, handler: HostHandler<M>,
): HostHandler<M> { ): HostHandler<M> {
return async (params: WorkerToHostMethods[M][0]) => { return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => {
requireCapability(method); requireCapability(method);
return handler(params); requireInvocationCompanyScope(method, params, context);
return handler(params, context);
}; };
} }
@ -591,8 +721,13 @@ export function createHostClientHandlers(
}), }),
// Companies // Companies
"companies.list": gated("companies.list", async (params) => { "companies.list": gated("companies.list", async (params, context) => {
return services.companies.list(params); 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) => { "companies.get": gated("companies.get", async (params) => {
return services.companies.get(params); return services.companies.get(params);
@ -772,6 +907,52 @@ export function createHostClientHandlers(
"goals.update": gated("goals.update", async (params) => { "goals.update": gated("goals.update", async (params) => {
return services.goals.update(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);
}),
}; };
} }

View file

@ -58,6 +58,7 @@ export {
createHostClientHandlers, createHostClientHandlers,
getRequiredCapability, getRequiredCapability,
CapabilityDeniedError, CapabilityDeniedError,
InvocationScopeDeniedError,
} from "./host-client-factory.js"; } from "./host-client-factory.js";
// JSON-RPC protocol helpers and constants // JSON-RPC protocol helpers and constants
@ -137,6 +138,9 @@ export type {
JsonRpcMessage, JsonRpcMessage,
JsonRpcErrorCode, JsonRpcErrorCode,
PluginRpcErrorCode, PluginRpcErrorCode,
PluginInvocationScope,
PluginInvocationContext,
WorkerHostCallContext,
InitializeParams, InitializeParams,
InitializeResult, InitializeResult,
ConfigChangedParams, ConfigChangedParams,
@ -218,6 +222,17 @@ export type {
PluginIssueSubtree, PluginIssueSubtree,
PluginIssueSummariesClient, PluginIssueSummariesClient,
PluginAgentsClient, PluginAgentsClient,
PluginAccessClient,
PluginAccessMembersClient,
PluginAccessInvitesClient,
PluginAccessMember,
PluginAccessInvite,
PluginAuthorizationClient,
PluginAuthorizationPolicySummary,
PluginAuthorizationPolicyRecord,
PluginAssignmentPreviewInput,
PluginAuthorizationDecisionResult,
PluginAuthorizationAuditEntry,
PluginAgentSessionsClient, PluginAgentSessionsClient,
AgentSession, AgentSession,
AgentSessionEvent, AgentSessionEvent,
@ -253,7 +268,12 @@ export type {
IssueDocumentSummary, IssueDocumentSummary,
Agent, Agent,
Goal, Goal,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
PluginDatabaseClient, PluginDatabaseClient,
HumanCompanyMembershipRole,
MembershipStatus,
} from "./types.js"; } from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared // Manifest and constant types re-exported from @paperclipai/shared
@ -353,6 +373,7 @@ export {
PLUGIN_CAPABILITIES, PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_STATE_SCOPE_KINDS, PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_JOB_STATUSES, PLUGIN_JOB_STATUSES,
PLUGIN_JOB_RUN_STATUSES, PLUGIN_JOB_RUN_STATUSES,
@ -360,4 +381,9 @@ export {
PLUGIN_WEBHOOK_DELIVERY_STATUSES, PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES, PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES, PLUGIN_BRIDGE_ERROR_CODES,
PERMISSION_KEYS,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
MEMBERSHIP_STATUSES,
PRINCIPAL_TYPES,
} from "@paperclipai/shared"; } from "@paperclipai/shared";

View file

@ -39,6 +39,7 @@ import type {
Agent, Agent,
Goal, Goal,
PluginLocalFolderDeclaration, PluginLocalFolderDeclaration,
PrincipalPermissionGrant,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@ -57,6 +58,13 @@ import type {
ToolResult, ToolResult,
PluginLocalFolderListing, PluginLocalFolderListing,
PluginLocalFolderStatus, PluginLocalFolderStatus,
PluginAccessInvite,
PluginAccessMember,
PluginAssignmentPreviewInput,
PluginAuthorizationAuditEntry,
PluginAuthorizationDecisionResult,
PluginAuthorizationPolicyRecord,
PluginAuthorizationPolicySummary,
} from "./types.js"; } from "./types.js";
import type { import type {
PluginHealthDiagnostics, PluginHealthDiagnostics,
@ -96,6 +104,14 @@ export interface JsonRpcRequest<
readonly method: TMethod; readonly method: TMethod;
/** Structured parameters for the method call. */ /** Structured parameters for the method call. */
readonly params: TParams; 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
* workerhost 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; readonly method: TMethod;
/** Structured parameters for the notification. */ /** Structured parameters for the notification. */
readonly params: TParams; readonly params: TParams;
/**
* Host-issued metadata for hostworker push notifications such as events.
* Workerhost 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 = export type PluginRpcErrorCode =
(typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES]; (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 workerhost RPC calls.
*/
export interface PluginInvocationContext {
id: string;
scope: PluginInvocationScope;
}
/**
* Context provided to host-side workerhost 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) // Host → Worker Method Signatures (§13 Host-Worker Protocol)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -302,6 +355,8 @@ export interface RunJobParams {
export interface GetDataParams { export interface GetDataParams {
/** Plugin-defined data key (e.g. `"sync-health"`). */ /** Plugin-defined data key (e.g. `"sync-health"`). */
key: string; key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Context and query parameters from the UI. */ /** Context and query parameters from the UI. */
params: Record<string, unknown>; params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */ /** Optional launcher/container metadata from the host render environment. */
@ -316,6 +371,8 @@ export interface GetDataParams {
export interface PerformActionParams { export interface PerformActionParams {
/** Plugin-defined action key (e.g. `"resync"`). */ /** Plugin-defined action key (e.g. `"resync"`). */
key: string; key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Action parameters from the UI. */ /** Action parameters from the UI. */
params: Record<string, unknown>; params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */ /** Optional launcher/container metadata from the host render environment. */
@ -1128,6 +1185,105 @@ export interface WorkerToHostMethods {
}, },
result: Goal, 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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. */ /** Union of all worker→host method names. */

View file

@ -38,6 +38,10 @@ import type {
AgentSessionEvent, AgentSessionEvent,
PluginLocalFolderEntry, PluginLocalFolderEntry,
PluginLocalFolderStatus, PluginLocalFolderStatus,
PluginAccessMember,
PrincipalPermissionGrant,
PermissionKey,
PrincipalType,
} from "./types.js"; } from "./types.js";
import type { import type {
PluginEnvironmentValidateConfigParams, PluginEnvironmentValidateConfigParams,
@ -73,7 +77,7 @@ export interface TestHarnessLogEntry {
export interface TestHarness { export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext; 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: { seed(input: {
companies?: Company[]; companies?: Company[];
projects?: Project[]; projects?: Project[];
@ -83,6 +87,8 @@ export interface TestHarness {
goals?: Goal[]; goals?: Goal[];
projectWorkspaces?: PluginWorkspace[]; projectWorkspaces?: PluginWorkspace[];
executionWorkspaces?: PluginExecutionWorkspaceMetadata[]; executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
accessMembers?: PluginAccessMember[];
principalGrants?: PrincipalPermissionGrant[];
}): void; }): void;
setConfig(config: Record<string, unknown>): void; setConfig(config: Record<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */ /** Dispatch a host or plugin event to registered handlers. */
@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const issueDocuments = new Map<string, IssueDocument>(); const issueDocuments = new Map<string, IssueDocument>();
const agents = new Map<string, Agent>(); const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>(); const goals = new Map<string, Goal>();
const accessMembers = new Map<string, PluginAccessMember>();
const principalGrants = new Map<string, PrincipalPermissionGrant[]>();
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<string, unknown> | 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<string, PluginWorkspace[]>(); const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>(); const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>(); const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
@ -1983,6 +2022,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return updated; 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: { data: {
register(key, handler) { register(key, handler) {
dataHandlers.set(key, handler); dataHandlers.set(key, handler);
@ -2065,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
projectWorkspaces.set(row.projectId, list); projectWorkspaces.set(row.projectId, list);
} }
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row); 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) { setConfig(config) {
currentConfig = { ...config }; currentConfig = { ...config };

View file

@ -39,6 +39,12 @@ import type {
RoutineRun, RoutineRun,
Agent, Agent,
Goal, Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -120,6 +126,12 @@ export type {
IssueSurfaceVisibility, IssueSurfaceVisibility,
Agent, Agent,
Goal, Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1576,6 +1588,169 @@ export interface PluginGoalsClient {
): Promise<Goal>; ): Promise<Goal>;
} }
// ---------------------------------------------------------------------------
// 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<string, unknown> | 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<PluginAccessMember[]>;
get(memberId: string, companyId: string): Promise<PluginAccessMember | null>;
update(
memberId: string,
patch: {
membershipRole?: HumanCompanyMembershipRole | null;
status?: Extract<MembershipStatus, "pending" | "active" | "suspended">;
},
companyId: string,
): Promise<PluginAccessMember>;
}
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<string, unknown> | null;
agentMessage?: string | null;
}): Promise<PluginAccessInvite & { token: string }>;
revoke(inviteId: string, companyId: string): Promise<PluginAccessInvite>;
}
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<string, unknown> | 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<string, unknown> | null;
};
}
export interface PluginAuthorizationAuditEntry {
id: string;
companyId: string;
actorType: string;
actorId: string;
action: string;
entityType: string;
entityId: string;
details: Record<string, unknown> | null;
createdAt: Date | string;
}
export interface PluginAuthorizationClient {
grants: {
list(input: { companyId: string; principalType?: PrincipalType; principalId?: string }): Promise<PrincipalPermissionGrant[]>;
set(input: {
companyId: string;
principalType: PrincipalType;
principalId: string;
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>;
grantedByUserId?: string | null;
}): Promise<PrincipalPermissionGrant[]>;
};
policies: {
summary(companyId: string): Promise<PluginAuthorizationPolicySummary>;
get(input: { companyId: string; resourceType: PluginAuthorizationPolicyRecord["resourceType"]; resourceId: string }): Promise<PluginAuthorizationPolicyRecord | null>;
update(input: {
companyId: string;
resourceType: PluginAuthorizationPolicyRecord["resourceType"];
resourceId: string;
policy: Record<string, unknown> | null;
}): Promise<PluginAuthorizationPolicyRecord>;
previewAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
explainAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
};
audit: {
search(input: {
companyId: string;
action?: string;
actorType?: string;
actorId?: string;
entityType?: string;
entityId?: string;
decision?: string;
limit?: number;
offset?: number;
}): Promise<PluginAuthorizationAuditEntry[]>;
};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Streaming (worker → UI push channel) // 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. */ /** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */
goals: PluginGoalsClient; 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. */ /** Register getData handlers for the plugin's UI components. */
data: PluginDataClient; data: PluginDataClient;

View file

@ -146,6 +146,7 @@ export type {
// Slot component prop interfaces // Slot component prop interfaces
export type { export type {
PluginPageProps, PluginPageProps,
PluginCompanySettingsPageProps,
PluginWidgetProps, PluginWidgetProps,
PluginDetailTabProps, PluginDetailTabProps,
PluginSidebarProps, PluginSidebarProps,

View file

@ -229,6 +229,18 @@ export interface PluginPageProps {
context: PluginHostContext; 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. * Props passed to a plugin dashboard widget component.
* *

View file

@ -35,6 +35,7 @@
*/ */
import fs from "node:fs"; import fs from "node:fs";
import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path"; import path from "node:path";
import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -66,6 +67,7 @@ import type {
} from "./types.js"; } from "./types.js";
import type { import type {
JsonRpcId, JsonRpcId,
JsonRpcNotification,
JsonRpcRequest, JsonRpcRequest,
JsonRpcResponse, JsonRpcResponse,
InitializeParams, InitializeParams,
@ -85,6 +87,7 @@ import type {
PluginEnvironmentResumeLeaseParams, PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams, PluginEnvironmentValidateConfigParams,
PluginEnvironmentProbeParams, PluginEnvironmentProbeParams,
PluginInvocationContext,
WorkerToHostMethodName, WorkerToHostMethodName,
WorkerToHostMethods, WorkerToHostMethods,
} from "./protocol.js"; } from "./protocol.js";
@ -279,6 +282,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let manifest: PaperclipPluginManifestV1 | null = null; let manifest: PaperclipPluginManifestV1 | null = null;
let currentConfig: Record<string, unknown> = {}; let currentConfig: Record<string, unknown> = {};
let databaseNamespace: string | null = null; let databaseNamespace: string | null = null;
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
// Plugin handler registrations (populated during setup()) // Plugin handler registrations (populated during setup())
const eventHandlers: EventRegistration[] = []; const eventHandlers: EventRegistration[] = [];
@ -365,7 +369,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}); });
try { try {
const request = createRequest(method, params, id); const activeInvocation = invocationContextStorage.getStore();
const request = {
...createRequest(method, params, id),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
};
sendMessage(request); sendMessage(request);
} catch (err) { } catch (err) {
settle(reject, err instanceof Error ? err : new Error(String(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 { function notifyHost(method: string, params: unknown): void {
try { try {
sendMessage(createNotification(method, params)); const activeInvocation = invocationContextStorage.getStore();
sendMessage({
...createNotification(method, params),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
});
} catch { } catch {
// Swallow — the host may have closed stdin // 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: { data: {
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void { register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
dataHandlers.set(key, handler); dataHandlers.set(key, handler);
@ -1175,7 +1266,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const { id, method, params } = request; const { id, method, params } = request;
try { 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)); sendMessage(createSuccessResponse(id, result ?? null));
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
@ -1413,11 +1507,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) { if (!handler) {
throw new Error(`No data handler registered for key "${params.key}"`); throw new Error(`No data handler registered for key "${params.key}"`);
} }
return handler( return handler({
params.renderEnvironment === undefined ...params.params,
? params.params ...(params.companyId === undefined ? {} : { companyId: params.companyId }),
: { ...params.params, renderEnvironment: params.renderEnvironment }, ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
); });
} }
async function handlePerformAction(params: PerformActionParams): Promise<unknown> { async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
@ -1425,11 +1519,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) { if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`); throw new Error(`No action handler registered for key "${params.key}"`);
} }
return handler( return handler({
params.renderEnvironment === undefined ...params.params,
? params.params ...(params.companyId === undefined ? {} : { companyId: params.companyId }),
: { ...params.params, renderEnvironment: params.renderEnvironment }, ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
); });
} }
async function handleExecuteTool(params: ExecuteToolParams): Promise<ToolResult> { async function handleExecuteTool(params: ExecuteToolParams): Promise<ToolResult> {
@ -1597,14 +1691,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}); });
} else if (isJsonRpcNotification(message)) { } else if (isJsonRpcNotification(message)) {
// Dispatch host→worker push notifications // 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<void>) => {
if (notif.paperclipInvocation) {
return invocationContextStorage.run(notif.paperclipInvocation, fn);
}
return fn();
};
if (notif.method === "agents.sessions.event" && notif.params) { if (notif.method === "agents.sessions.event" && notif.params) {
const event = notif.params as AgentSessionEvent; const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId); const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event); if (cb) cb(event);
} else if (notif.method === "onEvent" && notif.params) { } else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers // 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", { notifyHost("log", {
level: "error", level: "error",
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`, message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,

View file

@ -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<string, (input: unknown) => Promise<unknown>>)[method](params),
).rejects.toMatchObject({
name: "CapabilityDeniedError",
message: expect.stringContaining(capability),
});
await expect(
(handlers as Record<string, (input: unknown) => Promise<unknown>>)[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();
});
});

View file

@ -1,11 +1,26 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { createInterface } from "node:readline";
import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest"; 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", () => { describe("isWorkerEntrypoint", () => {
const tempRoots: string[] = []; const tempRoots: string[] = [];
@ -55,3 +70,145 @@ describe("isWorkerEntrypoint", () => {
).toBe(false); ).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<string, (response: JsonRpcResponse) => 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<void>((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<unknown>((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();
}
});
});

View file

@ -739,6 +739,11 @@ export const PLUGIN_CAPABILITIES = [
"activity.read", "activity.read",
"costs.read", "costs.read",
"issues.orchestration.read", "issues.orchestration.read",
"access.members.read",
"access.invites.read",
"authorization.grants.read",
"authorization.policies.read",
"authorization.audit.read",
"database.namespace.read", "database.namespace.read",
// Data Write // Data Write
"issues.create", "issues.create",
@ -756,6 +761,10 @@ export const PLUGIN_CAPABILITIES = [
"agents.resume", "agents.resume",
"agents.invoke", "agents.invoke",
"agents.managed", "agents.managed",
"access.members.write",
"access.invites.write",
"authorization.grants.write",
"authorization.policies.write",
"agent.sessions.create", "agent.sessions.create",
"agent.sessions.list", "agent.sessions.list",
"agent.sessions.send", "agent.sessions.send",
@ -857,6 +866,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"commentAnnotation", "commentAnnotation",
"commentContextMenuItem", "commentContextMenuItem",
"settingsPage", "settingsPage",
"companySettingsPage",
] as const; ] as const;
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number]; export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
@ -887,6 +897,21 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
export type PluginReservedCompanyRouteSegment = export type PluginReservedCompanyRouteSegment =
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number]; (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 * Launcher placement zones describe where a plugin-owned launcher can appear
* in the host UI. These are intentionally aligned with current slot surfaces * in the host UI. These are intentionally aligned with current slot surfaces

View file

@ -111,6 +111,7 @@ export {
PLUGIN_CAPABILITIES, PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS, PLUGIN_LAUNCHER_BOUNDS,
@ -226,6 +227,7 @@ export {
type PluginCapability, type PluginCapability,
type PluginUiSlotType, type PluginUiSlotType,
type PluginUiSlotEntityType, type PluginUiSlotEntityType,
type PluginReservedCompanySettingsRouteSegment,
type PluginLauncherPlacementZone, type PluginLauncherPlacementZone,
type PluginLauncherAction, type PluginLauncherAction,
type PluginLauncherBounds, type PluginLauncherBounds,

View file

@ -58,7 +58,7 @@ export interface AgentInstructionsBundle {
export interface AgentAccessState { export interface AgentAccessState {
canAssignTasks: boolean; canAssignTasks: boolean;
taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none"; taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none";
membership: CompanyMembership | null; membership: CompanyMembership | null;
grants: PrincipalPermissionGrant[]; grants: PrincipalPermissionGrant[];
} }

View file

@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration {
*/ */
entityTypes?: PluginUiSlotEntityType[]; 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`. * Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
* For companySettingsPage, `permissions` becomes
* `/:companyPrefix/company/settings/permissions`.
*/ */
routePath?: string; routePath?: string;
/** /**

View file

@ -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", () => { describe("plugin managed routine validators", () => {
it("accepts core issue surface visibility values in routine templates", () => { it("accepts core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.parse({ const parsed = pluginManagedRoutineDeclarationSchema.parse({
@ -128,4 +159,30 @@ describe("plugin UI slot validators", () => {
expect(parsed.entityTypes).toEqual(["execution_workspace"]); 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);
});
}); });

View file

@ -6,6 +6,7 @@ import {
PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS, PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS, PLUGIN_LAUNCHER_BOUNDS,
@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["entityTypes"], 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({ ctx.addIssue({
code: z.ZodIssueCode.custom, 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"], path: ["routePath"],
}); });
} }
@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"], 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])) { if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"], 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<typeof pluginUiSlotDeclarationSchema>; export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;

View file

@ -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<typeof createDb>;
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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
});
});
});

View file

@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto"; 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 { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { import {
agents,
companies, companies,
companyMemberships, companyMemberships,
createDb, createDb,
@ -14,6 +15,8 @@ import {
startEmbeddedPostgresTestDatabase, startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js"; } from "./helpers/embedded-postgres.js";
import { accessService } from "../services/access.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 embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -56,6 +59,7 @@ describeEmbeddedPostgres("access service", () => {
await db.delete(issues); await db.delete(issues);
await db.delete(principalPermissionGrants); await db.delete(principalPermissionGrants);
await db.delete(instanceUserRoles); await db.delete(instanceUserRoles);
await db.delete(agents);
await db.delete(companyMemberships); await db.delete(companyMemberships);
await db.delete(companies); await db.delete(companies);
}); });
@ -221,4 +225,285 @@ describeEmbeddedPostgres("access service", () => {
access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }), access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }),
).rejects.toThrow("Instance admins cannot be removed from company access"); ).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");
});
}); });

View file

@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
ensureMembership: vi.fn(), ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(), setPrincipalPermission: vi.fn(),
@ -192,6 +193,11 @@ describe("agent routes adapter validation", () => {
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true); mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAccessService.hasPermission.mockResolvedValue(true); mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.ensureMembership.mockResolvedValue(undefined); mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);

View file

@ -60,6 +60,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
getMembership: vi.fn(), getMembership: vi.fn(),
ensureMembership: vi.fn(), ensureMembership: vi.fn(),
@ -293,6 +294,17 @@ function resetMockDefaults() {
revokedAt: new Date("2026-04-11T00:05:00.000Z"), revokedAt: new Date("2026-04-11T00:05:00.000Z"),
})); }));
mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser); 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.hasPermission.mockImplementation(async () => false);
mockAccessService.getMembership.mockImplementation(async () => null); mockAccessService.getMembership.mockImplementation(async () => null);
mockAccessService.listPrincipalGrants.mockImplementation(async () => []); mockAccessService.listPrincipalGrants.mockImplementation(async () => []);

View file

@ -21,6 +21,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
})); }));
@ -175,6 +176,11 @@ describe("agent instructions bundle routes", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type })); 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.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({ mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent(), ...makeAgent(),

View file

@ -51,7 +51,16 @@ function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({ vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService, agentService: () => mockAgentService,
agentInstructionsService: () => ({}), 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: () => ({}), approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}), budgetService: () => ({}),

View file

@ -51,6 +51,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
getMembership: vi.fn(), getMembership: vi.fn(),
ensureMembership: vi.fn(), ensureMembership: vi.fn(),
@ -302,6 +303,7 @@ describe.sequential("agent permission routes", () => {
mockAgentService.getChainOfCommand.mockReset(); mockAgentService.getChainOfCommand.mockReset();
mockAgentService.resolveByReference.mockReset(); mockAgentService.resolveByReference.mockReset();
mockAccessService.canUser.mockReset(); mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset(); mockAccessService.hasPermission.mockReset();
mockAccessService.getMembership.mockReset(); mockAccessService.getMembership.mockReset();
mockAccessService.ensureMembership.mockReset(); mockAccessService.ensureMembership.mockReset();
@ -342,6 +344,14 @@ describe.sequential("agent permission routes", () => {
mockAgentService.update.mockResolvedValue(baseAgent); mockAgentService.update.mockResolvedValue(baseAgent);
mockAgentService.updatePermissions.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
mockAccessService.canUser.mockResolvedValue(true); 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.hasPermission.mockResolvedValue(false);
mockAccessService.getMembership.mockResolvedValue({ mockAccessService.getMembership.mockResolvedValue({
id: "membership-1", id: "membership-1",
@ -1342,6 +1352,24 @@ describe.sequential("agent permission routes", () => {
expect(res.body.access.taskAssignSource).toBe("explicit_grant"); expect(res.body.access.taskAssignSource).toBe("explicit_grant");
}, 15_000); }, 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 () => { it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
mockAgentService.updatePermissions.mockResolvedValue({ mockAgentService.updatePermissions.mockResolvedValue({
...baseAgent, ...baseAgent,

View file

@ -11,6 +11,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
getMembership: vi.fn(), getMembership: vi.fn(),
listPrincipalGrants: vi.fn(), listPrincipalGrants: vi.fn(),
@ -315,6 +316,11 @@ describe.sequential("agent skill routes", () => {
); );
mockLogActivity.mockResolvedValue(undefined); mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true); mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAccessService.hasPermission.mockResolvedValue(true); mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.getMembership.mockResolvedValue(null); mockAccessService.getMembership.mockResolvedValue(null);
mockAccessService.listPrincipalGrants.mockResolvedValue([]); mockAccessService.listPrincipalGrants.mockResolvedValue([]);

View file

@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
getMembership: vi.fn(async () => null), getMembership: vi.fn(async () => null),
listPrincipalGrants: vi.fn(async () => []), listPrincipalGrants: vi.fn(async () => []),
@ -120,6 +121,11 @@ describe("agent test-environment route", () => {
beforeEach(async () => { beforeEach(async () => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks(); vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockEnvironmentService.getById.mockResolvedValue({ mockEnvironmentService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111", id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1", companyId: "company-1",

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveViteHmrPort } from "../app.ts"; import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts";
describe("resolveViteHmrPort", () => { describe("resolveViteHmrPort", () => {
it("uses serverPort + 10000 when the result stays in range", () => { it("uses serverPort + 10000 when the result stays in range", () => {
@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => {
expect(resolveViteHmrPort(9_000)).toBe(19_000); 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");
});
});

View file

@ -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<typeof createDb>, 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<typeof createDb>,
companyId: string,
input: { role?: string; reportsTo?: string | null; permissions?: Record<string, unknown> } = {},
) {
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<typeof createDb>, companyId: string, label: string) {
return db
.insert(projects)
.values({
companyId,
name: `Project ${label} ${randomUUID()}`,
})
.returning()
.then((rows) => rows[0]!);
}
async function grantAgentPermission(
db: ReturnType<typeof createDb>,
companyId: string,
agentId: string,
permissionKey: "tasks:assign" | "tasks:assign_scope",
scope: Record<string, unknown> | 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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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" },
});
});
});

View file

@ -5,13 +5,17 @@ import {
buildBetterAuthAdvancedOptions, buildBetterAuthAdvancedOptions,
deriveAuthCookiePrefix, deriveAuthCookiePrefix,
deriveAuthTrustedOrigins, deriveAuthTrustedOrigins,
shouldDisableSecureAuthCookies,
} from "../auth/better-auth.js"; } from "../auth/better-auth.js";
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID; const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL;
afterEach(() => { afterEach(() => {
if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_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", () => { describe("Better Auth cookie scoping", () => {
@ -28,8 +32,8 @@ describe("Better Auth cookie scoping", () => {
expect(advanced).toEqual({ expect(advanced).toEqual({
cookiePrefix: "paperclip-sat-worktree", cookiePrefix: "paperclip-sat-worktree",
}); });
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe( expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch(
"paperclip-sat-worktree.session_token", /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<typeof shouldDisableSecureAuthCookies>[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<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://paperclip.example.test",
} as Parameters<typeof shouldDisableSecureAuthCookies>[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<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
});
it("adds hostname port variants for authenticated mode on non-default ports", () => { it("adds hostname port variants for authenticated mode on non-default ports", () => {
const trustedOrigins = deriveAuthTrustedOrigins({ const trustedOrigins = deriveAuthTrustedOrigins({
deploymentMode: "authenticated", deploymentMode: "authenticated",

View file

@ -20,6 +20,7 @@ const agentSvc = {
const accessSvc = { const accessSvc = {
ensureMembership: vi.fn(), ensureMembership: vi.fn(),
ensureRoleDefaultGrants: vi.fn(),
listActiveUserMemberships: vi.fn(), listActiveUserMemberships: vi.fn(),
copyActiveUserMemberships: vi.fn(), copyActiveUserMemberships: vi.fn(),
setPrincipalPermission: vi.fn(), setPrincipalPermission: vi.fn(),

View file

@ -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}`,
},
});
});

View file

@ -68,6 +68,7 @@ describe("human invite roles", () => {
it("maps owner to the full management grant set", () => { it("maps owner to the full management grant set", () => {
expect(grantsForHumanRole("owner")).toEqual([ expect(grantsForHumanRole("owner")).toEqual([
{ permissionKey: "agents:create", scope: null }, { permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null }, { permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", 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", () => { it("defaults legacy or missing roles to operator", () => {
expect(normalizeHumanRole("member")).toBe("operator"); expect(normalizeHumanRole("member")).toBe("operator");
expect(resolveHumanInviteRole(null)).toBe("operator"); expect(resolveHumanInviteRole(null)).toBe("operator");

View file

@ -30,6 +30,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
})); }));
@ -275,6 +276,13 @@ describe("agent issue mutation checkout ownership", () => {
registerRouteMocks(); registerRouteMocks();
vi.clearAllMocks(); vi.clearAllMocks();
mockAccessService.canUser.mockReset(); 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(); mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset(); mockAgentService.getById.mockReset();
mockAgentService.list.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 () => { it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
mockAccessService.hasPermission.mockImplementation(async ( mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
_companyId: string, allowed: input.action === "tasks:manage_active_checkouts",
_principalType: string, action: input.action,
principalId: string, reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant",
permissionKey: string, explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.",
) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts"); }));
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" }); 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();
});
}); });

View file

@ -22,6 +22,12 @@ const mockIssueService = vi.hoisted(() => ({
vi.mock("../services/index.js", () => ({ vi.mock("../services/index.js", () => ({
accessService: () => ({ accessService: () => ({
canUser: vi.fn(async () => true), 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), hasPermission: vi.fn(async () => true),
}), }),
agentService: () => ({ agentService: () => ({

View file

@ -16,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
})); }));
@ -229,6 +230,7 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockAccessService.canUser.mockReset(); mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset(); mockAccessService.hasPermission.mockReset();
mockHeartbeatService.wakeup.mockReset(); mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.reportRunActivity.mockReset(); mockHeartbeatService.reportRunActivity.mockReset();
@ -307,6 +309,15 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockAccessService.canUser.mockResolvedValue(false); 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); mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null); mockAgentService.getById.mockResolvedValue(null);
mockAgentService.list.mockResolvedValue([ mockAgentService.list.mockResolvedValue([

View file

@ -26,6 +26,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false), canUser: vi.fn(async () => false),
decide: vi.fn(),
hasPermission: vi.fn(async () => false), hasPermission: vi.fn(async () => false),
})); }));
@ -160,6 +161,17 @@ describe("issue execution policy routes", () => {
parentBlockerAdded: false, parentBlockerAdded: false,
}); });
mockAccessService.canUser.mockResolvedValue(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); mockAccessService.hasPermission.mockResolvedValue(false);
}); });

View file

@ -43,6 +43,12 @@ function registerModuleMocks() {
}), }),
accessService: () => ({ accessService: () => ({
canUser: vi.fn(async () => true), 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), hasPermission: vi.fn(async () => true),
}), }),
agentService: () => ({ agentService: () => ({

View file

@ -33,6 +33,12 @@ vi.mock("../services/index.js", () => ({
}), }),
accessService: () => ({ accessService: () => ({
canUser: vi.fn(async () => true), 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), hasPermission: vi.fn(async () => true),
}), }),
agentService: () => ({ agentService: () => ({
@ -95,6 +101,12 @@ function registerModuleMocks() {
}), }),
accessService: () => ({ accessService: () => ({
canUser: vi.fn(async () => true), 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), hasPermission: vi.fn(async () => true),
}), }),
agentService: () => ({ agentService: () => ({

View file

@ -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<typeof createDb>;
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<string, unknown>; 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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<string, unknown>,
});
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");
});
});

View file

@ -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<typeof createDb>, 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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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();
});
});

View file

@ -613,6 +613,28 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(call).not.toHaveBeenCalled(); 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 () => { it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
readyPlugin(); readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true }); const call = vi.fn().mockResolvedValue({ ok: true });

View file

@ -82,4 +82,29 @@ describe("plugin SDK test harness", () => {
"missing required capability 'skills.managed'", "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'",
);
});
}); });

View file

@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { import {
createHostClientHandlers,
JsonRpcCallError, JsonRpcCallError,
PLUGIN_RPC_ERROR_CODES,
type HostServices,
type HostToWorkerMethods, type HostToWorkerMethods,
} from "@paperclipai/plugin-sdk"; } from "@paperclipai/plugin-sdk";
import { import {
@ -14,6 +17,10 @@ import {
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); 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 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 TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
const TEST_MANIFEST: PaperclipPluginManifestV1 = { const TEST_MANIFEST: PaperclipPluginManifestV1 = {
@ -178,4 +185,86 @@ describe("plugin-worker-manager stderr failure context", () => {
await handle.stop().catch(() => undefined); 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);
}
});
}); });

View file

@ -138,6 +138,10 @@ vi.mock("../realtime/live-events-ws.js", () => ({
})); }));
vi.mock("../services/index.js", () => ({ vi.mock("../services/index.js", () => ({
backfillPrincipalAccessCompatibility: vi.fn(async () => ({
agentMembershipsInserted: 0,
humanGrantsInserted: 0,
})),
feedbackService: feedbackServiceFactoryMock, feedbackService: feedbackServiceFactoryMock,
heartbeatService: vi.fn(() => ({ heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined), reapOrphanedRuns: vi.fn(async () => undefined),

View file

@ -96,6 +96,12 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000); 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 { export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path; const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
@ -373,6 +379,7 @@ export async function createApp(
const uiRoot = path.resolve(__dirname, "../../ui"); const uiRoot = path.resolve(__dirname, "../../ui");
const publicUiRoot = path.resolve(uiRoot, "public"); const publicUiRoot = path.resolve(uiRoot, "public");
const hmrPort = resolveViteHmrPort(opts.serverPort); const hmrPort = resolveViteHmrPort(opts.serverPort);
const hmrHost = resolveViteHmrHost(opts.bindHost);
const { createServer: createViteServer } = await import("vite"); const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({ const vite = await createViteServer({
root: uiRoot, root: uiRoot,
@ -380,7 +387,7 @@ export async function createApp(
server: { server: {
middlewareMode: true, middlewareMode: true,
hmr: { hmr: {
host: opts.bindHost, ...(hmrHost ? { host: hmrHost } : {}),
port: hmrPort, port: hmrPort,
clientPort: hmrPort, clientPort: hmrPort,
}, },

View file

@ -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 { function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers {
const headers = new Headers(); const headers = new Headers();
for (const [key, raw] of Object.entries(rawHeaders)) { 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.", "For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.",
); );
} }
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl; const disableSecureCookies = shouldDisableSecureAuthCookies(config);
const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
const authConfig = { const authConfig = {
baseURL: baseUrl, baseURL: baseUrl,
@ -120,7 +128,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
requireEmailVerification: false, requireEmailVerification: false,
disableSignUp: config.authDisableSignUp, disableSignUp: config.authDisableSignUp,
}, },
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }), advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }),
}; };
if (!baseUrl) { if (!baseUrl) {

View file

@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
import type { DeploymentMode } from "@paperclipai/shared"; import type { DeploymentMode } from "@paperclipai/shared";
import { ensureHumanRoleDefaultGrants } from "./services/principal-access-compatibility.js";
const LOCAL_BOARD_USER_ID = "local-board"; const LOCAL_BOARD_USER_ID = "local-board";
const CLAIM_TTL_MS = 1000 * 60 * 60 * 24; const CLAIM_TTL_MS = 1000 * 60 * 60 * 24;
@ -89,6 +90,7 @@ export async function claimBoardOwnership(
const status = getChallengeStatus(opts.token, opts.code); const status = getChallengeStatus(opts.token, opts.code);
if (status !== "available") return { status }; if (status !== "available") return { status };
const claimedCompanyIds: string[] = [];
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const existingTargetAdmin = await tx const existingTargetAdmin = await tx
.select({ id: instanceUserRoles.id }) .select({ id: instanceUserRoles.id })
@ -108,6 +110,7 @@ export async function claimBoardOwnership(
const allCompanies = await tx.select({ id: companies.id }).from(companies); const allCompanies = await tx.select({ id: companies.id }).from(companies);
for (const company of allCompanies) { for (const company of allCompanies) {
claimedCompanyIds.push(company.id);
const existing = await tx const existing = await tx
.select({ id: companyMemberships.id, status: companyMemberships.status }) .select({ id: companyMemberships.id, status: companyMemberships.status })
.from(companyMemberships) .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) { if (activeChallenge && activeChallenge.token === opts.token) {
activeChallenge.claimedAt = new Date(); activeChallenge.claimedAt = new Date();
activeChallenge.claimedByUserId = opts.userId; activeChallenge.claimedByUserId = opts.userId;

View file

@ -30,6 +30,7 @@ import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { import {
feedbackService, feedbackService,
backfillPrincipalAccessCompatibility,
heartbeatService, heartbeatService,
instanceSettingsService, instanceSettingsService,
reconcilePersistedRuntimeServicesOnStartup, reconcilePersistedRuntimeServicesOnStartup,
@ -512,6 +513,10 @@ export async function startServer(): Promise<StartedServer> {
if (config.deploymentMode === "local_trusted") { if (config.deploymentMode === "local_trusted") {
await ensureLocalTrustedBoardPrincipal(db as any); 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") { if (config.deploymentMode === "authenticated") {
const { const {
createBetterAuthHandler, createBetterAuthHandler,

View file

@ -501,6 +501,15 @@ export function agentRoutes(
}; };
} }
if (membership?.status === "active") {
return {
canAssignTasks: true,
taskAssignSource: "simple_default" as const,
membership,
grants,
};
}
return { return {
canAssignTasks: false, canAssignTasks: false,
taskAssignSource: "none" as const, taskAssignSource: "none" as const,
@ -543,34 +552,32 @@ export function agentRoutes(
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
if (req.actor.type === "board") { const decision = await access.decide({
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; actor: req.actor,
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); action: "agents:create",
if (!allowed) { resource: { type: "company", companyId },
throw forbidden("Missing permission: agents:create"); });
} if (!decision.allowed) {
return null; throw forbidden(decision.explanation);
} }
if (!req.actor.agentId) throw forbidden("Agent authentication required"); if (req.actor.type !== "agent") return null;
const actorAgent = await svc.getById(req.actor.agentId); const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
if (!actorAgent || actorAgent.companyId !== companyId) { if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company"); 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; return actorAgent;
} }
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
assertBoard(req); assertBoard(req);
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; const decision = await access.decide({
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); actor: req.actor,
if (!allowed) { action: "agents:create",
throw forbidden("Missing permission: agents:create"); resource: { type: "company", companyId },
} });
if (decision.allowed) return;
throw forbidden(decision.explanation);
} }
async function assertCanReadConfigurations(req: Request, companyId: string) { async function assertCanReadConfigurations(req: Request, companyId: string) {
@ -592,15 +599,12 @@ export function agentRoutes(
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
if (req.actor.type === "board") { const decision = await access.decide({
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; actor: req.actor,
return access.canUser(companyId, req.actor.userId, "agents:create"); action: "agent_config:read",
} resource: { type: "company", companyId },
if (!req.actor.agentId) return false; });
const actorAgent = await svc.getById(req.actor.agentId); return decision.allowed;
if (!actorAgent || actorAgent.companyId !== companyId) return false;
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
return allowedByGrant || canCreateAgents(actorAgent);
} }
async function buildSkippedWakeupResponse( async function buildSkippedWakeupResponse(
@ -672,27 +676,13 @@ export function agentRoutes(
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId); assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") { const decision = await access.decide({
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); actor: req.actor,
return; action: "agent_config:update",
} resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
if (!req.actor.agentId) throw forbidden("Agent authentication required"); });
if (decision.allowed) return;
const actorAgent = await svc.getById(req.actor.agentId); throw forbidden(decision.explanation);
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");
} }
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {

View file

@ -271,7 +271,14 @@ export function companyRoutes(db: Db, storage?: StorageService) {
throw forbidden("Instance admin required"); throw forbidden("Instance admin required");
} }
const company = await svc.create(req.body); 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, { await logActivity(db, {
companyId: company.id, companyId: company.id,
actorType: "user", actorType: "user",

View file

@ -1246,29 +1246,48 @@ export function issueRoutes(
return (req.actor.companyIds ?? []).includes(companyId); return (req.actor.companyIds ?? []).includes(companyId);
} }
function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | null | undefined; role: string }) { type TaskAssignmentAuthorizationScope = {
if (agent.role === "ceo") return true; issueId?: string | null;
if (!agent.permissions || typeof agent.permissions !== "object") return false; projectId?: string | null;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents); 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); assertCompanyAccess(req, companyId);
if (req.actor.type === "board") { const decision = await access.decide({
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; actor: req.actor,
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); action: "tasks:assign",
if (!allowed) throw forbidden("Missing permission: tasks:assign"); resource: {
return; type: "issue",
} companyId,
if (req.actor.type === "agent") { issueId: assignmentScope?.issueId ?? null,
if (!req.actor.agentId) throw forbidden("Agent authentication required"); projectId: assignmentScope?.projectId ?? null,
const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign"); parentIssueId: assignmentScope?.parentIssueId ?? null,
if (allowedByGrant) return; assigneeAgentId: assignmentScope?.assigneeAgentId ?? null,
const actorAgent = await agentsSvc.getById(req.actor.agentId); assigneeUserId: assignmentScope?.assigneeUserId ?? null,
if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return; },
throw forbidden("Missing permission: tasks:assign"); scope: assignmentScope ?? null,
} });
throw unauthorized(); if (decision.allowed) return;
throw forbidden(decision.explanation);
} }
function requireAgentRunId(req: Request, res: Response) { function requireAgentRunId(req: Request, res: Response) {
@ -1284,31 +1303,12 @@ export function issueRoutes(
companyId: string, companyId: string,
assigneeAgentId: string, assigneeAgentId: string,
) { ) {
const allowedByGrant = await access.hasPermission( const decision = await access.decide({
companyId, actor: { type: "agent", agentId: actorAgentId, companyId },
"agent", action: "tasks:manage_active_checkouts",
actorAgentId, resource: { type: "issue", companyId, assigneeAgentId },
"tasks:manage_active_checkouts", });
); return decision.allowed;
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;
} }
async function assertAgentIssueMutationAllowed( async function assertAgentIssueMutationAllowed(
@ -3146,7 +3146,16 @@ export function issueRoutes(
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) { 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); await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
@ -3242,7 +3251,12 @@ export function issueRoutes(
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) { 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); await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
@ -3631,7 +3645,23 @@ export function issueRoutes(
if (assigneeWillChange && !transition.workflowControlledAssignment) { if (assigneeWillChange && !transition.workflowControlledAssignment) {
if (!isAgentReturningIssueToCreator) { 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; 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); const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
if (closedExecutionWorkspace) { if (closedExecutionWorkspace) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);

View file

@ -1116,7 +1116,7 @@ export function pluginRoutes(
return; return;
} }
assertPluginBridgeScope(req, body.companyId); const companyId = assertPluginBridgeScope(req, body.companyId);
try { try {
const result = await bridgeDeps.workerManager.call( const result = await bridgeDeps.workerManager.call(
@ -1124,6 +1124,7 @@ export function pluginRoutes(
"getData", "getData",
{ {
key: body.key, key: body.key,
...(companyId ? { companyId } : {}),
params: body.params ?? {}, params: body.params ?? {},
renderEnvironment: body.renderEnvironment ?? null, renderEnvironment: body.renderEnvironment ?? null,
}, },
@ -1208,7 +1209,7 @@ export function pluginRoutes(
return; return;
} }
assertPluginBridgeScope(req, body.companyId); const companyId = assertPluginBridgeScope(req, body.companyId);
try { try {
const result = await bridgeDeps.workerManager.call( const result = await bridgeDeps.workerManager.call(
@ -1216,6 +1217,7 @@ export function pluginRoutes(
"performAction", "performAction",
{ {
key: body.key, key: body.key,
...(companyId ? { companyId } : {}),
params: body.params ?? {}, params: body.params ?? {},
renderEnvironment: body.renderEnvironment ?? null, renderEnvironment: body.renderEnvironment ?? null,
}, },
@ -1301,7 +1303,7 @@ export function pluginRoutes(
renderEnvironment?: PluginLauncherRenderContextSnapshot | null; renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
} | undefined; } | undefined;
assertPluginBridgeScope(req, body?.companyId); const companyId = assertPluginBridgeScope(req, body?.companyId);
try { try {
const result = await bridgeDeps.workerManager.call( const result = await bridgeDeps.workerManager.call(
@ -1309,6 +1311,7 @@ export function pluginRoutes(
"getData", "getData",
{ {
key, key,
...(companyId ? { companyId } : {}),
params: body?.params ?? {}, params: body?.params ?? {},
renderEnvironment: body?.renderEnvironment ?? null, renderEnvironment: body?.renderEnvironment ?? null,
}, },
@ -1390,7 +1393,7 @@ export function pluginRoutes(
renderEnvironment?: PluginLauncherRenderContextSnapshot | null; renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
} | undefined; } | undefined;
assertPluginBridgeScope(req, body?.companyId); const companyId = assertPluginBridgeScope(req, body?.companyId);
try { try {
const result = await bridgeDeps.workerManager.call( const result = await bridgeDeps.workerManager.call(
@ -1398,6 +1401,7 @@ export function pluginRoutes(
"performAction", "performAction",
{ {
key, key,
...(companyId ? { companyId } : {}),
params: body?.params ?? {}, params: body?.params ?? {},
renderEnvironment: body?.renderEnvironment ?? null, renderEnvironment: body?.renderEnvironment ?? null,
}, },

View file

@ -9,6 +9,8 @@ import {
} from "@paperclipai/db"; } from "@paperclipai/db";
import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
import { conflict } from "../errors.js"; 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 MembershipRow = typeof companyMemberships.$inferSelect;
type GrantInput = { type GrantInput = {
@ -24,6 +26,8 @@ type MemberArchiveInput = {
}; };
export function accessService(db: Db) { export function accessService(db: Db) {
const authorization = authorizationService(db);
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> { async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
if (!userId) return false; if (!userId) return false;
const row = await db const row = await db
@ -58,21 +62,13 @@ export function accessService(db: Db) {
principalId: string, principalId: string,
permissionKey: PermissionKey, permissionKey: PermissionKey,
): Promise<boolean> { ): Promise<boolean> {
const membership = await getMembership(companyId, principalType, principalId); return authorization.decidePrincipalGrant({
if (!membership || membership.status !== "active") return false; companyId,
const grant = await db principalType,
.select({ id: principalPermissionGrants.id }) principalId,
.from(principalPermissionGrants) permissionKey,
.where( action: permissionKey,
and( }).then((decision) => decision.allowed);
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
eq(principalPermissionGrants.permissionKey, permissionKey),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(grant);
} }
async function canUser( async function canUser(
@ -80,9 +76,20 @@ export function accessService(db: Db) {
userId: string | null | undefined, userId: string | null | undefined,
permissionKey: PermissionKey, permissionKey: PermissionKey,
): Promise<boolean> { ): Promise<boolean> {
if (!userId) return false; return authorization.decide({
if (await isInstanceAdmin(userId)) return true; actor: { type: "board", userId },
return hasPermission(companyId, "user", userId, permissionKey); action: permissionKey,
resource: { type: "company", companyId },
}).then((decision) => decision.allowed);
}
async function decide(input: {
actor: AuthorizationActor;
action: Parameters<typeof authorization.decide>[0]["action"];
resource: AuthorizationResource;
scope?: Record<string, unknown> | null;
}) {
return authorization.decide(input);
} }
async function listMembers(companyId: string) { async function listMembers(companyId: string) {
@ -616,10 +623,30 @@ export function accessService(db: Db) {
membership.membershipRole, membership.membershipRole,
"active", "active",
); );
await ensureHumanRoleDefaultGrants(db, {
companyId: targetCompanyId,
principalId: membership.principalId,
membershipRole: membership.membershipRole,
grantedByUserId: null,
});
} }
return sourceMemberships; 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( async function listPrincipalGrants(
companyId: string, companyId: string,
principalType: PrincipalType, principalType: PrincipalType,
@ -768,6 +795,7 @@ export function accessService(db: Db) {
return { return {
isInstanceAdmin, isInstanceAdmin,
decide,
canUser, canUser,
hasPermission, hasPermission,
getMembership, getMembership,
@ -776,6 +804,7 @@ export function accessService(db: Db) {
listMembers, listMembers,
listActiveUserMemberships, listActiveUserMemberships,
copyActiveUserMemberships, copyActiveUserMemberships,
ensureRoleDefaultGrants,
archiveMember, archiveMember,
setMemberPermissions, setMemberPermissions,
updateMemberAndPermissions, updateMemberAndPermissions,

View file

@ -18,7 +18,9 @@ export function normalizeAgentPermissions(
} }
const record = permissions as Record<string, unknown>; const record = permissions as Record<string, unknown>;
const preserved = { ...record };
return { return {
...preserved,
canCreateAgents: canCreateAgents:
typeof record.canCreateAgents === "boolean" typeof record.canCreateAgents === "boolean"
? record.canCreateAgents ? record.canCreateAgents

View file

@ -554,7 +554,7 @@ export function agentService(db: Db) {
const updated = await db const updated = await db
.update(agents) .update(agents)
.set({ .set({
permissions: normalizeAgentPermissions(permissions, existing.role), permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(agents.id, id)) .where(eq(agents.id, id))

View file

@ -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<string, unknown> | null;
};
};
type PrincipalGrantDecision = AuthorizationDecision & {
grant?: NonNullable<AuthorizationDecision["grant"]>;
};
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<string, unknown> | 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<string, unknown>, 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<string, unknown>, 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function objectIsEmpty(value: Record<string, unknown>) {
return Object.keys(value).length === 0;
}
function readPolicyObject(container: unknown, key: string): Record<string, unknown> | 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<string, unknown> | 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<string, AgentHierarchyRow>,
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<string, unknown> | null,
requestedScope: Record<string, unknown> | 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, "allowed">): AuthorizationDecision {
return { ...input, allowed: true };
}
function deny(input: Omit<AuthorizationDecision, "allowed">): AuthorizationDecision {
return { ...input, allowed: false };
}
export function authorizationService(db: Db) {
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
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<string, unknown> | null;
}): Promise<PrincipalGrantDecision> {
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<AssignmentPolicyEffect> {
if (resource.type !== "issue") return { kind: "none" };
const checks: Array<Promise<AssignmentPolicyEffect>> = [];
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<string, unknown> | null;
}): Promise<AuthorizationDecision> {
const permissionKey = permissionForAction(input.action);
const companyId = companyIdForResource(input.resource);
async function decideWithTaskAssignmentGrants(
principalType: PrincipalType,
principalId: string,
): Promise<AuthorizationDecision> {
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<AuthorizationDecision | null> {
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,
};
}

View file

@ -28,6 +28,7 @@ export function grantsForHumanRole(
case "owner": case "owner":
return [ return [
{ permissionKey: "agents:create", scope: null }, { permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null }, { permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null }, { permissionKey: "tasks:assign", scope: null },
@ -36,6 +37,7 @@ export function grantsForHumanRole(
case "admin": case "admin":
return [ return [
{ permissionKey: "agents:create", scope: null }, { permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null }, { permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null }, { permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null }, { permissionKey: "joins:approve", scope: null },

View file

@ -4118,7 +4118,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (mode === "agent_safe" && options?.sourceCompanyId) { if (mode === "agent_safe" && options?.sourceCompanyId) {
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id); await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
} else { } 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; targetCompany = created;
companyAction = "created"; companyAction = "created";

View file

@ -44,6 +44,19 @@ export { sidebarBadgeService } from "./sidebar-badges.js";
export { sidebarPreferenceService } from "./sidebar-preferences.js"; export { sidebarPreferenceService } from "./sidebar-preferences.js";
export { inboxDismissalService } from "./inbox-dismissals.js"; export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.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 { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js"; export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js"; export { companyPortabilityService } from "./company-portability.js";

View file

@ -149,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
commentAnnotation: "ui.commentAnnotation.register", commentAnnotation: "ui.commentAnnotation.register",
commentContextMenuItem: "ui.action.register", commentContextMenuItem: "ui.action.register",
settingsPage: "instance.settings.register", settingsPage: "instance.settings.register",
companySettingsPage: "instance.settings.register",
routeSidebar: "ui.sidebar.register", routeSidebar: "ui.sidebar.register",
}; };

View file

@ -1,14 +1,18 @@
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
activityLog,
agentTaskSessions as agentTaskSessionsTable, agentTaskSessions as agentTaskSessionsTable,
agents as agentsTable, agents as agentsTable,
budgetIncidents, budgetIncidents,
costEvents, costEvents,
heartbeatRuns, heartbeatRuns,
invites,
issues as issuesTable, issues as issuesTable,
pluginLogs, pluginLogs,
principalPermissionGrants,
projects as projectsTable,
} from "@paperclipai/db"; } 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 { import type {
HostServices, HostServices,
Company, Company,
@ -22,7 +26,7 @@ import type {
PluginIssueOrchestrationSummary, PluginIssueOrchestrationSummary,
PluginExecutionWorkspaceMetadata, PluginExecutionWorkspaceMetadata,
} from "@paperclipai/plugin-sdk"; } 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 { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import { companyService } from "./companies.js"; import { companyService } from "./companies.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
@ -36,11 +40,8 @@ import { heartbeatService } from "./heartbeat.js";
import { budgetService } from "./budgets.js"; import { budgetService } from "./budgets.js";
import { issueApprovalService } from "./issue-approvals.js"; import { issueApprovalService } from "./issue-approvals.js";
import { subscribeCompanyLiveEvents } from "./live-events.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 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 { pluginRegistryService } from "./plugin-registry.js";
import { pluginStateStore } from "./plugin-state-store.js"; import { pluginStateStore } from "./plugin-state-store.js";
import { pluginDatabaseService } from "./plugin-database.js"; import { pluginDatabaseService } from "./plugin-database.js";
@ -71,6 +72,9 @@ import { request as httpsRequest } from "node:https";
import { isIP } from "node:net"; import { isIP } from "node:net";
import { logger } from "../middleware/logger.js"; import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.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 // SSRF protection for plugin HTTP fetch
@ -526,11 +530,10 @@ export function buildHostServices(
const issues = issueService(db); const issues = issueService(db);
const documents = documentService(db); const documents = documentService(db);
const goals = goalService(db); const goals = goalService(db);
const activity = activityService(db); const access = accessService(db);
const costs = costService(db); const authorization = authorizationService(db);
const budgets = budgetService(db); const budgets = budgetService(db);
const issueApprovals = issueApprovalService(db); const issueApprovals = issueApprovalService(db);
const assets = assetService(db);
const scopedBus = eventBus.forPlugin(pluginKey); const scopedBus = eventBus.forPlugin(pluginKey);
// Track active session event subscriptions for cleanup // Track active session event subscriptions for cleanup
@ -562,6 +565,17 @@ export function buildHostServices(
return rows.slice(offset, offset + limit); 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<typeof condition> => Boolean(condition));
return sql`(${sql.join(conditions, sql` OR `)})`;
};
/** /**
* Plugins are instance-wide in the current runtime. Company IDs are still * Plugins are instance-wide in the current runtime. Company IDs are still
* required for company-scoped data access, but there is no per-company * 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<string, unknown> | 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<string, unknown> : {}),
role: humanRole,
};
}
if (agentMessage) {
defaults.agent = {
...(typeof defaults.agent === "object" && defaults.agent !== null ? defaults.agent as Record<string, unknown> : {}),
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<string, unknown> : {};
return {
resourceType,
resourceId,
companyId,
policy: permissions.authorizationPolicy && typeof permissions.authorizationPolicy === "object"
? sanitizeRecord(permissions.authorizationPolicy as Record<string, unknown>)
: 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<string, unknown>).authorizationPolicy
: null;
return {
resourceType,
resourceId,
companyId,
policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record<string, unknown>) : 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<string, unknown>).authorizationPolicy
: null;
return {
resourceType,
resourceId,
companyId,
policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record<string, unknown>) : 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 { return {
config: { config: {
async get() { 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<string, typeof grants>();
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<string, unknown>),
},
});
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<typeof condition> => 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<string, unknown>) }
: {};
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<string, unknown>) }
: {};
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<string, unknown>) }
: {};
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<typeof condition> => 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: { agentSessions: {
async create(params) { async create(params) {
const companyId = ensureCompanyId(params.companyId); const companyId = ensureCompanyId(params.companyId);

View file

@ -19,6 +19,7 @@
*/ */
import { fork, type ChildProcess } from "node:child_process"; import { fork, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
@ -39,9 +40,12 @@ import {
} from "@paperclipai/plugin-sdk"; } from "@paperclipai/plugin-sdk";
import type { import type {
JsonRpcId, JsonRpcId,
PluginInvocationContext,
PluginInvocationScope,
JsonRpcResponse, JsonRpcResponse,
JsonRpcRequest, JsonRpcRequest,
JsonRpcNotification, JsonRpcNotification,
WorkerHostCallContext,
HostToWorkerMethodName, HostToWorkerMethodName,
HostToWorkerMethods, HostToWorkerMethods,
WorkerToHostMethodName, WorkerToHostMethodName,
@ -108,6 +112,7 @@ export type WorkerStatus =
*/ */
export type WorkerToHostHandler<M extends WorkerToHostMethodName> = ( export type WorkerToHostHandler<M extends WorkerToHostMethodName> = (
params: WorkerToHostMethods[M][0], params: WorkerToHostMethods[M][0],
context?: WorkerHostCallContext,
) => Promise<WorkerToHostMethods[M][1]>; ) => Promise<WorkerToHostMethods[M][1]>;
/** /**
@ -201,6 +206,11 @@ interface PendingRequest {
sentAt: number; sentAt: number;
} }
interface ActiveInvocation {
scope: PluginInvocationScope;
timer?: ReturnType<typeof setTimeout>;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PluginWorkerHandle — manages a single worker process // PluginWorkerHandle — manages a single worker process
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -379,6 +389,7 @@ export function createPluginWorkerHandle(
// Pending RPC requests awaiting a response // Pending RPC requests awaiting a response
const pendingRequests = new Map<string | number, PendingRequest>(); const pendingRequests = new Map<string | number, PendingRequest>();
let nextRequestId = 1; let nextRequestId = 1;
const activeInvocations = new Map<string, ActiveInvocation>();
// Optional methods reported by the worker during initialization // Optional methods reported by the worker during initialization
let supportedMethods: string[] = []; let supportedMethods: string[] = [];
@ -475,13 +486,78 @@ export function createPluginWorkerHandle(
pending.resolve(response); 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<string, unknown> {
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 (workerhost call). * Handle a JSON-RPC request from the worker (workerhost call).
*/ */
async function handleWorkerRequest(request: JsonRpcRequest): Promise<void> { async function handleWorkerRequest(request: JsonRpcRequest): Promise<void> {
const method = request.method as WorkerToHostMethodName; const method = request.method as WorkerToHostMethodName;
const handler = options.hostHandlers[method] as const handler = options.hostHandlers[method] as
| ((params: unknown) => Promise<unknown>) | ((params: unknown, context?: WorkerHostCallContext) => Promise<unknown>)
| undefined; | undefined;
if (!handler) { if (!handler) {
@ -501,7 +577,7 @@ export function createPluginWorkerHandle(
} }
try { try {
const result = await handler(request.params); const result = await handler(request.params, contextForWorkerMessage(request));
sendMessage({ sendMessage({
jsonrpc: JSONRPC_VERSION, jsonrpc: JSONRPC_VERSION,
id: request.id, id: request.id,
@ -509,12 +585,15 @@ export function createPluginWorkerHandle(
}); });
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : String(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"); log.error({ method, err: errorMessage }, "host handler error");
try { try {
sendMessage( sendMessage(
createErrorResponse( createErrorResponse(
request.id, request.id,
JSONRPC_ERROR_CODES.INTERNAL_ERROR, errorCode,
errorMessage, errorMessage,
), ),
); );
@ -572,12 +651,28 @@ export function createPluginWorkerHandle(
notification.method === "streams.close" notification.method === "streams.close"
) { ) {
const params = (notification.params ?? {}) as Record<string, unknown>; const params = (notification.params ?? {}) as Record<string, unknown>;
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 // Track open channels so we can emit synthetic close on crash
if (notification.method === "streams.open") { if (notification.method === "streams.open") {
const ch = String(params.channel ?? ""); const ch = String(params.channel ?? "");
const co = String(params.companyId ?? ""); if (ch) openStreamChannels.set(ch, companyId);
if (ch) openStreamChannels.set(ch, co);
} else if (notification.method === "streams.close") { } else if (notification.method === "streams.close") {
openStreamChannels.delete(String(params.channel ?? "")); openStreamChannels.delete(String(params.channel ?? ""));
} }
@ -760,6 +855,10 @@ export function createPluginWorkerHandle(
); );
} }
pendingRequests.clear(); 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 id = nextRequestId++;
const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS); 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 // Guard against double-settlement. When a process exits all pending
// requests are rejected via rejectAllPending(), but the timeout timer // requests are rejected via rejectAllPending(), but the timeout timer
@ -1032,6 +1133,7 @@ export function createPluginWorkerHandle(
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
pendingRequests.delete(id); pendingRequests.delete(id);
clearInvocation(invocation);
fn(value); fn(value);
}; };
@ -1064,11 +1166,15 @@ export function createPluginWorkerHandle(
pendingRequests.set(id, pending); pendingRequests.set(id, pending);
try { try {
const request = createRequest(method, params, id); const request = {
...createRequest(method, params, id),
...(invocation ? { paperclipInvocation: invocation } : {}),
};
sendMessage(request); sendMessage(request);
} catch (err) { } catch (err) {
clearTimeout(timer); clearTimeout(timer);
pendingRequests.delete(id); pendingRequests.delete(id);
clearInvocation(invocation);
reject( reject(
new Error( new Error(
`Failed to send "${method}" to worker: ${ `Failed to send "${method}" to worker: ${
@ -1135,13 +1241,17 @@ export function createPluginWorkerHandle(
notify(method: string, params: unknown) { notify(method: string, params: unknown) {
if (status !== "running") return; if (status !== "running") return;
const invocationScope = deriveInvocationScope(method, params);
const invocation = invocationScope ? registerInvocation(invocationScope, MAX_RPC_TIMEOUT_MS) : null;
try { try {
sendMessage({ sendMessage({
jsonrpc: JSONRPC_VERSION, jsonrpc: JSONRPC_VERSION,
method, method,
params, params,
...(invocation ? { paperclipInvocation: invocation } : {}),
}); });
} catch { } catch {
clearInvocation(invocation);
log.warn({ method }, "failed to send notification to worker"); log.warn({ method }, "failed to send notification to worker");
} }
}, },

View file

@ -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<string, unknown> | 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<number> {
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<number> {
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<PrincipalAccessCompatibilityBackfillStats> {
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,
};
}

View file

@ -30,7 +30,8 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox"; import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings"; import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments"; 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 { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills"; import { CompanySkills } from "./pages/CompanySkills";
import { Secrets } from "./pages/Secrets"; import { Secrets } from "./pages/Secrets";
@ -69,11 +70,13 @@ function boardRoutes() {
<Route path="companies" element={<Companies />} /> <Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} /> <Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<CompanyEnvironments />} /> <Route path="company/settings/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/access" element={<CompanyAccess />} /> <Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
<Route path="company/settings/invites" element={<CompanyInvites />} /> <Route path="company/settings/invites" element={<CompanyInvites />} />
<Route path="company/export/*" element={<CompanyExport />} /> <Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} /> <Route path="company/import" element={<CompanyImport />} />
<Route path="company/settings/secrets" element={<Secrets />} /> <Route path="company/settings/secrets" element={<Secrets />} />
<Route path="company/settings/:settingsRoutePath/*" element={<CompanySettingsPluginPage />} />
<Route path="skills/*" element={<CompanySkills />} /> <Route path="skills/*" element={<CompanySkills />} />
<Route path="settings" element={<LegacySettingsRedirect />} /> <Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} /> <Route path="settings/*" element={<LegacySettingsRedirect />} />

View file

@ -10,6 +10,7 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({ const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(), get: vi.fn(),
})); }));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
Link: ({ Link: ({
@ -61,6 +62,10 @@ vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi, sidebarBadgesApi: mockSidebarBadgesApi,
})); }));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@ -83,6 +88,11 @@ describe("CompanySettingsSidebar", () => {
failedRuns: 0, failedRuns: 0,
joinRequests: 2, joinRequests: 2,
}); });
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
}); });
afterEach(() => { afterEach(() => {
@ -110,7 +120,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General"); expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments"); expect(container.textContent).toContain("Environments");
expect(container.textContent).toContain("Access"); expect(container.textContent).toContain("Members");
expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Invites");
expect(container.textContent).toContain("Secrets"); expect(container.textContent).toContain("Secrets");
expect(sidebarNavItemMock).toHaveBeenCalledWith( expect(sidebarNavItemMock).toHaveBeenCalledWith(
@ -129,8 +139,8 @@ describe("CompanySettingsSidebar", () => {
); );
expect(sidebarNavItemMock).toHaveBeenCalledWith( expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: "/company/settings/access", to: "/company/settings/members",
label: "Access", label: "Members",
badge: 2, badge: 2,
end: true, end: true,
}), }),
@ -154,4 +164,50 @@ describe("CompanySettingsSidebar", () => {
root.unmount(); 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(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
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();
});
});
}); });

View file

@ -1,16 +1,22 @@
import { useQuery } from "@tanstack/react-query"; 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 { sidebarBadgesApi } from "@/api/sidebarBadges";
import { ApiError } from "@/api/client"; import { ApiError } from "@/api/client";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext"; import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext"; import { useSidebar } from "@/context/SidebarContext";
import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarNavItem } from "./SidebarNavItem";
export function CompanySettingsSidebar() { export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany(); const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar(); const { isMobile, setSidebarOpen } = useSidebar();
const { slots: companySettingsPluginSlots } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const { data: badges } = useQuery({ const { data: badges } = useQuery({
queryKey: selectedCompanyId queryKey: selectedCompanyId
? queryKeys.sidebarBadges(selectedCompanyId) ? queryKeys.sidebarBadges(selectedCompanyId)
@ -61,12 +67,23 @@ export function CompanySettingsSidebar() {
end end
/> />
<SidebarNavItem <SidebarNavItem
to="/company/settings/access" to="/company/settings/members"
label="Access" label="Members"
icon={Shield} icon={Users}
badge={badges?.joinRequests ?? 0} badge={badges?.joinRequests ?? 0}
end end
/> />
{companySettingsPluginSlots
.filter((slot) => slot.routePath)
.map((slot) => (
<SidebarNavItem
key={`${slot.pluginKey}:${slot.id}`}
to={`/company/settings/${slot.routePath}`}
label={slot.displayName}
icon={Puzzle}
end
/>
))}
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end /> <SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end /> <SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
</div> </div>

View file

@ -3,10 +3,14 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
import { IssueBlockedNotice } from "./IssueBlockedNotice"; import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { ToastProvider } from "../context/ToastContext";
const retryNowMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => ( Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null; let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null; let container: HTMLDivElement | null = null;
let dateNowSpy: ReturnType<typeof vi.spyOn> | 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(() => { afterEach(() => {
if (root) { if (root) {
@ -27,13 +77,22 @@ afterEach(() => {
root = null; root = null;
container?.remove(); container?.remove();
container = null; container = null;
dateNowSpy?.mockRestore();
dateNowSpy = null;
}); });
function withProviders(node: ReactNode) { 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 ( return (
<MemoryRouter> <MemoryRouter>
<QueryClientProvider client={client}>{node}</QueryClientProvider> <QueryClientProvider client={client}>
<ToastProvider>{node}</ToastProvider>
</QueryClientProvider>
</MemoryRouter> </MemoryRouter>
); );
} }
@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => {
expect(node.textContent).toContain("This issue still needs a next step."); expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder"); expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan"); 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.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull(); 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(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
scheduledRetry={baseRetry}
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("Corrective wake scheduled in 1d");
const button = node.querySelector<HTMLButtonElement>('[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", () => { it("does not render when the issue is done even if a stale handoff state is required", () => {
const node = render( const node = render(
<IssueBlockedNotice <IssueBlockedNotice

View file

@ -2,12 +2,17 @@ import type {
IssueBlockerAttention, IssueBlockerAttention,
IssueRecoveryAction, IssueRecoveryAction,
IssueRelationIssueSummary, IssueRelationIssueSummary,
IssueScheduledRetry,
SuccessfulRunHandoffState, SuccessfulRunHandoffState,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { AlertTriangle, Flag } from "lucide-react"; import { AlertTriangle, CheckCircle2, Flag, Loader2, RotateCcw } from "lucide-react";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatMonitorOffset } from "../lib/issue-monitor";
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook"; import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { RetryErrorBand } from "./IssueScheduledRetryCard";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers"; import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
import { import {
deriveActiveRecoveryDisplayState, deriveActiveRecoveryDisplayState,
@ -34,22 +39,96 @@ function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
); );
} }
function SuccessfulRunRetryNowControl({
issueId,
scheduledRetry,
}: {
issueId: string;
scheduledRetry: IssueScheduledRetry;
}) {
const retryNow = useRetryNowMutation(issueId);
const dueAtIso = scheduledRetry.scheduledRetryAt
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
: null;
const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null;
const scheduleLabel = relative === "now"
? "due now"
: relative
? `scheduled ${relative}`
: "scheduled";
const success = retryNow.isSuccess
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
return (
<div className="mt-2 rounded-md border border-amber-300/70 bg-background/80 p-2 dark:border-amber-500/40 dark:bg-background/40">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-xs leading-5 text-amber-900 dark:text-amber-100">
Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately.
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 border-amber-300/80 bg-background/80 text-amber-950 shadow-none hover:bg-amber-100 dark:border-amber-500/50 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
onClick={() => retryNow.mutate()}
disabled={retryNow.isPending || success}
data-testid="issue-next-step-retry-now"
>
{retryNow.isPending ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
Retrying...
</span>
) : success ? (
<span className="inline-flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
Retry now
</span>
)}
</Button>
</div>
<RetryErrorBand
error={retryNow.lastError}
className="mt-2 border-amber-300/70 bg-amber-100/70 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-100"
onRetry={() => {
retryNow.reset();
retryNow.mutate();
}}
/>
</div>
);
}
export function IssueBlockedNotice({ export function IssueBlockedNotice({
issueId,
issueStatus, issueStatus,
blockers, blockers,
blockerAttention, blockerAttention,
successfulRunHandoff, successfulRunHandoff,
scheduledRetry,
agentName, agentName,
}: { }: {
issueId?: string | null;
issueStatus?: string; issueStatus?: string;
blockers: IssueRelationIssueSummary[]; blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null; blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null; successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
agentName?: string | null; agentName?: string | null;
}) { }) {
if (issueStatus === "done" || issueStatus === "cancelled") return null; if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true; const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null; 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 blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers const terminalBlockers = blockers
@ -162,6 +241,12 @@ export function IssueBlockedNotice({
Detected progress: {successfulRunHandoff.detectedProgressSummary} Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p> </p>
) : null} ) : null}
{successfulRunRetryNow ? (
<SuccessfulRunRetryNowControl
issueId={successfulRunRetryNow.issueId}
scheduledRetry={successfulRunRetryNow.scheduledRetry}
/>
) : null}
</> </>
) : null} ) : null}
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? ( {showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (

View file

@ -38,6 +38,7 @@ import type {
IssueBlockerAttention, IssueBlockerAttention,
IssueRecoveryAction, IssueRecoveryAction,
IssueRelationIssueSummary, IssueRelationIssueSummary,
IssueScheduledRetry,
SuccessfulRunHandoffState, SuccessfulRunHandoffState,
IssueWorkMode, IssueWorkMode,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@ -296,9 +297,11 @@ interface IssueChatThreadProps {
timelineEvents?: IssueTimelineEvent[]; timelineEvents?: IssueTimelineEvent[];
liveRuns?: LiveRunForIssue[]; liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null; activeRun?: ActiveRunForIssue | null;
issueId?: string | null;
blockedBy?: IssueRelationIssueSummary[]; blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null; blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null; successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
recoveryAction?: IssueRecoveryAction | null; recoveryAction?: IssueRecoveryAction | null;
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void; onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean; canFalsePositiveRecoveryAction?: boolean;
@ -3617,9 +3620,11 @@ export function IssueChatThread({
timelineEvents = [], timelineEvents = [],
liveRuns = [], liveRuns = [],
activeRun = null, activeRun = null,
issueId = null,
blockedBy = [], blockedBy = [],
blockerAttention = null, blockerAttention = null,
successfulRunHandoff = null, successfulRunHandoff = null,
scheduledRetry = null,
recoveryAction = null, recoveryAction = null,
onResolveRecoveryAction, onResolveRecoveryAction,
canFalsePositiveRecoveryAction = false, canFalsePositiveRecoveryAction = false,
@ -4299,10 +4304,12 @@ export function IssueChatThread({
/> />
) : null} ) : null}
<IssueBlockedNotice <IssueBlockedNotice
issueId={issueId}
issueStatus={issueStatus} issueStatus={issueStatus}
blockers={unresolvedBlockers} blockers={unresolvedBlockers}
blockerAttention={blockerAttention} blockerAttention={blockerAttention}
successfulRunHandoff={recoveryAction ? null : successfulRunHandoff} successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
scheduledRetry={scheduledRetry}
agentName={ agentName={
successfulRunHandoff?.assigneeAgentId successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null ? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null

View file

@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
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<typeof createRoot> | 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(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody>{children}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
});
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<HTMLButtonElement>(".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");
});
});

View file

@ -1,6 +1,6 @@
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query"; 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 Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@ -364,6 +364,7 @@ function CodeBlock({
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [wrapLines, setWrapLines] = useState(false);
const preRef = useRef<HTMLPreElement>(null); const preRef = useRef<HTMLPreElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
@ -401,33 +402,57 @@ function CodeBlock({
}, 1500); }, 1500);
}, [children]); }, [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 ( return (
<div className="paperclip-markdown-codeblock"> <div className="paperclip-markdown-codeblock" data-wrap-lines={wrapLines || undefined}>
<pre <pre
{...preProps} {...preProps}
ref={preRef} ref={preRef}
style={mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined)} style={{
...mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined),
...(wrapLines
? {
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
wordBreak: "break-word",
}
: null),
}}
> >
{children} {children}
</pre> </pre>
<button <div className="paperclip-markdown-codeblock-actions">
type="button" <button
onClick={handleCopy} type="button"
aria-label="Copy code" onClick={() => setWrapLines((value) => !value)}
title={label} aria-label={wrapLabel}
className="paperclip-markdown-codeblock-copy" title={wrapLabel}
data-copied={copied || undefined} className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
data-failed={failed || undefined} aria-pressed={wrapLines}
> data-active={wrapLines || undefined}
{copied && !failed ? ( >
<Check aria-hidden="true" className="h-3.5 w-3.5" /> <WrapText aria-hidden="true" className="h-3.5 w-3.5" />
) : ( <span className="paperclip-markdown-codeblock-action-label">{wrapLabel}</span>
<Copy aria-hidden="true" className="h-3.5 w-3.5" /> </button>
)} <button
<span className="paperclip-markdown-codeblock-copy-label">{label}</span> type="button"
</button> onClick={handleCopy}
aria-label="Copy code"
title={copyLabel}
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-copy"
data-copied={copied || undefined}
data-failed={failed || undefined}
>
{copied && !failed ? (
<Check aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
)}
<span className="paperclip-markdown-codeblock-action-label">{copyLabel}</span>
</button>
</div>
</div> </div>
); );
} }

View file

@ -60,27 +60,29 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general"); expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments"); expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments"); expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/company/settings/access")).toBe("access"); expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access"); 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"); expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
}); });
it("renders the active tab and navigates when a different tab is selected", async () => { 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); const root = createRoot(container);
await act(async () => { await act(async () => {
root.render(<CompanySettingsNav />); root.render(<CompanySettingsNav />);
}); });
expect(container.textContent).toContain("access"); expect(container.textContent).toContain("members");
expect(pageTabBarMock).toHaveBeenCalledWith( expect(pageTabBarMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
value: "access", value: "members",
items: [ items: [
{ value: "general", label: "General" }, { value: "general", label: "General" },
{ value: "environments", label: "Environments" }, { value: "environments", label: "Environments" },
{ value: "access", label: "Access" }, { value: "members", label: "Members" },
{ value: "invites", label: "Invites" }, { value: "invites", label: "Invites" },
], ],
}), }),

View file

@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router";
const items = [ const items = [
{ value: "general", label: "General", href: "/company/settings" }, { value: "general", label: "General", href: "/company/settings" },
{ value: "environments", label: "Environments", href: "/company/settings/environments" }, { 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" }, { value: "invites", label: "Invites", href: "/company/settings/invites" },
] as const; ] as const;
@ -16,8 +16,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
return "environments"; return "environments";
} }
if (pathname.includes("/company/settings/access")) { if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) {
return "access"; return "members";
} }
if (pathname.includes("/company/settings/invites")) { if (pathname.includes("/company/settings/invites")) {

View file

@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi } from "../../api/heartbeats"; import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters"; import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys"; import { queryKeys } from "../../lib/queryKeys";
import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
const LOG_POLL_INTERVAL_MS = 2000; const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000; const LOG_READ_LIMIT_BYTES = 256_000;
@ -279,8 +280,9 @@ export function useLiveRunTranscripts({
const connect = () => { const connect = () => {
if (closed) return; if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const url = buildSameOriginWebSocketUrl(
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; `/api/companies/${encodeURIComponent(companyId)}/events/ws`,
);
socket = new WebSocket(url); socket = new WebSocket(url);
socket.onmessage = (message) => { socket.onmessage = (message) => {

View file

@ -14,6 +14,7 @@ import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-iss
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes"; import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router"; import { useLocation } from "../lib/router";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
const TOAST_COOLDOWN_WINDOW_MS = 10_000; const TOAST_COOLDOWN_WINDOW_MS = 10_000;
const TOAST_COOLDOWN_MAX = 3; const TOAST_COOLDOWN_MAX = 3;
@ -979,8 +980,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const connect = () => { const connect = () => {
if (closed) return; if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const url = buildSameOriginWebSocketUrl(
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`; `/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`,
);
const nextSocket = new WebSocket(url); const nextSocket = new WebSocket(url);
socket = nextSocket; socket = nextSocket;

View file

@ -717,15 +717,23 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
background: none; background: none;
} }
/* Copy-to-clipboard button on fenced code blocks */ /* Actions for fenced and indented preformatted markdown blocks */
.paperclip-markdown-codeblock { .paperclip-markdown-codeblock {
position: relative; position: relative;
} }
.paperclip-markdown-codeblock-copy { .paperclip-markdown-codeblock-actions {
position: absolute; position: absolute;
top: 0.4rem; top: 0.4rem;
right: 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; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
@ -737,30 +745,31 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
font-size: 0.7rem; font-size: 0.7rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
opacity: 0; transition: background-color 0.12s ease, color 0.12s ease;
transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease;
} }
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy, .paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
.paperclip-markdown-codeblock-copy:focus-visible, .paperclip-markdown-codeblock-actions:focus-within,
.paperclip-markdown-codeblock-copy[data-copied] { .paperclip-markdown-codeblock-action[data-copied],
.paperclip-markdown-codeblock-action[data-active] {
opacity: 1; opacity: 1;
} }
.paperclip-markdown-codeblock-copy:hover { .paperclip-markdown-codeblock-action:hover {
background-color: var(--accent); background-color: var(--accent);
color: var(--accent-foreground); 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); color: var(--primary);
} }
.paperclip-markdown-codeblock-copy[data-failed] { .paperclip-markdown-codeblock-action[data-failed] {
color: var(--destructive); color: var(--destructive);
} }
.paperclip-markdown-codeblock-copy-label { .paperclip-markdown-codeblock-action-label {
font-weight: 500; font-weight: 500;
} }

View file

@ -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");
});
});

View file

@ -0,0 +1,20 @@
type BrowserLocationLike = Pick<Location, "host" | "hostname" | "port" | "protocol">;
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}`;
}

View file

@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout"; import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge"; import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold"; import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState"; import { describeRunRetryState } from "../lib/runRetryState";
@ -1713,7 +1714,9 @@ function ConfigurationTab({
? "Enabled automatically while this agent can create new agents." ? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant" : taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -3863,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const connect = () => { const connect = () => {
if (closed) return; if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const url = buildSameOriginWebSocketUrl(
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`; `/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
);
socket = new WebSocket(url); socket = new WebSocket(url);
socket.onopen = () => { socket.onopen = () => {

View file

@ -4,23 +4,25 @@ import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 listMembersMock = vi.hoisted(() => vi.fn());
const listJoinRequestsMock = 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 archiveMemberMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn()); const listAgentsMock = vi.hoisted(() => vi.fn());
const listIssuesMock = 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", () => ({ vi.mock("@/api/access", () => ({
accessApi: { accessApi: {
listMembers: (companyId: string) => listMembersMock(companyId), listMembers: (companyId: string) => listMembersMock(companyId),
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status), 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(), updateMemberPermissions: vi.fn(),
updateMemberAccess: (companyId: string, memberId: string, input: unknown) => updateMemberAccess: vi.fn(),
updateMemberAccessMock(companyId, memberId, input),
archiveMember: (companyId: string, memberId: string, input: unknown) => archiveMember: (companyId: string, memberId: string, input: unknown) =>
archiveMemberMock(companyId, memberId, input), archiveMemberMock(companyId, memberId, input),
approveJoinRequest: vi.fn(), approveJoinRequest: vi.fn(),
@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({
}, },
})); }));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
mockNavigate(to, replace);
return <div data-testid="navigate">{to}</div>;
},
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
vi.mock("@/context/CompanyContext", () => ({ vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ useCompany: () => ({
selectedCompanyId: "company-1", selectedCompanyId: "company-1",
@ -146,7 +160,7 @@ describe("CompanyAccess", () => {
}, },
}, },
]); ]);
updateMemberAccessMock.mockResolvedValue({}); updateMemberMock.mockResolvedValue({});
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 }); archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
listAgentsMock.mockResolvedValue([ listAgentsMock.mockResolvedValue([
{ {
@ -164,6 +178,11 @@ describe("CompanyAccess", () => {
status: "todo", status: "todo",
}, },
]); ]);
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
}); });
afterEach(() => { afterEach(() => {
@ -172,7 +191,7 @@ describe("CompanyAccess", () => {
vi.clearAllMocks(); 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 root = createRoot(container);
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@ -188,10 +207,15 @@ describe("CompanyAccess", () => {
await flushReact(); await flushReact();
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("Humans");
expect(container.textContent).toContain("Pending human joins"); expect(container.textContent).toContain("Pending human joins");
expect(container.textContent).toContain("User account"); 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("Agents");
expect(container.textContent).not.toContain("Pending agent joins"); expect(container.textContent).not.toContain("Pending agent joins");
expect(container.textContent).not.toContain("Open join request queue"); expect(container.textContent).not.toContain("Open join request queue");
@ -210,18 +234,16 @@ describe("CompanyAccess", () => {
}); });
await flushReact(); await flushReact();
expect(document.body.textContent).toContain("Implicit grants from role"); expect(document.body.textContent).toContain("Update company role and membership status");
expect(document.body.textContent).toContain("Owner currently includes these permissions automatically."); expect(document.body.textContent).not.toContain("Implicit grants from role");
expect(document.body.textContent).toContain( expect(document.body.textContent).not.toContain("permissionKey");
"Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.",
);
await act(async () => { await act(async () => {
root.unmount(); 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 root = createRoot(container);
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@ -248,7 +270,7 @@ describe("CompanyAccess", () => {
await flushReact(); await flushReact();
const saveButton = Array.from(document.body.querySelectorAll("button")).find( const saveButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "Save access", (button) => button.textContent === "Save member",
); );
expect(saveButton).toBeTruthy(); expect(saveButton).toBeTruthy();
@ -257,10 +279,9 @@ describe("CompanyAccess", () => {
}); });
await flushReact(); await flushReact();
expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", { expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
membershipRole: "owner", membershipRole: "owner",
status: "active", status: "active",
grants: [],
}); });
await act(async () => { await act(async () => {
@ -382,4 +403,65 @@ describe("CompanyAccess", () => {
root.unmount(); 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(
<QueryClientProvider client={queryClient}>
<CompanyAccessLegacyRoute />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<CompanyAccessLegacyRoute />
</QueryClientProvider>,
);
});
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();
});
});
}); });

View file

@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
PERMISSION_KEYS,
type Agent, type Agent,
type PermissionKey,
} from "@paperclipai/shared"; } 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 { accessApi, type CompanyMember } from "@/api/access";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client"; import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues"; import { issuesApi } from "@/api/issues";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -25,38 +22,13 @@ import { Badge } from "@/components/ui/badge";
import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext"; import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext"; import { useToast } from "@/context/ToastContext";
import { Link, Navigate } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { usePluginSlots } from "@/plugins/slots";
const permissionLabels: Record<PermissionKey, string> = {
"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<NonNullable<CompanyMember["membershipRole"]>, 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: [],
};
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out"; const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
type EditableMemberStatus = "pending" | "active" | "suspended"; type EditableMemberStatus = "pending" | "active" | "suspended";
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
return role ? implicitRoleGrantMap[role] : [];
}
export function CompanyAccess() { export function CompanyAccess() {
const { selectedCompany, selectedCompanyId } = useCompany(); const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@ -67,13 +39,12 @@ export function CompanyAccess() {
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned"); const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null); const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active"); const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" }, { label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" }, { label: "Settings", href: "/company/settings" },
{ label: "Access" }, { label: "Members" },
]); ]);
}, [selectedCompany?.name, setBreadcrumbs]); }, [selectedCompany?.name, setBreadcrumbs]);
@ -103,11 +74,10 @@ export function CompanyAccess() {
}; };
const updateMemberMutation = useMutation({ const updateMemberMutation = useMutation({
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => { mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => {
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, { return accessApi.updateMember(selectedCompanyId!, input.memberId, {
membershipRole: input.membershipRole, membershipRole: input.membershipRole,
status: input.status, status: input.status,
grants: input.grants.map((permissionKey) => ({ permissionKey })),
}); });
}, },
onSuccess: async () => { onSuccess: async () => {
@ -223,7 +193,6 @@ export function CompanyAccess() {
if (!editingMember) return; if (!editingMember) return;
setDraftRole(editingMember.membershipRole); setDraftRole(editingMember.membershipRole);
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended"); setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
}, [editingMember]); }, [editingMember]);
useEffect(() => { useEffect(() => {
@ -255,8 +224,6 @@ export function CompanyAccess() {
joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? []; joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? [];
const joinRequestActionPending = const joinRequestActionPending =
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending; approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
const implicitGrantSet = new Set(implicitGrantKeys);
const activeReassignmentUsers = members.filter( const activeReassignmentUsers = members.filter(
(member) => (member) =>
member.status === "active" && member.status === "active" &&
@ -271,11 +238,14 @@ export function CompanyAccess() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" /> <ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Access</h1> <h1 className="text-lg font-semibold">Company Members</h1>
</div> </div>
<p className="max-w-3xl text-sm text-muted-foreground"> <p className="max-w-3xl text-sm text-muted-foreground">
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.
</p> </p>
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Core keeps this page focused on membership, invite approvals, and safe member removal.
</div>
</div> </div>
{access && !access.currentUserRole && ( {access && !access.currentUserRole && (
@ -291,7 +261,7 @@ export function CompanyAccess() {
<h2 className="text-base font-semibold">Humans</h2> <h2 className="text-base font-semibold">Humans</h2>
</div> </div>
<p className="max-w-3xl text-sm text-muted-foreground"> <p className="max-w-3xl text-sm text-muted-foreground">
Manage human company memberships, status, and grants here. Manage human company memberships and status here.
</p> </p>
</div> </div>
@ -340,11 +310,10 @@ export function CompanyAccess() {
) : null} ) : null}
<div className="overflow-hidden rounded-xl border border-border"> <div className="overflow-hidden rounded-xl border border-border">
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div>User account</div> <div>User account</div>
<div>Role</div> <div>Role</div>
<div>Status</div> <div>Status</div>
<div>Grants</div>
<div className="text-right">Action</div> <div className="text-right">Action</div>
</div> </div>
{members.length === 0 ? ( {members.length === 0 ? (
@ -356,7 +325,7 @@ export function CompanyAccess() {
return ( return (
<div <div
key={member.id} key={member.id}
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0" className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div> <div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
@ -372,7 +341,6 @@ export function CompanyAccess() {
{member.status.replace("_", " ")} {member.status.replace("_", " ")}
</Badge> </Badge>
</div> </div>
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
<div className="space-y-1 text-right"> <div className="space-y-1 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}> <Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
@ -405,7 +373,7 @@ export function CompanyAccess() {
<DialogHeader> <DialogHeader>
<DialogTitle>Edit member</DialogTitle> <DialogTitle>Edit member</DialogTitle>
<DialogDescription> <DialogDescription>
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}.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{editingMember && ( {editingMember && (
@ -443,66 +411,6 @@ export function CompanyAccess() {
</select> </select>
</label> </label>
</div> </div>
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium">Grants</h3>
<p className="text-sm text-muted-foreground">
Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes.
</p>
</div>
<div className="rounded-lg border border-border px-3 py-3">
<div className="text-sm font-medium">Implicit grants from role</div>
<p className="mt-1 text-sm text-muted-foreground">
{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."}
</p>
{implicitGrantKeys.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{implicitGrantKeys.map((permissionKey) => (
<Badge key={permissionKey} variant="outline">
{permissionLabels[permissionKey]}
</Badge>
))}
</div>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2">
{PERMISSION_KEYS.map((permissionKey) => (
<label
key={permissionKey}
className="flex items-start gap-3 rounded-lg border border-border px-3 py-2"
>
<Checkbox
checked={draftGrants.has(permissionKey)}
onCheckedChange={(checked) => {
setDraftGrants((current) => {
const next = new Set(current);
if (checked) next.add(permissionKey);
else next.delete(permissionKey);
return next;
});
}}
/>
<span className="space-y-1">
<span className="block text-sm font-medium">{permissionLabels[permissionKey]}</span>
<span className="block text-xs text-muted-foreground">{permissionKey}</span>
{implicitGrantSet.has(permissionKey) ? (
<span className="block text-xs text-muted-foreground">
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.
</span>
) : null}
{draftGrants.has(permissionKey) ? (
<span className="block text-xs text-muted-foreground">
Stored explicitly for this member.
</span>
) : null}
</span>
</label>
))}
</div>
</div>
</div> </div>
)} )}
<DialogFooter> <DialogFooter>
@ -516,12 +424,11 @@ export function CompanyAccess() {
memberId: editingMember.id, memberId: editingMember.id,
membershipRole: draftRole, membershipRole: draftRole,
status: draftStatus, status: draftStatus,
grants: [...draftGrants],
}); });
}} }}
disabled={updateMemberMutation.isPending} disabled={updateMemberMutation.isPending}
> >
{updateMemberMutation.isPending ? "Saving…" : "Save access"} {updateMemberMutation.isPending ? "Saving…" : "Save member"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -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 <Navigate to="/company/settings/permissions" replace />;
}
if (isLoading) {
return <div className="text-sm text-muted-foreground">Checking for advanced permission extensions...</div>;
}
return (
<div className="max-w-2xl space-y-5">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Advanced Permissions</h1>
</div>
<p className="text-sm text-muted-foreground">
Advanced access, scoped assignment, and explicit grant controls are provided by installed company settings extensions.
</p>
</div>
<div className="space-y-4 rounded-xl border border-border px-5 py-5">
<div className="space-y-2">
<h2 className="text-sm font-semibold">Advanced permissions unavailable</h2>
<p className="text-sm text-muted-foreground">
Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires an installed extension.
</p>
{errorMessage ? (
<p className="text-sm text-destructive">Plugin extensions unavailable: {errorMessage}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button asChild>
<Link to="/company/settings/members">Open Members</Link>
</Button>
<Button asChild variant="outline">
<Link to="/company/settings/invites">Open Invites</Link>
</Button>
</div>
</div>
</div>
);
}
function memberDisplayName(member: CompanyMember | null) { function memberDisplayName(member: CompanyMember | null) {
if (!member) return "this member"; if (!member) return "this member";
return member.user?.name?.trim() || member.user?.email || member.principalId; return member.user?.name?.trim() || member.user?.email || member.principalId;

View file

@ -152,7 +152,8 @@ describe("CompanyInvites", () => {
expect(container.textContent).toContain("Choose a role"); expect(container.textContent).toContain("Choose a role");
expect(container.textContent).toContain("Each invite link is single-use."); 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("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 }); expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
const viewMoreButton = Array.from(container.querySelectorAll("button")).find( const viewMoreButton = Array.from(container.querySelectorAll("button")).find(

View file

@ -14,8 +14,8 @@ const inviteRoleOptions = [
{ {
value: "viewer", value: "viewer",
label: "Viewer", label: "Viewer",
description: "Can view company work and follow along without operational permissions.", description: "Can view company work and follow along.",
gets: "No built-in grants.", gets: "View-only company membership.",
}, },
{ {
value: "operator", value: "operator",
@ -32,8 +32,8 @@ const inviteRoleOptions = [
{ {
value: "owner", value: "owner",
label: "Owner", label: "Owner",
description: "Full company access, including membership and permission management.", description: "Full company access, including membership management.",
gets: "Everything in Admin, plus managing members and permission grants.", gets: "Everything in Admin, plus managing members.",
}, },
] as const; ] as const;

View file

@ -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 }) => <a href={to}>{children}</a>,
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 };
}) => (
<div data-testid="plugin-slot-mount">
{slot.displayName}:{context.companyId}:{context.companyPrefix}
</div>
),
}));
// 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(
<QueryClientProvider client={queryClient}>
<CompanySettingsPluginPage />
</QueryClientProvider>,
);
});
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();
});
});
});

View file

@ -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 <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
}
return <div className="text-sm text-muted-foreground">Select a company to view this page.</div>;
}
if (!settingsRoutePath || isLoading) {
return <div className="text-sm text-muted-foreground">Loading...</div>;
}
if (errorMessage) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Plugin extensions unavailable: {errorMessage}
</div>
);
}
if (pageSlots.length > 1) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Multiple plugins declare the company settings route <code>{settingsRoutePath}</code>. Disable one plugin or change its route.
</div>
);
}
if (!pageSlot) {
return <NotFoundPage scope="board" />;
}
return (
<PluginSlotMount
slot={pageSlot}
context={{ companyId: resolvedCompanyId, companyPrefix }}
className="min-h-[200px]"
missingBehavior="placeholder"
/>
);
}

View file

@ -403,16 +403,16 @@ describe("InviteLandingPage", () => {
expect(container.textContent).toContain("Request to join Acme Robotics"); 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("A company admin must approve your request to join.");
expect(container.textContent).toContain( 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.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( const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
(link) => link.textContent === "Company Settings → Access", (link) => link.textContent === "Company Settings → Members",
); );
expect(approvalLinks).toHaveLength(2); 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) { for (const link of approvalLinks) {
expect(link.getAttribute("href")).toBe(expectedApprovalUrl); expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
} }
@ -471,7 +471,7 @@ describe("InviteLandingPage", () => {
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull(); expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
expect(container.textContent).toContain("Your request is still awaiting approval."); expect(container.textContent).toContain("Your request is still awaiting approval.");
expect(container.textContent).toContain( 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 () => { await act(async () => {

View file

@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({
claimApiKeyPath = null, claimApiKeyPath = null,
onboardingTextUrl = null, onboardingTextUrl = null,
}: AwaitingJoinApprovalPanelProps) { }: AwaitingJoinApprovalPanelProps) {
const approvalUrl = `${window.location.origin}/company/settings/access`; const approvalUrl = `${window.location.origin}/company/settings/members`;
const approverLabel = invitedByUserName ?? "A company admin"; const approverLabel = invitedByUserName ?? "A company admin";
return ( return (
@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({
href={approvalUrl} href={approvalUrl}
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100" className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
> >
Company Settings Access Company Settings Members
</a> </a>
</div> </div>
<p className="text-sm text-zinc-400"> <p className="text-sm text-zinc-400">
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings Access</a> to approve your request. Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings Members</a> to approve your request.
</p> </p>
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
Refresh this page after you've been approved — you'll be redirected automatically. Refresh this page after you've been approved — you'll be redirected automatically.

View file

@ -23,8 +23,8 @@ const inviteRoleOptions = [
{ {
value: "viewer", value: "viewer",
label: "Viewer", label: "Viewer",
description: "Can view company work and follow along without operational permissions.", description: "Can view company work and follow along.",
gets: "No built-in grants.", gets: "View-only company membership.",
}, },
{ {
value: "operator", value: "operator",
@ -41,8 +41,8 @@ const inviteRoleOptions = [
{ {
value: "owner", value: "owner",
label: "Owner", label: "Owner",
description: "Full company access, including membership and permission management.", description: "Full company access, including membership management.",
gets: "Everything in Admin, plus managing members and permission grants.", gets: "Everything in Admin, plus managing members.",
}, },
] as const; ] as const;
@ -423,8 +423,8 @@ function InviteResultPreview({
<> <>
<div className="border border-zinc-800 p-3"> <div className="border border-zinc-800 p-3">
<p className="mb-1 text-xs text-zinc-500">Approval page</p> <p className="mb-1 text-xs text-zinc-500">Approval page</p>
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/access"> <a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/members">
Company Settings Access Company Settings Members
</a> </a>
</div> </div>
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
@ -897,7 +897,7 @@ export function InviteUxLab() {
/> />
<InviteResultPreview <InviteResultPreview
title="Request to join Acme Robotics" title="Request to join Acme Robotics"
description="Ask them to visit Company Settings → Access to approve your request." description="Ask them to visit Company Settings → Members to approve your request."
/> />
</div> </div>
</LabSection> </LabSection>

Some files were not shown because too many files have changed in this diff Show more