[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
102 changed files with 6744 additions and 395 deletions
+1
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 build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
+54 -1
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 |
| Board | Single human board operator per deployment |
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
| Visibility | Full visibility to board and all agents in same company |
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
@@ -487,6 +487,59 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
| Report cost | yes | yes |
| Set company budget | yes | no |
| Set subordinate budget | yes | yes (manager subtree only) |
| Set work-object visibility (issue/project) | no | no (pro gate) |
## 9.4 Permission Terminology and Default Visibility Rule
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
The approved term set is:
- **Agent profile visibility**: identity-level facts needed for delegation and governance (name, role, capabilities, reporting lines).
- **Agent config visibility**: adapter/runtime config metadata and secret-access policy.
- **Assignment/invocation permission**: who may modify or execute a task.
- **Work-object visibility**: who can read/write issues, comments, projects, and attachments.
- **Tool/secret policy**: what tools and secret-backed credentials an agent can use and what appears in logs.
- **Escalation authority**: where refusal/blocked decisions route (manager, then board).
## 9.5 Core V1 Rule: what “private” means
- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
| Permission area | Free / V1 default | Pro / Enterprise |
|---|---|---|
| Company boundary | Hard boundary only (`company_id`) | Multi-company policy overlays (`membership`, `project`, and `task` scopes) |
| Simple roles | Board + agent roles with existing approval/budget gates | Additional role aliases + scoped approver roles |
| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows |
## 9.7 Recommended first-slice implementation order
1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates.
2. Treat profile privacy as external-facing signal only; do not use it to hide company-visible work objects.
3. Enforce assignment/invocation coupling (`assignee`/`agent` checks, checkout semantics, invocation checks).
4. Standardize read-path redaction for secrets and secret references, including logs and activity.
5. Standardize escalation paths (`blocked` and refusal) so non-board agents hand off by manager/board with immutable audit.
## 9.8 Scoped Task Assignment Grants
`tasks:assign` remains the broad assignment permission. Existing unscoped grants preserve compatibility and allow the principal to assign any visible company task within normal company-boundary checks.
`tasks:assign_scope` is the constrained assignment permission. Its `principal_permission_grants.scope` JSON must include at least one recognized constraint:
- Project scope: `projectId`, `projectIds`, or `allow: ["project:<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)
+2
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.
Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
### Cross-Team Work
+14 -1
View File
@@ -364,13 +364,16 @@ export interface PaperclipPluginManifestV1 {
| "contextMenuItem"
| "commentAnnotation"
| "commentContextMenuItem"
| "settingsPage";
| "settingsPage"
| "companySettingsPage";
id: string;
displayName: string;
/** Which export name in the UI bundle provides this component */
exportName: string;
/** For detailTab: which entity types this tab appears on */
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
/** For page and companySettingsPage: single route segment */
routePath?: string;
}>;
};
}
@@ -1206,6 +1209,8 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th
Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards.
For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations.
## 20. Local Tooling
Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations.
@@ -1455,6 +1460,14 @@ Each plugin may expose a company-context main page:
This page is where board users do most day-to-day work.
## 24.4 Company Settings Plugin Page
Each ready plugin may expose a company settings page:
- `/:companyPrefix/company/settings/:routePath`
The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages.
## 25. Uninstall And Data Lifecycle
When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
@@ -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 });
}
});
});
@@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise<void> {
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> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
return;
}
@@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise<void> {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
if (await isExpectedSymlink(target, source)) return;
await fs.unlink(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
}
async function ensureCopiedFile(target: string, source: string): Promise<void> {
@@ -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;
@@ -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;
@@ -610,6 +610,20 @@
"when": 1778976000000,
"tag": "0086_routine_env_runtime_contract",
"breakpoints": true
},
{
"idx": 87,
"version": "7",
"when": 1779360000000,
"tag": "0087_backfill_environment_manage_human_defaults",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1779446400000,
"tag": "0088_backfill_principal_access_compatibility",
"breakpoints": true
}
]
}
@@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink";
export const SLOT_IDS = {
page: "kitchen-sink-page",
settingsPage: "kitchen-sink-settings-page",
companySettingsPage: "kitchen-sink-company-settings-page",
dashboardWidget: "kitchen-sink-dashboard-widget",
sidebar: "kitchen-sink-sidebar-link",
sidebarPanel: "kitchen-sink-sidebar-panel",
@@ -23,6 +24,7 @@ export const SLOT_IDS = {
export const EXPORT_NAMES = {
page: "KitchenSinkPage",
settingsPage: "KitchenSinkSettingsPage",
companySettingsPage: "KitchenSinkCompanySettingsPage",
dashboardWidget: "KitchenSinkDashboardWidget",
sidebar: "KitchenSinkSidebarLink",
sidebarPanel: "KitchenSinkSidebarPanel",
@@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = {
displayName: "Kitchen Sink Settings",
exportName: EXPORT_NAMES.settingsPage,
},
{
type: "companySettingsPage",
id: SLOT_IDS.companySettingsPage,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.companySettingsPage,
routePath: "kitchen-sink",
},
{
type: "dashboardWidget",
id: SLOT_IDS.dashboardWidget,
@@ -10,6 +10,7 @@ import {
usePluginToast,
type PluginCommentAnnotationProps,
type PluginCommentContextMenuItemProps,
type PluginCompanySettingsPageProps,
type PluginDetailTabProps,
type PluginPageProps,
type PluginProjectSidebarItemProps,
@@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
);
}
export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
const href = hostNavigation.resolveHref("/company/settings/kitchen-sink");
return (
<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) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
+11 -2
View File
@@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url);
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
@@ -134,7 +134,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope.
## Scheduled (recurring) jobs
@@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `activity.read` |
| | `costs.read` |
| | `issues.orchestration.read` |
| | `access.members.read` |
| | `access.invites.read` |
| | `authorization.grants.read` |
| | `authorization.policies.read` |
| | `authorization.audit.read` |
| | `database.namespace.read` |
| | `issues.create` |
| | `issues.update` |
@@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `local.folders` |
| **Agent** | `agent.tools.register` |
| | `agents.invoke` |
| | `access.members.write` |
| | `access.invites.write` |
| | `authorization.grants.write` |
| | `authorization.policies.write` |
| | `agent.sessions.create` |
| | `agent.sessions.list` |
| | `agent.sessions.send` |
+186 -5
View File
@@ -49,7 +49,7 @@
*/
import type { PluginCapability } from "@paperclipai/shared";
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
// ---------------------------------------------------------------------------
@@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error {
}
}
/**
* Thrown when a worker→host call asks for company-scoped data outside the
* company authorized for the current top-level plugin invocation.
*/
export class InvocationScopeDeniedError extends Error {
override readonly name = "InvocationScopeDeniedError";
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
constructor(pluginId: string, method: string, message: string) {
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
}
}
// ---------------------------------------------------------------------------
// Host service interfaces
// ---------------------------------------------------------------------------
@@ -257,6 +270,28 @@ export interface HostServices {
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][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> = (
params: WorkerToHostMethods[M][0],
context?: WorkerHostCallContext,
) => Promise<WorkerToHostMethods[M][1]>;
/**
@@ -431,6 +467,24 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"goals.get": "goals.read",
"goals.create": "goals.create",
"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 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.
* Throws `CapabilityDeniedError` if the capability is missing.
@@ -485,9 +614,10 @@ export function createHostClientHandlers(
method: M,
handler: HostHandler<M>,
): HostHandler<M> {
return async (params: WorkerToHostMethods[M][0]) => {
return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => {
requireCapability(method);
return handler(params);
requireInvocationCompanyScope(method, params, context);
return handler(params, context);
};
}
@@ -591,8 +721,13 @@ export function createHostClientHandlers(
}),
// Companies
"companies.list": gated("companies.list", async (params) => {
return services.companies.list(params);
"companies.list": gated("companies.list", async (params, context) => {
const rows = await services.companies.list(params);
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
if (!allowedCompanyId) return rows;
return rows.filter((company) =>
isRecord(company) && company.id === allowedCompanyId,
) as WorkerToHostMethods["companies.list"][1];
}),
"companies.get": gated("companies.get", async (params) => {
return services.companies.get(params);
@@ -772,6 +907,52 @@ export function createHostClientHandlers(
"goals.update": gated("goals.update", async (params) => {
return services.goals.update(params);
}),
// Access
"access.members.list": gated("access.members.list", async (params) => {
return services.access.listMembers(params);
}),
"access.members.get": gated("access.members.get", async (params) => {
return services.access.getMember(params);
}),
"access.members.update": gated("access.members.update", async (params) => {
return services.access.updateMember(params);
}),
"access.invites.list": gated("access.invites.list", async (params) => {
return services.access.listInvites(params);
}),
"access.invites.create": gated("access.invites.create", async (params) => {
return services.access.createInvite(params);
}),
"access.invites.revoke": gated("access.invites.revoke", async (params) => {
return services.access.revokeInvite(params);
}),
// Authorization
"authorization.grants.list": gated("authorization.grants.list", async (params) => {
return services.authorization.listGrants(params);
}),
"authorization.grants.set": gated("authorization.grants.set", async (params) => {
return services.authorization.setGrants(params);
}),
"authorization.policies.summary": gated("authorization.policies.summary", async (params) => {
return services.authorization.policySummary(params);
}),
"authorization.policies.get": gated("authorization.policies.get", async (params) => {
return services.authorization.getPolicy(params);
}),
"authorization.policies.update": gated("authorization.policies.update", async (params) => {
return services.authorization.updatePolicy(params);
}),
"authorization.policies.previewAssignment": gated("authorization.policies.previewAssignment", async (params) => {
return services.authorization.previewAssignment(params);
}),
"authorization.policies.explainAssignment": gated("authorization.policies.explainAssignment", async (params) => {
return services.authorization.explainAssignment(params);
}),
"authorization.audit.search": gated("authorization.audit.search", async (params) => {
return services.authorization.searchAudit(params);
}),
};
}
+26
View File
@@ -58,6 +58,7 @@ export {
createHostClientHandlers,
getRequiredCapability,
CapabilityDeniedError,
InvocationScopeDeniedError,
} from "./host-client-factory.js";
// JSON-RPC protocol helpers and constants
@@ -137,6 +138,9 @@ export type {
JsonRpcMessage,
JsonRpcErrorCode,
PluginRpcErrorCode,
PluginInvocationScope,
PluginInvocationContext,
WorkerHostCallContext,
InitializeParams,
InitializeResult,
ConfigChangedParams,
@@ -218,6 +222,17 @@ export type {
PluginIssueSubtree,
PluginIssueSummariesClient,
PluginAgentsClient,
PluginAccessClient,
PluginAccessMembersClient,
PluginAccessInvitesClient,
PluginAccessMember,
PluginAccessInvite,
PluginAuthorizationClient,
PluginAuthorizationPolicySummary,
PluginAuthorizationPolicyRecord,
PluginAssignmentPreviewInput,
PluginAuthorizationDecisionResult,
PluginAuthorizationAuditEntry,
PluginAgentSessionsClient,
AgentSession,
AgentSessionEvent,
@@ -253,7 +268,12 @@ export type {
IssueDocumentSummary,
Agent,
Goal,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
PluginDatabaseClient,
HumanCompanyMembershipRole,
MembershipStatus,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
@@ -353,6 +373,7 @@ export {
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_JOB_STATUSES,
PLUGIN_JOB_RUN_STATUSES,
@@ -360,4 +381,9 @@ export {
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
PERMISSION_KEYS,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
MEMBERSHIP_STATUSES,
PRINCIPAL_TYPES,
} from "@paperclipai/shared";
+156
View File
@@ -39,6 +39,7 @@ import type {
Agent,
Goal,
PluginLocalFolderDeclaration,
PrincipalPermissionGrant,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@@ -57,6 +58,13 @@ import type {
ToolResult,
PluginLocalFolderListing,
PluginLocalFolderStatus,
PluginAccessInvite,
PluginAccessMember,
PluginAssignmentPreviewInput,
PluginAuthorizationAuditEntry,
PluginAuthorizationDecisionResult,
PluginAuthorizationPolicyRecord,
PluginAuthorizationPolicySummary,
} from "./types.js";
import type {
PluginHealthDiagnostics,
@@ -96,6 +104,14 @@ export interface JsonRpcRequest<
readonly method: TMethod;
/** Structured parameters for the method call. */
readonly params: TParams;
/**
* Host-issued metadata for the top-level plugin invocation that is currently
* executing. The worker treats this as opaque and echoes only the id on
* worker→host calls made from the same async execution context.
*/
readonly paperclipInvocation?: PluginInvocationContext;
/** Opaque top-level invocation id echoed by worker→host requests. */
readonly paperclipInvocationId?: string;
}
/**
@@ -156,6 +172,13 @@ export interface JsonRpcNotification<
readonly method: TMethod;
/** Structured parameters for the notification. */
readonly params: TParams;
/**
* Host-issued metadata for host→worker push notifications such as events.
* Worker→host notifications echo only `paperclipInvocationId`.
*/
readonly paperclipInvocation?: PluginInvocationContext;
/** Opaque top-level invocation id echoed by worker→host notifications. */
readonly paperclipInvocationId?: string;
}
/**
@@ -217,6 +240,36 @@ export const PLUGIN_RPC_ERROR_CODES = {
export type PluginRpcErrorCode =
(typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES];
// ---------------------------------------------------------------------------
// Invocation scope metadata
// ---------------------------------------------------------------------------
/**
* Company scope attached by the host to one top-level plugin invocation.
* Absence of this metadata means the invocation is instance/global scoped.
*/
export interface PluginInvocationScope {
companyId: string;
}
/**
* Opaque invocation metadata generated by the host. Workers must not derive or
* mutate this. They only echo the id on nested worker→host RPC calls.
*/
export interface PluginInvocationContext {
id: string;
scope: PluginInvocationScope;
}
/**
* Context provided to host-side worker→host handlers after the worker echoes a
* host-issued invocation id.
*/
export interface WorkerHostCallContext {
invocationScope?: PluginInvocationScope | null;
invalidInvocationScope?: boolean;
}
// ---------------------------------------------------------------------------
// Host → Worker Method Signatures (§13 Host-Worker Protocol)
// ---------------------------------------------------------------------------
@@ -302,6 +355,8 @@ export interface RunJobParams {
export interface GetDataParams {
/** Plugin-defined data key (e.g. `"sync-health"`). */
key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Context and query parameters from the UI. */
params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */
@@ -316,6 +371,8 @@ export interface GetDataParams {
export interface PerformActionParams {
/** Plugin-defined action key (e.g. `"resync"`). */
key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Action parameters from the UI. */
params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */
@@ -1128,6 +1185,105 @@ export interface WorkerToHostMethods {
},
result: Goal,
];
// Access
"access.members.list": [
params: { companyId: string; includeArchived?: boolean },
result: PluginAccessMember[],
];
"access.members.get": [
params: { memberId: string; companyId: string },
result: PluginAccessMember | null,
];
"access.members.update": [
params: {
memberId: string;
companyId: string;
patch: {
membershipRole?: string | null;
status?: "pending" | "active" | "suspended";
};
},
result: PluginAccessMember,
];
"access.invites.list": [
params: {
companyId: string;
state?: "active" | "revoked" | "accepted" | "expired";
limit?: number;
offset?: number;
},
result: { invites: PluginAccessInvite[]; nextOffset: number | null },
];
"access.invites.create": [
params: {
companyId: string;
allowedJoinTypes?: "human" | "agent" | "both";
humanRole?: string | null;
defaultsPayload?: Record<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. */
+196 -1
View File
@@ -38,6 +38,10 @@ import type {
AgentSessionEvent,
PluginLocalFolderEntry,
PluginLocalFolderStatus,
PluginAccessMember,
PrincipalPermissionGrant,
PermissionKey,
PrincipalType,
} from "./types.js";
import type {
PluginEnvironmentValidateConfigParams,
@@ -73,7 +77,7 @@ export interface TestHarnessLogEntry {
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
seed(input: {
companies?: Company[];
projects?: Project[];
@@ -83,6 +87,8 @@ export interface TestHarness {
goals?: Goal[];
projectWorkspaces?: PluginWorkspace[];
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
accessMembers?: PluginAccessMember[];
principalGrants?: PrincipalPermissionGrant[];
}): void;
setConfig(config: Record<string, unknown>): void;
/** 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 agents = new Map<string, Agent>();
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 executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
@@ -1983,6 +2022,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return updated;
},
},
access: {
members: {
async list(input) {
requireCapability(manifest, capabilitySet, "access.members.read");
const cid = requireCompanyId(input.companyId);
const includeArchived = input.includeArchived === true;
return [...accessMembers.values()]
.filter((member) => member.companyId === cid)
.filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
.map((member) => ({
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
}));
},
async get(memberId, companyId) {
requireCapability(manifest, capabilitySet, "access.members.read");
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) return null;
return {
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
};
},
async update(memberId, patch, companyId) {
requireCapability(manifest, capabilitySet, "access.members.write");
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) {
throw new Error(`Membership not found: ${memberId}`);
}
const updated: PluginAccessMember = {
...member,
membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
status: patch.status === undefined ? member.status : patch.status,
updatedAt: new Date().toISOString(),
};
accessMembers.set(memberId, updated);
return {
...updated,
grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
};
},
},
invites: {
async list(input) {
requireCapability(manifest, capabilitySet, "access.invites.read");
requireCompanyId(input.companyId);
return { invites: [], nextOffset: null };
},
async create(input) {
requireCapability(manifest, capabilitySet, "access.invites.write");
requireCompanyId(input.companyId);
throw new Error("Invite creation is not implemented in the plugin test harness");
},
async revoke(inviteId, companyId) {
requireCapability(manifest, capabilitySet, "access.invites.write");
requireCompanyId(companyId);
throw new Error(`Invite not found: ${inviteId}`);
},
},
},
authorization: {
grants: {
async list(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.read");
const cid = requireCompanyId(input.companyId);
if (input.principalType && input.principalId) {
return getPrincipalGrants(cid, input.principalType, input.principalId);
}
const out: PrincipalPermissionGrant[] = [];
for (const [key, grants] of principalGrants.entries()) {
if (!key.startsWith(`${cid}:`)) continue;
for (const grant of grants) {
if (input.principalType && grant.principalType !== input.principalType) continue;
if (input.principalId && grant.principalId !== input.principalId) continue;
out.push(grant);
}
}
return out;
},
async set(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.write");
const cid = requireCompanyId(input.companyId);
return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
},
},
policies: {
async summary(companyId) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
const cid = requireCompanyId(companyId);
const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
let grantCount = 0;
for (const [key, grants] of principalGrants.entries()) {
if (key.startsWith(`${cid}:`)) grantCount += grants.length;
}
return {
companyId: cid,
permissionsMode: "simple",
memberCount: members.length,
activeMemberCount: members.filter((member) => member.status === "active").length,
grantCount,
advancedPolicyAvailable: false,
};
},
async get(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return null;
},
async update(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.write");
const cid = requireCompanyId(input.companyId);
return {
companyId: cid,
resourceType: input.resourceType,
resourceId: input.resourceId,
policy: input.policy,
updatedAt: new Date().toISOString(),
};
},
async previewAssignment(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return {
allowed: true,
action: "issue.assign",
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
reason: "simple_mode",
};
},
async explainAssignment(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return {
allowed: true,
action: "issue.assign",
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
reason: "simple_mode",
};
},
},
audit: {
async search(input) {
requireCapability(manifest, capabilitySet, "authorization.audit.read");
requireCompanyId(input.companyId);
return [];
},
},
},
data: {
register(key, handler) {
dataHandlers.set(key, handler);
@@ -2065,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
projectWorkspaces.set(row.projectId, list);
}
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
for (const row of input.principalGrants ?? []) {
const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
list.push(row);
principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
}
},
setConfig(config) {
currentConfig = { ...config };
+181
View File
@@ -39,6 +39,12 @@ import type {
RoutineRun,
Agent,
Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -120,6 +126,12 @@ export type {
IssueSurfaceVisibility,
Agent,
Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -1576,6 +1588,169 @@ export interface PluginGoalsClient {
): Promise<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)
// ---------------------------------------------------------------------------
@@ -1716,6 +1891,12 @@ export interface PluginContext {
/** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */
goals: PluginGoalsClient;
/** Read and manage access memberships and invites. Requires `access.*` capabilities. */
access: PluginAccessClient;
/** Read and manage authorization grants, policy summaries, previews, and audit entries. Requires `authorization.*` capabilities. */
authorization: PluginAuthorizationClient;
/** Register getData handlers for the plugin's UI components. */
data: PluginDataClient;
+1
View File
@@ -146,6 +146,7 @@ export type {
// Slot component prop interfaces
export type {
PluginPageProps,
PluginCompanySettingsPageProps,
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
+12
View File
@@ -229,6 +229,18 @@ export interface PluginPageProps {
context: PluginHostContext;
}
/**
* Props passed to a plugin company settings page component.
*
* A company settings page is mounted at
* `/:companyPrefix/company/settings/:routePath` and always receives the active
* company id and prefix when available.
*/
export interface PluginCompanySettingsPageProps {
/** The current host context, including company id and prefix. */
context: PluginHostContext;
}
/**
* Props passed to a plugin dashboard widget component.
*
+115 -15
View File
@@ -35,6 +35,7 @@
*/
import fs from "node:fs";
import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { fileURLToPath } from "node:url";
@@ -66,6 +67,7 @@ import type {
} from "./types.js";
import type {
JsonRpcId,
JsonRpcNotification,
JsonRpcRequest,
JsonRpcResponse,
InitializeParams,
@@ -85,6 +87,7 @@ import type {
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentProbeParams,
PluginInvocationContext,
WorkerToHostMethodName,
WorkerToHostMethods,
} from "./protocol.js";
@@ -279,6 +282,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let manifest: PaperclipPluginManifestV1 | null = null;
let currentConfig: Record<string, unknown> = {};
let databaseNamespace: string | null = null;
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
// Plugin handler registrations (populated during setup())
const eventHandlers: EventRegistration[] = [];
@@ -365,7 +369,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
try {
const request = createRequest(method, params, id);
const activeInvocation = invocationContextStorage.getStore();
const request = {
...createRequest(method, params, id),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
};
sendMessage(request);
} catch (err) {
settle(reject, err instanceof Error ? err : new Error(String(err)));
@@ -378,7 +386,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
*/
function notifyHost(method: string, params: unknown): void {
try {
sendMessage(createNotification(method, params));
const activeInvocation = invocationContextStorage.getStore();
sendMessage({
...createNotification(method, params),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
});
} catch {
// Swallow — the host may have closed stdin
}
@@ -1086,6 +1098,85 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
access: {
members: {
async list(input) {
return callHost("access.members.list", {
companyId: input.companyId,
includeArchived: input.includeArchived,
});
},
async get(memberId: string, companyId: string) {
return callHost("access.members.get", { memberId, companyId });
},
async update(memberId: string, patch, companyId: string) {
return callHost("access.members.update", { memberId, patch, companyId });
},
},
invites: {
async list(input) {
return callHost("access.invites.list", {
companyId: input.companyId,
state: input.state,
limit: input.limit,
offset: input.offset,
});
},
async create(input) {
return callHost("access.invites.create", {
companyId: input.companyId,
allowedJoinTypes: input.allowedJoinTypes,
humanRole: input.humanRole,
defaultsPayload: input.defaultsPayload,
agentMessage: input.agentMessage,
});
},
async revoke(inviteId: string, companyId: string) {
return callHost("access.invites.revoke", { inviteId, companyId });
},
},
},
authorization: {
grants: {
async list(input) {
return callHost("authorization.grants.list", input);
},
async set(input) {
return callHost("authorization.grants.set", input);
},
},
policies: {
async summary(companyId: string) {
return callHost("authorization.policies.summary", { companyId });
},
async get(input) {
return callHost("authorization.policies.get", input);
},
async update(input) {
return callHost("authorization.policies.update", input);
},
async previewAssignment(input) {
return callHost("authorization.policies.previewAssignment", input);
},
async explainAssignment(input) {
return callHost("authorization.policies.explainAssignment", input);
},
},
audit: {
async search(input) {
return callHost("authorization.audit.search", input);
},
},
},
data: {
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
dataHandlers.set(key, handler);
@@ -1175,7 +1266,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const { id, method, params } = request;
try {
const result = await dispatchMethod(method, params);
const invoke = () => dispatchMethod(method, params);
const result = request.paperclipInvocation
? await invocationContextStorage.run(request.paperclipInvocation, invoke)
: await invoke();
sendMessage(createSuccessResponse(id, result ?? null));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -1413,11 +1507,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No data handler registered for key "${params.key}"`);
}
return handler(
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment },
);
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
}
async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
@@ -1425,11 +1519,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`);
}
return handler(
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment },
);
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
}
async function handleExecuteTool(params: ExecuteToolParams): Promise<ToolResult> {
@@ -1597,14 +1691,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
} else if (isJsonRpcNotification(message)) {
// Dispatch host→worker push notifications
const notif = message as { method: string; params?: unknown };
const notif = message as JsonRpcNotification & { method: string; params?: unknown };
const runNotification = (fn: () => void | Promise<void>) => {
if (notif.paperclipInvocation) {
return invocationContextStorage.run(notif.paperclipInvocation, fn);
}
return fn();
};
if (notif.method === "agents.sessions.event" && notif.params) {
const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event);
} else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers
handleOnEvent(notif.params as OnEventParams).catch((err) => {
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
notifyHost("log", {
level: "error",
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,
@@ -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();
});
});
@@ -1,11 +1,26 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline";
import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { isWorkerEntrypoint } from "../src/worker-rpc-host.js";
import { definePlugin } from "../src/define-plugin.js";
import {
createRequest,
createErrorResponse,
createSuccessResponse,
isJsonRpcRequest,
isJsonRpcResponse,
parseMessage,
PLUGIN_RPC_ERROR_CODES,
serializeMessage,
type JsonRpcResponse,
type PluginInvocationContext,
} from "../src/protocol.js";
import { isWorkerEntrypoint, startWorkerRpcHost } from "../src/worker-rpc-host.js";
describe("isWorkerEntrypoint", () => {
const tempRoots: string[] = [];
@@ -55,3 +70,145 @@ describe("isWorkerEntrypoint", () => {
).toBe(false);
});
});
describe("worker invocation scope propagation", () => {
it("keeps overlapping company scopes local to each getData invocation", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<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();
}
});
});
+25
View File
@@ -739,6 +739,11 @@ export const PLUGIN_CAPABILITIES = [
"activity.read",
"costs.read",
"issues.orchestration.read",
"access.members.read",
"access.invites.read",
"authorization.grants.read",
"authorization.policies.read",
"authorization.audit.read",
"database.namespace.read",
// Data Write
"issues.create",
@@ -756,6 +761,10 @@ export const PLUGIN_CAPABILITIES = [
"agents.resume",
"agents.invoke",
"agents.managed",
"access.members.write",
"access.invites.write",
"authorization.grants.write",
"authorization.policies.write",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
@@ -857,6 +866,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"commentAnnotation",
"commentContextMenuItem",
"settingsPage",
"companySettingsPage",
] as const;
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
@@ -887,6 +897,21 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
export type PluginReservedCompanyRouteSegment =
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number];
/**
* Reserved route segments under `/:companyPrefix/company/settings/...` that
* plugin company settings pages may not claim.
*/
export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [
"general",
"environments",
"access",
"members",
"invites",
"secrets",
] as const;
export type PluginReservedCompanySettingsRouteSegment =
(typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number];
/**
* Launcher placement zones describe where a plugin-owned launcher can appear
* in the host UI. These are intentionally aligned with current slot surfaces
+2
View File
@@ -111,6 +111,7 @@ export {
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS,
@@ -226,6 +227,7 @@ export {
type PluginCapability,
type PluginUiSlotType,
type PluginUiSlotEntityType,
type PluginReservedCompanySettingsRouteSegment,
type PluginLauncherPlacementZone,
type PluginLauncherAction,
type PluginLauncherBounds,
+1 -1
View File
@@ -58,7 +58,7 @@ export interface AgentInstructionsBundle {
export interface AgentAccessState {
canAssignTasks: boolean;
taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none";
taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none";
membership: CompanyMembership | null;
grants: PrincipalPermissionGrant[];
}
+4 -1
View File
@@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration {
*/
entityTypes?: PluginUiSlotEntityType[];
/**
* Optional company-scoped route segment for page and routeSidebar slots.
* Optional company-scoped route segment for page, routeSidebar, and
* companySettingsPage slots.
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
* For companySettingsPage, `permissions` becomes
* `/:companyPrefix/company/settings/permissions`.
*/
routePath?: string;
/**
@@ -8,6 +8,37 @@ describe("plugin capability constants", () => {
});
});
describe("plugin manifest validators", () => {
it("accepts existing-style plugins that do not request access or authorization capabilities", () => {
const parsed = pluginManifestV1Schema.parse({
id: "paperclip.compat-dashboard",
apiVersion: 1,
version: "0.1.0",
displayName: "Compat Dashboard",
description: "Dashboard-only plugin without access or authorization host APIs.",
author: "Paperclip",
categories: ["ui"],
capabilities: ["ui.dashboardWidget.register"],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui.js",
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "compat-dashboard",
displayName: "Compat Dashboard",
exportName: "CompatDashboard",
},
],
},
});
expect(parsed.capabilities).toEqual(["ui.dashboardWidget.register"]);
});
});
describe("plugin managed routine validators", () => {
it("accepts core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.parse({
@@ -128,4 +159,30 @@ describe("plugin UI slot validators", () => {
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
});
it("accepts company settings page slots with a non-core settings route", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "companySettingsPage",
id: "permissions-settings",
displayName: "Permissions",
exportName: "PermissionsSettingsPage",
routePath: "permissions",
});
expect(parsed.routePath).toBe("permissions");
});
it("prevents company settings page slots from shadowing core settings routes", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "companySettingsPage",
id: "access-settings",
displayName: "Access",
exportName: "AccessSettingsPage",
routePath: "access",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
});
+21 -2
View File
@@ -6,6 +6,7 @@ import {
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS,
@@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["entityTypes"],
});
}
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar" && value.type !== "companySettingsPage") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routePath is only supported for page and routeSidebar slots",
message: "routePath is only supported for page, routeSidebar, and companySettingsPage slots",
path: ["routePath"],
});
}
@@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"],
});
}
if (value.type === "companySettingsPage" && !value.routePath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "companySettingsPage slots require routePath",
path: ["routePath"],
});
}
if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"],
});
}
if (
value.type === "companySettingsPage"
&& value.routePath
&& PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number])
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `company settings routePath "${value.routePath}" is reserved by the host`,
path: ["routePath"],
});
}
});
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;
@@ -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,
});
});
});
+286 -1
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
companyMemberships,
createDb,
@@ -14,6 +15,8 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { accessService } from "../services/access.js";
import { grantsForHumanRole } from "../services/company-member-roles.js";
import { backfillPrincipalAccessCompatibility } from "../services/principal-access-compatibility.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -56,6 +59,7 @@ describeEmbeddedPostgres("access service", () => {
await db.delete(issues);
await db.delete(principalPermissionGrants);
await db.delete(instanceUserRoles);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companies);
});
@@ -221,4 +225,285 @@ describeEmbeddedPostgres("access service", () => {
access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }),
).rejects.toThrow("Instance admins cannot be removed from company access");
});
it("allows owner and admin role-default grants to manage environments", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
const roles = ["admin", "operator", "viewer"] as const;
const members = await db
.insert(companyMemberships)
.values(
roles.map((role) => ({
companyId: company.id,
principalType: "user" as const,
principalId: `${role}-${randomUUID()}`,
status: "active" as const,
membershipRole: role,
})),
)
.returning();
await access.setPrincipalGrants(
company.id,
"user",
owner.principalId,
grantsForHumanRole("owner"),
owner.principalId,
);
for (const member of members) {
await access.setPrincipalGrants(
company.id,
"user",
member.principalId,
grantsForHumanRole(member.membershipRole as "admin" | "operator" | "viewer"),
owner.principalId,
);
}
const admin = members.find((member) => member.membershipRole === "admin")!;
const operator = members.find((member) => member.membershipRole === "operator")!;
const viewer = members.find((member) => member.membershipRole === "viewer")!;
await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true);
await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false);
await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false);
});
it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const scopedEnvironmentGrant = { environmentId: "env-1" };
const humanRows = await db
.insert(companyMemberships)
.values([
{
companyId: company.id,
principalType: "user",
principalId: `admin-${randomUUID()}`,
status: "active",
membershipRole: "admin",
},
{
companyId: company.id,
principalType: "user",
principalId: `operator-${randomUUID()}`,
status: "active",
membershipRole: "operator",
},
{
companyId: company.id,
principalType: "user",
principalId: `viewer-${randomUUID()}`,
status: "active",
membershipRole: "viewer",
},
{
companyId: company.id,
principalType: "user",
principalId: `legacy-${randomUUID()}`,
status: "active",
membershipRole: null,
},
])
.returning();
const admin = humanRows[0]!;
const operator = humanRows[1]!;
const viewer = humanRows[2]!;
const legacyMember = humanRows[3]!;
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: owner.principalId,
permissionKey: "environments:manage",
scope: scopedEnvironmentGrant,
grantedByUserId: "custom-author",
});
const first = await backfillPrincipalAccessCompatibility(db);
const second = await backfillPrincipalAccessCompatibility(db);
expect(first.humanGrantsInserted).toBeGreaterThan(0);
expect(second.humanGrantsInserted).toBe(0);
await expect(accessService(db).canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
await expect(accessService(db).canUser(company.id, operator.principalId, "tasks:assign")).resolves.toBe(true);
await expect(accessService(db).canUser(company.id, legacyMember.principalId, "tasks:assign")).resolves.toBe(true);
await expect(accessService(db).canUser(company.id, viewer.principalId, "tasks:assign")).resolves.toBe(false);
const ownerEnvironmentGrants = await db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, company.id),
eq(principalPermissionGrants.principalId, owner.principalId),
eq(principalPermissionGrants.permissionKey, "environments:manage"),
),
);
expect(ownerEnvironmentGrants).toHaveLength(1);
expect(ownerEnvironmentGrants[0]?.scope).toEqual(scopedEnvironmentGrant);
expect(ownerEnvironmentGrants[0]?.grantedByUserId).toBe("custom-author");
});
it("backfills non-terminal agents as active company members without reviving pending or terminated agents", async () => {
const { company } = await createCompanyWithOwner(db);
const agentRows = await db
.insert(agents)
.values([
{
companyId: company.id,
name: `Idle ${randomUUID()}`,
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
{
companyId: company.id,
name: `Running ${randomUUID()}`,
role: "engineer",
status: "running",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
{
companyId: company.id,
name: `Pending ${randomUUID()}`,
role: "engineer",
status: "pending_approval",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
{
companyId: company.id,
name: `Terminated ${randomUUID()}`,
role: "engineer",
status: "terminated",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
])
.returning();
const idleAgent = agentRows[0]!;
const runningAgent = agentRows[1]!;
const pendingAgent = agentRows[2]!;
const terminatedAgent = agentRows[3]!;
const first = await backfillPrincipalAccessCompatibility(db);
const second = await backfillPrincipalAccessCompatibility(db);
expect(first.agentMembershipsInserted).toBe(2);
expect(second.agentMembershipsInserted).toBe(0);
const memberships = await db
.select()
.from(companyMemberships)
.where(eq(companyMemberships.principalType, "agent"));
expect(memberships.map((membership) => membership.principalId).sort()).toEqual([
idleAgent.id,
runningAgent.id,
].sort());
expect(memberships.every((membership) => membership.status === "active")).toBe(true);
expect(memberships.every((membership) => membership.membershipRole === "member")).toBe(true);
expect(memberships.some((membership) => membership.principalId === pendingAgent.id)).toBe(false);
expect(memberships.some((membership) => membership.principalId === terminatedAgent.id)).toBe(false);
});
it("copies active user memberships with role-default grants for safe company imports", async () => {
const source = await createCompanyWithOwner(db);
const target = await createCompanyWithOwner(db);
const admin = await db
.insert(companyMemberships)
.values({
companyId: source.company.id,
principalType: "user",
principalId: `admin-${randomUUID()}`,
status: "active",
membershipRole: "admin",
})
.returning()
.then((rows) => rows[0]!);
const access = accessService(db);
await access.copyActiveUserMemberships(source.company.id, target.company.id);
const copiedOwnerGrants = await access.listPrincipalGrants(
target.company.id,
"user",
source.owner.principalId,
);
const copiedAdminGrants = await access.listPrincipalGrants(
target.company.id,
"user",
admin.principalId,
);
expect(copiedOwnerGrants.map((grant) => grant.permissionKey)).toEqual(
grantsForHumanRole("owner").map((grant) => grant.permissionKey).sort(),
);
expect(copiedAdminGrants.map((grant) => grant.permissionKey)).toEqual(
grantsForHumanRole("admin").map((grant) => grant.permissionKey).sort(),
);
});
it("preserves explicit scoped environment grants when backfilling owner and admin defaults", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const scopedGrant = { environmentId: "env-1" };
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: owner.principalId,
permissionKey: "environments:manage",
scope: scopedGrant,
grantedByUserId: "custom-grant-author",
});
await db.execute(sql.raw(`
INSERT INTO "principal_permission_grants" (
"company_id",
"principal_type",
"principal_id",
"permission_key",
"scope",
"granted_by_user_id",
"created_at",
"updated_at"
)
SELECT
"company_id",
'user',
"principal_id",
'environments:manage',
NULL,
NULL,
now(),
now()
FROM "company_memberships"
WHERE "principal_type" = 'user'
AND "status" = 'active'
AND "membership_role" IN ('owner', 'admin')
ON CONFLICT (
"company_id",
"principal_type",
"principal_id",
"permission_key"
) DO NOTHING
`));
const grants = await db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, company.id),
eq(principalPermissionGrants.principalId, owner.principalId),
eq(principalPermissionGrants.permissionKey, "environments:manage"),
),
);
expect(grants).toHaveLength(1);
expect(grants[0]?.scope).toEqual(scopedGrant);
expect(grants[0]?.grantedByUserId).toBe("custom-grant-author");
});
});
@@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
@@ -192,6 +193,11 @@ describe("agent routes adapter validation", () => {
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
@@ -60,6 +60,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
@@ -293,6 +294,17 @@ function resetMockDefaults() {
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
}));
mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser);
mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
? true
: currentAccessCanUser;
return {
allowed,
action: input.action,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
};
});
mockAccessService.hasPermission.mockImplementation(async () => false);
mockAccessService.getMembership.mockImplementation(async () => null);
mockAccessService.listPrincipalGrants.mockImplementation(async () => []);
@@ -21,6 +21,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -175,6 +176,11 @@ describe("agent instructions bundle routes", () => {
vi.clearAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAgentService.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent(),
@@ -51,7 +51,16 @@ function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
accessService: () => ({}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
@@ -51,6 +51,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
@@ -302,6 +303,7 @@ describe.sequential("agent permission routes", () => {
mockAgentService.getChainOfCommand.mockReset();
mockAgentService.resolveByReference.mockReset();
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset();
mockAccessService.getMembership.mockReset();
mockAccessService.ensureMembership.mockReset();
@@ -342,6 +344,14 @@ describe.sequential("agent permission routes", () => {
mockAgentService.update.mockResolvedValue(baseAgent);
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
const allowed = Boolean(await mockAccessService.canUser());
return {
allowed,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant" : `Missing test grant for ${input.action ?? "action"}`,
};
});
mockAccessService.hasPermission.mockResolvedValue(false);
mockAccessService.getMembership.mockResolvedValue({
id: "membership-1",
@@ -1342,6 +1352,24 @@ describe.sequential("agent permission routes", () => {
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
}, 15_000);
it("reports simple-mode task assignment as enabled for active company agent members", async () => {
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
expect(res.status).toBe(200);
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("simple_default");
}, 15_000);
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
mockAgentService.updatePermissions.mockResolvedValue({
...baseAgent,
@@ -11,6 +11,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
@@ -315,6 +316,11 @@ describe.sequential("agent skill routes", () => {
);
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.getMembership.mockResolvedValue(null);
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
@@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(async () => null),
listPrincipalGrants: vi.fn(async () => []),
@@ -120,6 +121,11 @@ describe("agent test-environment route", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockEnvironmentService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveViteHmrPort } from "../app.ts";
import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts";
describe("resolveViteHmrPort", () => {
it("uses serverPort + 10000 when the result stays in range", () => {
@@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => {
expect(resolveViteHmrPort(9_000)).toBe(19_000);
});
});
describe("resolveViteHmrHost", () => {
it("omits wildcard bind hosts so Vite uses the browser hostname", () => {
expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined();
expect(resolveViteHmrHost("::")).toBeUndefined();
});
it("keeps concrete bind hosts", () => {
expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1");
expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev");
});
});
@@ -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" },
});
});
});
+41 -2
View File
@@ -5,13 +5,17 @@ import {
buildBetterAuthAdvancedOptions,
deriveAuthCookiePrefix,
deriveAuthTrustedOrigins,
shouldDisableSecureAuthCookies,
} from "../auth/better-auth.js";
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL;
afterEach(() => {
if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID;
if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.PAPERCLIP_PUBLIC_URL;
else process.env.PAPERCLIP_PUBLIC_URL = ORIGINAL_PUBLIC_URL;
});
describe("Better Auth cookie scoping", () => {
@@ -28,8 +32,8 @@ describe("Better Auth cookie scoping", () => {
expect(advanced).toEqual({
cookiePrefix: "paperclip-sat-worktree",
});
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe(
"paperclip-sat-worktree.session_token",
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch(
/paperclip-sat-worktree\.session_token$/,
);
});
@@ -42,6 +46,41 @@ describe("Better Auth cookie scoping", () => {
});
});
it("disables secure cookies when no canonical public auth URL is configured", () => {
delete process.env.PAPERCLIP_PUBLIC_URL;
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
} as Parameters<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", () => {
const trustedOrigins = deriveAuthTrustedOrigins({
deploymentMode: "authenticated",
@@ -20,6 +20,7 @@ const agentSvc = {
const accessSvc = {
ensureMembership: vi.fn(),
ensureRoleDefaultGrants: vi.fn(),
listActiveUserMemberships: vi.fn(),
copyActiveUserMemberships: vi.fn(),
setPrincipalPermission: vi.fn(),
@@ -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}`,
},
});
});
@@ -68,6 +68,7 @@ describe("human invite roles", () => {
it("maps owner to the full management grant set", () => {
expect(grantsForHumanRole("owner")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
@@ -75,6 +76,16 @@ describe("human invite roles", () => {
]);
});
it("maps admin to management grants including environment management", () => {
expect(grantsForHumanRole("admin")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
]);
});
it("defaults legacy or missing roles to operator", () => {
expect(normalizeHumanRole("member")).toBe("operator");
expect(resolveHumanInviteRole(null)).toBe("operator");
@@ -30,6 +30,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -275,6 +276,13 @@ describe("agent issue mutation checkout ownership", () => {
registerRouteMocks();
vi.clearAllMocks();
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "tasks:assign",
action: input.action,
reason: input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "tasks:assign" ? "Allowed by test assignment default." : "Missing permission.",
}));
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
@@ -682,12 +690,12 @@ describe("agent issue mutation checkout ownership", () => {
});
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
mockAccessService.hasPermission.mockImplementation(async (
_companyId: string,
_principalType: string,
principalId: string,
permissionKey: string,
) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts");
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "tasks:manage_active_checkouts",
action: input.action,
reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.",
}));
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
@@ -828,4 +836,37 @@ describe("agent issue mutation checkout ownership", () => {
}),
);
});
it("uses the authorization decision path for assignment changes", async () => {
const decide = vi.fn(async () => ({
allowed: false,
action: "tasks:assign",
reason: "deny_policy_restricted",
explanation: "Target agent requires approval before task assignment.",
}));
(mockAccessService as any).decide = decide;
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent(peerAgentId),
});
const app = await createApp(ownerActor());
const res = await request(app)
.patch(`/api/issues/${issueId}`)
.send({ assigneeAgentId: peerAgentId });
expect(res.status).toBe(403);
expect(res.body.error).toContain("requires approval");
expect(decide).toHaveBeenCalledWith(expect.objectContaining({
action: "tasks:assign",
resource: expect.objectContaining({
type: "issue",
companyId,
issueId,
assigneeAgentId: peerAgentId,
}),
}));
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
@@ -22,6 +22,12 @@ const mockIssueService = vi.hoisted(() => ({
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -16,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -229,6 +230,7 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset();
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.reportRunActivity.mockReset();
@@ -307,6 +309,15 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
const allowed = input.action !== "tasks:manage_active_checkouts";
return {
allowed,
action: input.action,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant." : "Missing active checkout override.",
};
});
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.list.mockResolvedValue([
@@ -26,6 +26,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false),
decide: vi.fn(),
hasPermission: vi.fn(async () => false),
}));
@@ -160,6 +161,17 @@ describe("issue execution policy routes", () => {
parentBlockerAdded: false,
});
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
? true
: Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission());
return {
allowed,
action: input.action,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
};
});
mockAccessService.hasPermission.mockResolvedValue(false);
});
@@ -43,6 +43,12 @@ function registerModuleMocks() {
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -33,6 +33,12 @@ vi.mock("../services/index.js", () => ({
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -95,6 +101,12 @@ function registerModuleMocks() {
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
@@ -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");
});
});
@@ -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();
});
});
@@ -613,6 +613,28 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(call).not.toHaveBeenCalled();
});
it("forwards authorized bridge company scope to the plugin worker", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/health`)
.send({ companyId: companyA, params: { view: "compact" } });
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "getData", {
key: "health",
companyId: companyA,
params: { view: "compact" },
renderEnvironment: null,
});
});
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
@@ -82,4 +82,29 @@ describe("plugin SDK test harness", () => {
"missing required capability 'skills.managed'",
);
});
it("requires access and authorization capabilities for permission SDK calls", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-access-authz-capability",
apiVersion: 1,
version: "0.1.0",
displayName: "Missing Access Capability",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
await expect(harness.ctx.access.members.list({ companyId: "company-1" })).rejects.toThrow(
"missing required capability 'access.members.read'",
);
await expect(harness.ctx.authorization.grants.list({ companyId: "company-1" })).rejects.toThrow(
"missing required capability 'authorization.grants.read'",
);
await expect(harness.ctx.authorization.audit.search({ companyId: "company-1" })).rejects.toThrow(
"missing required capability 'authorization.audit.read'",
);
});
});
@@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
createHostClientHandlers,
JsonRpcCallError,
PLUGIN_RPC_ERROR_CODES,
type HostServices,
type HostToWorkerMethods,
} from "@paperclipai/plugin-sdk";
import {
@@ -14,6 +17,10 @@ import {
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-invocation-scope.cjs",
);
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
@@ -178,4 +185,86 @@ describe("plugin-worker-manager stderr failure context", () => {
await handle.stop().catch(() => undefined);
}
});
it("passes echoed invocation scope to worker-to-host handlers", async () => {
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: {
"companies.get": companiesGet,
},
});
try {
await handle.start();
await expect(handle.call("getData", {
key: "probe",
companyId: "company-1",
params: {
mode: "echo",
requestedCompanyId: "company-1",
},
} as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" });
expect(companiesGet).toHaveBeenCalledWith(
{ companyId: "company-1" },
{ invocationScope: { companyId: "company-1" } },
);
} finally {
await handle.stop().catch(() => undefined);
}
});
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
const hostHandlers = createHostClientHandlers({
pluginId: "test.plugin",
capabilities: ["companies.read"],
services: {
companies: {
get: companiesGet,
},
} as unknown as HostServices,
});
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers,
});
try {
await handle.start();
for (const mode of ["omit", "unknown"]) {
await expect(handle.call("getData", {
key: "probe",
companyId: "company-1",
params: {
mode,
requestedCompanyId: "company-2",
},
} as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
});
}
expect(companiesGet).not.toHaveBeenCalled();
} finally {
await handle.stop().catch(() => undefined);
}
});
});
@@ -138,6 +138,10 @@ vi.mock("../realtime/live-events-ws.js", () => ({
}));
vi.mock("../services/index.js", () => ({
backfillPrincipalAccessCompatibility: vi.fn(async () => ({
agentMembershipsInserted: 0,
humanGrantsInserted: 0,
})),
feedbackService: feedbackServiceFactoryMock,
heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined),
+8 -1
View File
@@ -96,6 +96,12 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000);
}
export function resolveViteHmrHost(bindHost: string): string | undefined {
const normalized = bindHost.trim().toLowerCase();
if (normalized === "0.0.0.0" || normalized === "::") return undefined;
return bindHost;
}
export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
@@ -373,6 +379,7 @@ export async function createApp(
const uiRoot = path.resolve(__dirname, "../../ui");
const publicUiRoot = path.resolve(uiRoot, "public");
const hmrPort = resolveViteHmrPort(opts.serverPort);
const hmrHost = resolveViteHmrHost(opts.bindHost);
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: uiRoot,
@@ -380,7 +387,7 @@ export async function createApp(
server: {
middlewareMode: true,
hmr: {
host: opts.bindHost,
...(hmrHost ? { host: hmrHost } : {}),
port: hmrPort,
clientPort: hmrPort,
},
+11 -3
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 {
const headers = new Headers();
for (const [key, raw] of Object.entries(rawHeaders)) {
@@ -99,8 +108,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
"For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.",
);
}
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl;
const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
const disableSecureCookies = shouldDisableSecureAuthCookies(config);
const authConfig = {
baseURL: baseUrl,
@@ -120,7 +128,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }),
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }),
};
if (!baseUrl) {
+12
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
import type { DeploymentMode } from "@paperclipai/shared";
import { ensureHumanRoleDefaultGrants } from "./services/principal-access-compatibility.js";
const LOCAL_BOARD_USER_ID = "local-board";
const CLAIM_TTL_MS = 1000 * 60 * 60 * 24;
@@ -89,6 +90,7 @@ export async function claimBoardOwnership(
const status = getChallengeStatus(opts.token, opts.code);
if (status !== "available") return { status };
const claimedCompanyIds: string[] = [];
await db.transaction(async (tx) => {
const existingTargetAdmin = await tx
.select({ id: instanceUserRoles.id })
@@ -108,6 +110,7 @@ export async function claimBoardOwnership(
const allCompanies = await tx.select({ id: companies.id }).from(companies);
for (const company of allCompanies) {
claimedCompanyIds.push(company.id);
const existing = await tx
.select({ id: companyMemberships.id, status: companyMemberships.status })
.from(companyMemberships)
@@ -140,6 +143,15 @@ export async function claimBoardOwnership(
}
});
for (const companyId of claimedCompanyIds) {
await ensureHumanRoleDefaultGrants(db, {
companyId,
principalId: opts.userId,
membershipRole: "owner",
grantedByUserId: opts.userId,
});
}
if (activeChallenge && activeChallenge.token === opts.token) {
activeChallenge.claimedAt = new Date();
activeChallenge.claimedByUserId = opts.userId;
+5
View File
@@ -30,6 +30,7 @@ import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import {
feedbackService,
backfillPrincipalAccessCompatibility,
heartbeatService,
instanceSettingsService,
reconcilePersistedRuntimeServicesOnStartup,
@@ -512,6 +513,10 @@ export async function startServer(): Promise<StartedServer> {
if (config.deploymentMode === "local_trusted") {
await ensureLocalTrustedBoardPrincipal(db as any);
}
const accessBackfill = await backfillPrincipalAccessCompatibility(db as any);
if (accessBackfill.agentMembershipsInserted > 0 || accessBackfill.humanGrantsInserted > 0) {
logger.info(accessBackfill, "Backfilled principal access compatibility records");
}
if (config.deploymentMode === "authenticated") {
const {
createBetterAuthHandler,
+38 -48
View File
@@ -501,6 +501,15 @@ export function agentRoutes(
};
}
if (membership?.status === "active") {
return {
canAssignTasks: true,
taskAssignSource: "simple_default" as const,
membership,
grants,
};
}
return {
canAssignTasks: false,
taskAssignSource: "none" as const,
@@ -543,34 +552,32 @@ export function agentRoutes(
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
if (!allowed) {
throw forbidden("Missing permission: agents:create");
}
return null;
const decision = await access.decide({
actor: req.actor,
action: "agents:create",
resource: { type: "company", companyId },
});
if (!decision.allowed) {
throw forbidden(decision.explanation);
}
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
if (req.actor.type !== "agent") return null;
const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
if (!allowedByGrant && !canCreateAgents(actorAgent)) {
throw forbidden("Missing permission: can create agents");
}
return actorAgent;
}
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
if (!allowed) {
throw forbidden("Missing permission: agents:create");
}
const decision = await access.decide({
actor: req.actor,
action: "agents:create",
resource: { type: "company", companyId },
});
if (decision.allowed) return;
throw forbidden(decision.explanation);
}
async function assertCanReadConfigurations(req: Request, companyId: string) {
@@ -592,15 +599,12 @@ export function agentRoutes(
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
return access.canUser(companyId, req.actor.userId, "agents:create");
}
if (!req.actor.agentId) return false;
const actorAgent = await svc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) return false;
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
return allowedByGrant || canCreateAgents(actorAgent);
const decision = await access.decide({
actor: req.actor,
action: "agent_config:read",
resource: { type: "company", companyId },
});
return decision.allowed;
}
async function buildSkippedWakeupResponse(
@@ -672,27 +676,13 @@ export function agentRoutes(
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") {
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
return;
}
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.id === targetAgent.id) return;
if (actorAgent.role === "ceo") return;
const allowedByGrant = await access.hasPermission(
targetAgent.companyId,
"agent",
actorAgent.id,
"agents:create",
);
if (allowedByGrant || canCreateAgents(actorAgent)) return;
throw forbidden("Only CEO or agent creators can modify other agents");
const decision = await access.decide({
actor: req.actor,
action: "agent_config:update",
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
});
if (decision.allowed) return;
throw forbidden(decision.explanation);
}
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
+8 -1
View File
@@ -271,7 +271,14 @@ export function companyRoutes(db: Db, storage?: StorageService) {
throw forbidden("Instance admin required");
}
const company = await svc.create(req.body);
await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active");
const ownerPrincipalId = req.actor.userId ?? "local-board";
await access.ensureMembership(company.id, "user", ownerPrincipalId, "owner", "active");
await access.ensureRoleDefaultGrants(
company.id,
ownerPrincipalId,
"owner",
req.actor.userId ?? null,
);
await logActivity(db, {
companyId: company.id,
actorType: "user",
+88 -48
View File
@@ -1246,29 +1246,48 @@ export function issueRoutes(
return (req.actor.companyIds ?? []).includes(companyId);
}
function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | null | undefined; role: string }) {
if (agent.role === "ceo") return true;
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
type TaskAssignmentAuthorizationScope = {
issueId?: string | null;
projectId?: string | null;
parentIssueId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
};
async function resolveAssignmentProjectId(input: {
companyId: string;
projectId: string | null | undefined;
parentIssueId?: string | null;
}) {
if (input.projectId !== undefined) return input.projectId;
if (!input.parentIssueId) return null;
const parent = await svc.getById(input.parentIssueId);
if (!parent || parent.companyId !== input.companyId) return null;
return parent.projectId ?? null;
}
async function assertCanAssignTasks(req: Request, companyId: string) {
async function assertCanAssignTasks(
req: Request,
companyId: string,
assignmentScope?: TaskAssignmentAuthorizationScope,
) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) throw forbidden("Missing permission: tasks:assign");
return;
}
if (req.actor.type === "agent") {
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign");
if (allowedByGrant) return;
const actorAgent = await agentsSvc.getById(req.actor.agentId);
if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return;
throw forbidden("Missing permission: tasks:assign");
}
throw unauthorized();
const decision = await access.decide({
actor: req.actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId,
issueId: assignmentScope?.issueId ?? null,
projectId: assignmentScope?.projectId ?? null,
parentIssueId: assignmentScope?.parentIssueId ?? null,
assigneeAgentId: assignmentScope?.assigneeAgentId ?? null,
assigneeUserId: assignmentScope?.assigneeUserId ?? null,
},
scope: assignmentScope ?? null,
});
if (decision.allowed) return;
throw forbidden(decision.explanation);
}
function requireAgentRunId(req: Request, res: Response) {
@@ -1284,31 +1303,12 @@ export function issueRoutes(
companyId: string,
assigneeAgentId: string,
) {
const allowedByGrant = await access.hasPermission(
companyId,
"agent",
actorAgentId,
"tasks:manage_active_checkouts",
);
if (allowedByGrant) return true;
const companyAgents = await agentsSvc.list(companyId);
const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent]));
const actorAgent = agentsById.get(actorAgentId);
if (!actorAgent) return false;
if (canCreateAgentsLegacy(actorAgent)) return true;
// Reporting-chain managers may intervene in an agent's active checkout
// without taking the task over. Peers must own the checkout/run first.
let cursor: string | null = assigneeAgentId;
for (let depth = 0; cursor && depth < 50; depth += 1) {
const assignee = agentsById.get(cursor);
if (!assignee) return false;
if (assignee.reportsTo === actorAgentId) return true;
cursor = assignee.reportsTo;
}
return false;
const decision = await access.decide({
actor: { type: "agent", agentId: actorAgentId, companyId },
action: "tasks:manage_active_checkouts",
resource: { type: "issue", companyId, assigneeAgentId },
});
return decision.allowed;
}
async function assertAgentIssueMutationAllowed(
@@ -3146,7 +3146,16 @@ export function issueRoutes(
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, companyId);
await assertCanAssignTasks(req, companyId, {
projectId: await resolveAssignmentProjectId({
companyId,
projectId: req.body.projectId,
parentIssueId: req.body.parentId,
}),
parentIssueId: req.body.parentId ?? null,
assigneeAgentId: req.body.assigneeAgentId ?? null,
assigneeUserId: req.body.assigneeUserId ?? null,
});
}
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
@@ -3242,7 +3251,12 @@ export function issueRoutes(
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, parent.companyId);
await assertCanAssignTasks(req, parent.companyId, {
projectId: req.body.projectId ?? parent.projectId ?? null,
parentIssueId: parent.id,
assigneeAgentId: req.body.assigneeAgentId ?? null,
assigneeUserId: req.body.assigneeUserId ?? null,
});
}
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
@@ -3631,7 +3645,23 @@ export function issueRoutes(
if (assigneeWillChange && !transition.workflowControlledAssignment) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
await assertCanAssignTasks(req, existing.companyId, {
issueId: existing.id,
projectId: await resolveAssignmentProjectId({
companyId: existing.companyId,
projectId: updateFields.projectId === undefined
? existing.projectId
: updateFields.projectId as string | null | undefined,
parentIssueId: (updateFields.parentId === undefined
? existing.parentId
: updateFields.parentId) as string | null | undefined,
}),
parentIssueId: (updateFields.parentId === undefined
? existing.parentId
: updateFields.parentId) as string | null | undefined,
assigneeAgentId: nextAssigneeAgentId,
assigneeUserId: nextAssigneeUserId,
});
}
}
@@ -4401,6 +4431,16 @@ export function issueRoutes(
return;
}
if (issue.assigneeAgentId !== req.body.agentId) {
await assertCanAssignTasks(req, issue.companyId, {
issueId: issue.id,
projectId: issue.projectId ?? null,
parentIssueId: issue.parentId ?? null,
assigneeAgentId: req.body.agentId,
assigneeUserId: null,
});
}
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
if (closedExecutionWorkspace) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
+8 -4
View File
@@ -1116,7 +1116,7 @@ export function pluginRoutes(
return;
}
assertPluginBridgeScope(req, body.companyId);
const companyId = assertPluginBridgeScope(req, body.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1124,6 +1124,7 @@ export function pluginRoutes(
"getData",
{
key: body.key,
...(companyId ? { companyId } : {}),
params: body.params ?? {},
renderEnvironment: body.renderEnvironment ?? null,
},
@@ -1208,7 +1209,7 @@ export function pluginRoutes(
return;
}
assertPluginBridgeScope(req, body.companyId);
const companyId = assertPluginBridgeScope(req, body.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1216,6 +1217,7 @@ export function pluginRoutes(
"performAction",
{
key: body.key,
...(companyId ? { companyId } : {}),
params: body.params ?? {},
renderEnvironment: body.renderEnvironment ?? null,
},
@@ -1301,7 +1303,7 @@ export function pluginRoutes(
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
} | undefined;
assertPluginBridgeScope(req, body?.companyId);
const companyId = assertPluginBridgeScope(req, body?.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1309,6 +1311,7 @@ export function pluginRoutes(
"getData",
{
key,
...(companyId ? { companyId } : {}),
params: body?.params ?? {},
renderEnvironment: body?.renderEnvironment ?? null,
},
@@ -1390,7 +1393,7 @@ export function pluginRoutes(
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
} | undefined;
assertPluginBridgeScope(req, body?.companyId);
const companyId = assertPluginBridgeScope(req, body?.companyId);
try {
const result = await bridgeDeps.workerManager.call(
@@ -1398,6 +1401,7 @@ export function pluginRoutes(
"performAction",
{
key,
...(companyId ? { companyId } : {}),
params: body?.params ?? {},
renderEnvironment: body?.renderEnvironment ?? null,
},
+47 -18
View File
@@ -9,6 +9,8 @@ import {
} from "@paperclipai/db";
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
import { conflict } from "../errors.js";
import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js";
type MembershipRow = typeof companyMemberships.$inferSelect;
type GrantInput = {
@@ -24,6 +26,8 @@ type MemberArchiveInput = {
};
export function accessService(db: Db) {
const authorization = authorizationService(db);
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
if (!userId) return false;
const row = await db
@@ -58,21 +62,13 @@ export function accessService(db: Db) {
principalId: string,
permissionKey: PermissionKey,
): Promise<boolean> {
const membership = await getMembership(companyId, principalType, principalId);
if (!membership || membership.status !== "active") return false;
const grant = await db
.select({ id: principalPermissionGrants.id })
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
eq(principalPermissionGrants.permissionKey, permissionKey),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(grant);
return authorization.decidePrincipalGrant({
companyId,
principalType,
principalId,
permissionKey,
action: permissionKey,
}).then((decision) => decision.allowed);
}
async function canUser(
@@ -80,9 +76,20 @@ export function accessService(db: Db) {
userId: string | null | undefined,
permissionKey: PermissionKey,
): Promise<boolean> {
if (!userId) return false;
if (await isInstanceAdmin(userId)) return true;
return hasPermission(companyId, "user", userId, permissionKey);
return authorization.decide({
actor: { type: "board", userId },
action: permissionKey,
resource: { type: "company", companyId },
}).then((decision) => decision.allowed);
}
async function decide(input: {
actor: AuthorizationActor;
action: Parameters<typeof authorization.decide>[0]["action"];
resource: AuthorizationResource;
scope?: Record<string, unknown> | null;
}) {
return authorization.decide(input);
}
async function listMembers(companyId: string) {
@@ -616,10 +623,30 @@ export function accessService(db: Db) {
membership.membershipRole,
"active",
);
await ensureHumanRoleDefaultGrants(db, {
companyId: targetCompanyId,
principalId: membership.principalId,
membershipRole: membership.membershipRole,
grantedByUserId: null,
});
}
return sourceMemberships;
}
async function ensureRoleDefaultGrants(
companyId: string,
principalId: string,
membershipRole: string | null | undefined,
grantedByUserId: string | null,
) {
return ensureHumanRoleDefaultGrants(db, {
companyId,
principalId,
membershipRole,
grantedByUserId,
});
}
async function listPrincipalGrants(
companyId: string,
principalType: PrincipalType,
@@ -768,6 +795,7 @@ export function accessService(db: Db) {
return {
isInstanceAdmin,
decide,
canUser,
hasPermission,
getMembership,
@@ -776,6 +804,7 @@ export function accessService(db: Db) {
listMembers,
listActiveUserMemberships,
copyActiveUserMemberships,
ensureRoleDefaultGrants,
archiveMember,
setMemberPermissions,
updateMemberAndPermissions,
+2
View File
@@ -18,7 +18,9 @@ export function normalizeAgentPermissions(
}
const record = permissions as Record<string, unknown>;
const preserved = { ...record };
return {
...preserved,
canCreateAgents:
typeof record.canCreateAgents === "boolean"
? record.canCreateAgents
+1 -1
View File
@@ -554,7 +554,7 @@ export function agentService(db: Db) {
const updated = await db
.update(agents)
.set({
permissions: normalizeAgentPermissions(permissions, existing.role),
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
updatedAt: new Date(),
})
.where(eq(agents.id, id))
+823
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,
};
}
@@ -28,6 +28,7 @@ export function grantsForHumanRole(
case "owner":
return [
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
@@ -36,6 +37,7 @@ export function grantsForHumanRole(
case "admin":
return [
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
+8 -1
View File
@@ -4118,7 +4118,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (mode === "agent_safe" && options?.sourceCompanyId) {
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
} else {
await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active");
const ownerPrincipalId = actorUserId ?? "board";
await access.ensureMembership(created.id, "user", ownerPrincipalId, "owner", "active");
await access.ensureRoleDefaultGrants(
created.id,
ownerPrincipalId,
"owner",
actorUserId ?? null,
);
}
targetCompany = created;
companyAction = "created";
+13
View File
@@ -44,6 +44,19 @@ export { sidebarBadgeService } from "./sidebar-badges.js";
export { sidebarPreferenceService } from "./sidebar-preferences.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js";
export {
backfillPrincipalAccessCompatibility,
ensureHumanRoleDefaultGrants,
insertMissingPrincipalGrants,
type PrincipalAccessCompatibilityBackfillStats,
} from "./principal-access-compatibility.js";
export { authorizationService } from "./authorization.js";
export type {
AuthorizationAction,
AuthorizationActor,
AuthorizationDecision,
AuthorizationResource,
} from "./authorization.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js";
@@ -149,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
commentAnnotation: "ui.commentAnnotation.register",
commentContextMenuItem: "ui.action.register",
settingsPage: "instance.settings.register",
companySettingsPage: "instance.settings.register",
routeSidebar: "ui.sidebar.register",
};
+550 -9
View File
@@ -1,14 +1,18 @@
import type { Db } from "@paperclipai/db";
import {
activityLog,
agentTaskSessions as agentTaskSessionsTable,
agents as agentsTable,
budgetIncidents,
costEvents,
heartbeatRuns,
invites,
issues as issuesTable,
pluginLogs,
principalPermissionGrants,
projects as projectsTable,
} from "@paperclipai/db";
import { eq, and, like, desc, inArray, sql } from "drizzle-orm";
import { eq, and, like, desc, inArray, sql, isNull, isNotNull, gt, lte } from "drizzle-orm";
import type {
HostServices,
Company,
@@ -22,7 +26,7 @@ import type {
PluginIssueOrchestrationSummary,
PluginExecutionWorkspaceMetadata,
} from "@paperclipai/plugin-sdk";
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared";
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import { companyService } from "./companies.js";
import { agentService } from "./agents.js";
@@ -36,11 +40,8 @@ import { heartbeatService } from "./heartbeat.js";
import { budgetService } from "./budgets.js";
import { issueApprovalService } from "./issue-approvals.js";
import { subscribeCompanyLiveEvents } from "./live-events.js";
import { randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import path from "node:path";
import { activityService } from "./activity.js";
import { costService } from "./costs.js";
import { assetService } from "./assets.js";
import { pluginRegistryService } from "./plugin-registry.js";
import { pluginStateStore } from "./plugin-state-store.js";
import { pluginDatabaseService } from "./plugin-database.js";
@@ -71,6 +72,9 @@ import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
import { accessService } from "./access.js";
import { authorizationService, type AuthorizationActor } from "./authorization.js";
import { sanitizeRecord } from "../redaction.js";
// ---------------------------------------------------------------------------
// SSRF protection for plugin HTTP fetch
@@ -526,11 +530,10 @@ export function buildHostServices(
const issues = issueService(db);
const documents = documentService(db);
const goals = goalService(db);
const activity = activityService(db);
const costs = costService(db);
const access = accessService(db);
const authorization = authorizationService(db);
const budgets = budgetService(db);
const issueApprovals = issueApprovalService(db);
const assets = assetService(db);
const scopedBus = eventBus.forPlugin(pluginKey);
// Track active session event subscriptions for cleanup
@@ -562,6 +565,17 @@ export function buildHostServices(
return rows.slice(offset, offset + limit);
};
const authorizationAuditDecisionCondition = (decisionFilter: string) => {
const conditions = [
sql`lower(${activityLog.details}->>'decision') = ${decisionFilter}`,
decisionFilter === "allow" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 6) = 'allow_'` : undefined,
decisionFilter === "deny" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 5) = 'deny_'` : undefined,
decisionFilter === "allow" ? sql`${activityLog.details}->>'allowed' = 'true'` : undefined,
decisionFilter === "deny" ? sql`${activityLog.details}->>'allowed' = 'false'` : undefined,
].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition));
return sql`(${sql.join(conditions, sql` OR `)})`;
};
/**
* Plugins are instance-wide in the current runtime. Company IDs are still
* required for company-scoped data access, but there is no per-company
@@ -841,6 +855,202 @@ export function buildHostServices(
}));
};
const INVITE_TOKEN_PREFIX = "pcp_invite_";
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
const INVITE_TOKEN_MAX_RETRIES = 5;
const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000;
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
const createInviteToken = () => {
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
let suffix = "";
for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length];
}
return `${INVITE_TOKEN_PREFIX}${suffix}`;
};
const isInviteTokenHashCollisionError = (error: unknown) => {
const candidates = [
error,
(error as { cause?: unknown } | null)?.cause ?? null,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null;
const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : "";
const constraint = "constraint" in candidate && typeof candidate.constraint === "string" ? candidate.constraint : null;
if (code !== "23505") continue;
if (constraint === "invites_token_hash_unique_idx") return true;
if (message.includes("invites_token_hash_unique_idx")) return true;
}
return false;
};
const inviteState = (invite: typeof invites.$inferSelect) => {
if (invite.revokedAt) return "revoked" as const;
if (invite.acceptedAt) return "accepted" as const;
if (invite.expiresAt <= new Date()) return "expired" as const;
return "active" as const;
};
const redactInvite = (invite: typeof invites.$inferSelect) => {
const { tokenHash: _tokenHash, defaultsPayload, ...safeInvite } = invite;
return {
...safeInvite,
allowedJoinTypes: safeInvite.allowedJoinTypes as InviteJoinType,
defaultsPayload: defaultsPayload && typeof defaultsPayload === "object"
? sanitizeRecord(defaultsPayload)
: defaultsPayload ?? null,
state: inviteState(invite),
};
};
const inviteStateWhereClause = (state: unknown) => {
const now = new Date();
switch (state) {
case "active":
return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), gt(invites.expiresAt, now));
case "accepted":
return isNotNull(invites.acceptedAt);
case "expired":
return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), lte(invites.expiresAt, now));
case "revoked":
return isNotNull(invites.revokedAt);
default:
return undefined;
}
};
const mergeInviteDefaults = (defaultsPayload: Record<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 {
config: {
async get() {
@@ -1993,6 +2203,337 @@ export function buildHostServices(
},
},
access: {
async listMembers(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const rows = await access.listMembers(companyId);
const visibleRows = params.includeArchived ? rows : rows.filter((row) => row.status !== "archived");
const grants = await db
.select()
.from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId));
const grantsByPrincipal = new Map<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: {
async create(params) {
const companyId = ensureCompanyId(params.companyId);
+116 -6
View File
@@ -19,6 +19,7 @@
*/
import { fork, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
@@ -39,9 +40,12 @@ import {
} from "@paperclipai/plugin-sdk";
import type {
JsonRpcId,
PluginInvocationContext,
PluginInvocationScope,
JsonRpcResponse,
JsonRpcRequest,
JsonRpcNotification,
WorkerHostCallContext,
HostToWorkerMethodName,
HostToWorkerMethods,
WorkerToHostMethodName,
@@ -108,6 +112,7 @@ export type WorkerStatus =
*/
export type WorkerToHostHandler<M extends WorkerToHostMethodName> = (
params: WorkerToHostMethods[M][0],
context?: WorkerHostCallContext,
) => Promise<WorkerToHostMethods[M][1]>;
/**
@@ -201,6 +206,11 @@ interface PendingRequest {
sentAt: number;
}
interface ActiveInvocation {
scope: PluginInvocationScope;
timer?: ReturnType<typeof setTimeout>;
}
// ---------------------------------------------------------------------------
// PluginWorkerHandle — manages a single worker process
// ---------------------------------------------------------------------------
@@ -379,6 +389,7 @@ export function createPluginWorkerHandle(
// Pending RPC requests awaiting a response
const pendingRequests = new Map<string | number, PendingRequest>();
let nextRequestId = 1;
const activeInvocations = new Map<string, ActiveInvocation>();
// Optional methods reported by the worker during initialization
let supportedMethods: string[] = [];
@@ -475,13 +486,78 @@ export function createPluginWorkerHandle(
pending.resolve(response);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isRecord(value: unknown): value is Record<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).
*/
async function handleWorkerRequest(request: JsonRpcRequest): Promise<void> {
const method = request.method as WorkerToHostMethodName;
const handler = options.hostHandlers[method] as
| ((params: unknown) => Promise<unknown>)
| ((params: unknown, context?: WorkerHostCallContext) => Promise<unknown>)
| undefined;
if (!handler) {
@@ -501,7 +577,7 @@ export function createPluginWorkerHandle(
}
try {
const result = await handler(request.params);
const result = await handler(request.params, contextForWorkerMessage(request));
sendMessage({
jsonrpc: JSONRPC_VERSION,
id: request.id,
@@ -509,12 +585,15 @@ export function createPluginWorkerHandle(
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
const errorCode = typeof (err as { code?: unknown }).code === "number"
? (err as { code: number }).code
: JSONRPC_ERROR_CODES.INTERNAL_ERROR;
log.error({ method, err: errorMessage }, "host handler error");
try {
sendMessage(
createErrorResponse(
request.id,
JSONRPC_ERROR_CODES.INTERNAL_ERROR,
errorCode,
errorMessage,
),
);
@@ -572,12 +651,28 @@ export function createPluginWorkerHandle(
notification.method === "streams.close"
) {
const params = (notification.params ?? {}) as Record<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
if (notification.method === "streams.open") {
const ch = String(params.channel ?? "");
const co = String(params.companyId ?? "");
if (ch) openStreamChannels.set(ch, co);
if (ch) openStreamChannels.set(ch, companyId);
} else if (notification.method === "streams.close") {
openStreamChannels.delete(String(params.channel ?? ""));
}
@@ -760,6 +855,10 @@ export function createPluginWorkerHandle(
);
}
pendingRequests.clear();
for (const invocation of activeInvocations.values()) {
if (invocation.timer) clearTimeout(invocation.timer);
}
activeInvocations.clear();
}
// -----------------------------------------------------------------------
@@ -1020,6 +1119,8 @@ export function createPluginWorkerHandle(
const id = nextRequestId++;
const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS);
const invocationScope = deriveInvocationScope(method, params);
const invocation = invocationScope ? registerInvocation(invocationScope) : null;
// Guard against double-settlement. When a process exits all pending
// requests are rejected via rejectAllPending(), but the timeout timer
@@ -1032,6 +1133,7 @@ export function createPluginWorkerHandle(
settled = true;
clearTimeout(timer);
pendingRequests.delete(id);
clearInvocation(invocation);
fn(value);
};
@@ -1064,11 +1166,15 @@ export function createPluginWorkerHandle(
pendingRequests.set(id, pending);
try {
const request = createRequest(method, params, id);
const request = {
...createRequest(method, params, id),
...(invocation ? { paperclipInvocation: invocation } : {}),
};
sendMessage(request);
} catch (err) {
clearTimeout(timer);
pendingRequests.delete(id);
clearInvocation(invocation);
reject(
new Error(
`Failed to send "${method}" to worker: ${
@@ -1135,13 +1241,17 @@ export function createPluginWorkerHandle(
notify(method: string, params: unknown) {
if (status !== "running") return;
const invocationScope = deriveInvocationScope(method, params);
const invocation = invocationScope ? registerInvocation(invocationScope, MAX_RPC_TIMEOUT_MS) : null;
try {
sendMessage({
jsonrpc: JSONRPC_VERSION,
method,
params,
...(invocation ? { paperclipInvocation: invocation } : {}),
});
} catch {
clearInvocation(invocation);
log.warn({ method }, "failed to send notification to worker");
}
},
@@ -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,
};
}
+5 -2
View File
@@ -30,7 +30,8 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
import { CompanyAccess } from "./pages/CompanyAccess";
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills";
import { Secrets } from "./pages/Secrets";
@@ -69,11 +70,13 @@ function boardRoutes() {
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<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/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="company/settings/secrets" element={<Secrets />} />
<Route path="company/settings/:settingsRoutePath/*" element={<CompanySettingsPluginPage />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
@@ -10,6 +10,7 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({
@@ -61,6 +62,10 @@ vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi,
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -83,6 +88,11 @@ describe("CompanySettingsSidebar", () => {
failedRuns: 0,
joinRequests: 2,
});
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
});
afterEach(() => {
@@ -110,7 +120,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Members");
expect(container.textContent).toContain("Invites");
expect(container.textContent).toContain("Secrets");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
@@ -129,8 +139,8 @@ describe("CompanySettingsSidebar", () => {
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/access",
label: "Access",
to: "/company/settings/members",
label: "Members",
badge: 2,
end: true,
}),
@@ -154,4 +164,50 @@ describe("CompanySettingsSidebar", () => {
root.unmount();
});
});
it("renders company settings pages contributed by ready plugins", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
exportName: "PermissionsPage",
routePath: "permissions",
pluginId: "plugin-1",
pluginKey: "permissions-extension",
pluginDisplayName: "Permissions Extension",
pluginVersion: "0.1.0",
},
],
isLoading: false,
errorMessage: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<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();
});
});
});
+21 -4
View File
@@ -1,16 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
import { ApiError } from "@/api/client";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem";
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const { slots: companySettingsPluginSlots } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const { data: badges } = useQuery({
queryKey: selectedCompanyId
? queryKeys.sidebarBadges(selectedCompanyId)
@@ -61,12 +67,23 @@ export function CompanySettingsSidebar() {
end
/>
<SidebarNavItem
to="/company/settings/access"
label="Access"
icon={Shield}
to="/company/settings/members"
label="Members"
icon={Users}
badge={badges?.joinRequests ?? 0}
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/secrets" label="Secrets" icon={KeyRound} end />
</div>
+101 -3
View File
@@ -3,10 +3,14 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { ToastProvider } from "../context/ToastContext";
const retryNowMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<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
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | 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(() => {
if (root) {
@@ -27,13 +77,22 @@ afterEach(() => {
root = null;
container?.remove();
container = null;
dateNowSpy?.mockRestore();
dateNowSpy = null;
});
function withProviders(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
return (
<MemoryRouter>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
<QueryClientProvider client={client}>
<ToastProvider>{node}</ToastProvider>
</QueryClientProvider>
</MemoryRouter>
);
}
@@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => {
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Retry now");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
it("shows retry-now action for next-step notices with a scheduled retry", async () => {
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
const node = render(
<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", () => {
const node = render(
<IssueBlockedNotice
+86 -1
View File
@@ -2,12 +2,17 @@ import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
IssueScheduledRetry,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { AlertTriangle, Flag } from "lucide-react";
import { AlertTriangle, CheckCircle2, Flag, Loader2, RotateCcw } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatMonitorOffset } from "../lib/issue-monitor";
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { RetryErrorBand } from "./IssueScheduledRetryCard";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
import {
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({
issueId,
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
scheduledRetry,
agentName,
}: {
issueId?: string | null;
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
agentName?: string | null;
}) {
if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const successfulRunRetryNow = showSuccessfulRunHandoff
&& issueId
&& scheduledRetry?.status === "scheduled_retry"
? { issueId, scheduledRetry }
: null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -162,6 +241,12 @@ export function IssueBlockedNotice({
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
{successfulRunRetryNow ? (
<SuccessfulRunRetryNowControl
issueId={successfulRunRetryNow.issueId}
scheduledRetry={successfulRunRetryNow.scheduledRetry}
/>
) : null}
</>
) : null}
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
+7
View File
@@ -38,6 +38,7 @@ import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
IssueScheduledRetry,
SuccessfulRunHandoffState,
IssueWorkMode,
} from "@paperclipai/shared";
@@ -296,9 +297,11 @@ interface IssueChatThreadProps {
timelineEvents?: IssueTimelineEvent[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
issueId?: string | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
recoveryAction?: IssueRecoveryAction | null;
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
@@ -3617,9 +3620,11 @@ export function IssueChatThread({
timelineEvents = [],
liveRuns = [],
activeRun = null,
issueId = null,
blockedBy = [],
blockerAttention = null,
successfulRunHandoff = null,
scheduledRetry = null,
recoveryAction = null,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction = false,
@@ -4299,10 +4304,12 @@ export function IssueChatThread({
/>
) : null}
<IssueBlockedNotice
issueId={issueId}
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
scheduledRetry={scheduledRetry}
agentName={
successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
@@ -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");
});
});
+45 -20
View File
@@ -1,6 +1,6 @@
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, Copy, ExternalLink, Github } from "lucide-react";
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
@@ -364,6 +364,7 @@ function CodeBlock({
}) {
const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const [wrapLines, setWrapLines] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -401,33 +402,57 @@ function CodeBlock({
}, 1500);
}, [children]);
const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines";
return (
<div className="paperclip-markdown-codeblock">
<div className="paperclip-markdown-codeblock" data-wrap-lines={wrapLines || undefined}>
<pre
{...preProps}
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}
</pre>
<button
type="button"
onClick={handleCopy}
aria-label="Copy code"
title={label}
className="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-copy-label">{label}</span>
</button>
<div className="paperclip-markdown-codeblock-actions">
<button
type="button"
onClick={() => setWrapLines((value) => !value)}
aria-label={wrapLabel}
title={wrapLabel}
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
aria-pressed={wrapLines}
data-active={wrapLines || undefined}
>
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
<span className="paperclip-markdown-codeblock-action-label">{wrapLabel}</span>
</button>
<button
type="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>
);
}
@@ -60,27 +60,29 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
expect(getCompanySettingsTab("/company/settings/access")).toBe("members");
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members");
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
});
it("renders the active tab and navigates when a different tab is selected", async () => {
currentPathname = "/PAP/company/settings/access";
currentPathname = "/PAP/company/settings/members";
const root = createRoot(container);
await act(async () => {
root.render(<CompanySettingsNav />);
});
expect(container.textContent).toContain("access");
expect(container.textContent).toContain("members");
expect(pageTabBarMock).toHaveBeenCalledWith(
expect.objectContaining({
value: "access",
value: "members",
items: [
{ value: "general", label: "General" },
{ value: "environments", label: "Environments" },
{ value: "access", label: "Access" },
{ value: "members", label: "Members" },
{ value: "invites", label: "Invites" },
],
}),
@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router";
const items = [
{ value: "general", label: "General", href: "/company/settings" },
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
{ value: "access", label: "Access", href: "/company/settings/access" },
{ value: "members", label: "Members", href: "/company/settings/members" },
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
] as const;
@@ -16,8 +16,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
return "environments";
}
if (pathname.includes("/company/settings/access")) {
return "access";
if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) {
return "members";
}
if (pathname.includes("/company/settings/invites")) {
@@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
@@ -279,8 +280,9 @@ export function useLiveRunTranscripts({
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(companyId)}/events/ws`,
);
socket = new WebSocket(url);
socket.onmessage = (message) => {
+4 -2
View File
@@ -14,6 +14,7 @@ import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-iss
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
const TOAST_COOLDOWN_MAX = 3;
@@ -979,8 +980,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`,
);
const nextSocket = new WebSocket(url);
socket = nextSocket;
+20 -11
View File
@@ -717,15 +717,23 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
background: none;
}
/* Copy-to-clipboard button on fenced code blocks */
/* Actions for fenced and indented preformatted markdown blocks */
.paperclip-markdown-codeblock {
position: relative;
}
.paperclip-markdown-codeblock-copy {
.paperclip-markdown-codeblock-actions {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.12s ease;
}
.paperclip-markdown-codeblock-action {
display: inline-flex;
align-items: center;
gap: 0.25rem;
@@ -737,30 +745,31 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease;
transition: background-color 0.12s ease, color 0.12s ease;
}
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy,
.paperclip-markdown-codeblock-copy:focus-visible,
.paperclip-markdown-codeblock-copy[data-copied] {
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
.paperclip-markdown-codeblock-actions:focus-within,
.paperclip-markdown-codeblock-action[data-copied],
.paperclip-markdown-codeblock-action[data-active] {
opacity: 1;
}
.paperclip-markdown-codeblock-copy:hover {
.paperclip-markdown-codeblock-action:hover {
background-color: var(--accent);
color: var(--accent-foreground);
}
.paperclip-markdown-codeblock-copy[data-copied] {
.paperclip-markdown-codeblock-action[data-active],
.paperclip-markdown-codeblock-action[data-copied] {
color: var(--primary);
}
.paperclip-markdown-codeblock-copy[data-failed] {
.paperclip-markdown-codeblock-action[data-failed] {
color: var(--destructive);
}
.paperclip-markdown-codeblock-copy-label {
.paperclip-markdown-codeblock-action-label {
font-weight: 500;
}
+51
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");
});
});
+20
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}`;
}
+7 -3
View File
@@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
@@ -1713,7 +1714,9 @@ function ConfigurationTab({
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant."
: "Disabled unless explicitly granted.";
: taskAssignSource === "simple_default"
? "Enabled by simple company-wide task assignment defaults."
: "Disabled unless explicitly granted.";
return (
<div className="space-y-6">
@@ -3863,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
);
socket = new WebSocket(url);
socket.onopen = () => {
+99 -17
View File
@@ -4,23 +4,25 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanyAccess } from "./CompanyAccess";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess";
const listMembersMock = vi.hoisted(() => vi.fn());
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
const updateMemberAccessMock = vi.hoisted(() => vi.fn());
const updateMemberMock = vi.hoisted(() => vi.fn());
const archiveMemberMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const listIssuesMock = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/access", () => ({
accessApi: {
listMembers: (companyId: string) => listMembersMock(companyId),
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status),
updateMember: vi.fn(),
updateMember: (companyId: string, memberId: string, input: unknown) =>
updateMemberMock(companyId, memberId, input),
updateMemberPermissions: vi.fn(),
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
updateMemberAccessMock(companyId, memberId, input),
updateMemberAccess: vi.fn(),
archiveMember: (companyId: string, memberId: string, input: unknown) =>
archiveMemberMock(companyId, memberId, input),
approveJoinRequest: vi.fn(),
@@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({
},
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <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", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
@@ -146,7 +160,7 @@ describe("CompanyAccess", () => {
},
},
]);
updateMemberAccessMock.mockResolvedValue({});
updateMemberMock.mockResolvedValue({});
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
listAgentsMock.mockResolvedValue([
{
@@ -164,6 +178,11 @@ describe("CompanyAccess", () => {
status: "todo",
},
]);
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
});
afterEach(() => {
@@ -172,7 +191,7 @@ describe("CompanyAccess", () => {
vi.clearAllMocks();
});
it("keeps the page human-focused and explains implicit versus explicit grants", async () => {
it("keeps the page human-focused and hides advanced permission controls", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -188,10 +207,15 @@ describe("CompanyAccess", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("Manage company user memberships");
expect(container.textContent).toContain("Manage the people who can work in Paperclip");
expect(container.textContent).toContain("Members can collaborate across the company by default");
expect(container.textContent).toContain("Core keeps this page focused on membership");
expect(container.textContent).toContain("Humans");
expect(container.textContent).toContain("Pending human joins");
expect(container.textContent).toContain("User account");
expect(container.textContent).not.toContain("Grants");
expect(container.textContent).not.toContain("explicit grants");
expect(container.textContent).not.toContain("Assign scoped tasks");
expect(container.textContent).not.toContain("Agents");
expect(container.textContent).not.toContain("Pending agent joins");
expect(container.textContent).not.toContain("Open join request queue");
@@ -210,18 +234,16 @@ describe("CompanyAccess", () => {
});
await flushReact();
expect(document.body.textContent).toContain("Implicit grants from role");
expect(document.body.textContent).toContain("Owner currently includes these permissions automatically.");
expect(document.body.textContent).toContain(
"Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.",
);
expect(document.body.textContent).toContain("Update company role and membership status");
expect(document.body.textContent).not.toContain("Implicit grants from role");
expect(document.body.textContent).not.toContain("permissionKey");
await act(async () => {
root.unmount();
});
});
it("saves member role, status, and grants in one request", async () => {
it("saves member role and status without touching grants", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -248,7 +270,7 @@ describe("CompanyAccess", () => {
await flushReact();
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "Save access",
(button) => button.textContent === "Save member",
);
expect(saveButton).toBeTruthy();
@@ -257,10 +279,9 @@ describe("CompanyAccess", () => {
});
await flushReact();
expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", {
expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
membershipRole: "owner",
status: "active",
grants: [],
});
await act(async () => {
@@ -382,4 +403,65 @@ describe("CompanyAccess", () => {
root.unmount();
});
});
it("redirects legacy access deep links to the permissions extension route when installed", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
routePath: "permissions",
pluginKey: "permissions-extension",
},
],
isLoading: false,
errorMessage: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<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();
});
});
});
+76 -109
View File
@@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
PERMISSION_KEYS,
type Agent,
type PermissionKey,
} from "@paperclipai/shared";
import { ShieldCheck, Trash2, Users } from "lucide-react";
import { Shield, ShieldCheck, Trash2, Users } from "lucide-react";
import { accessApi, type CompanyMember } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -25,38 +22,13 @@ import { Badge } from "@/components/ui/badge";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { Link, Navigate } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
const permissionLabels: Record<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: [],
};
import { usePluginSlots } from "@/plugins/slots";
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
type EditableMemberStatus = "pending" | "active" | "suspended";
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
return role ? implicitRoleGrantMap[role] : [];
}
export function CompanyAccess() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -67,13 +39,12 @@ export function CompanyAccess() {
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Access" },
{ label: "Members" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
@@ -103,11 +74,10 @@ export function CompanyAccess() {
};
const updateMemberMutation = useMutation({
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => {
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => {
return accessApi.updateMember(selectedCompanyId!, input.memberId, {
membershipRole: input.membershipRole,
status: input.status,
grants: input.grants.map((permissionKey) => ({ permissionKey })),
});
},
onSuccess: async () => {
@@ -223,7 +193,6 @@ export function CompanyAccess() {
if (!editingMember) return;
setDraftRole(editingMember.membershipRole);
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
}, [editingMember]);
useEffect(() => {
@@ -255,8 +224,6 @@ export function CompanyAccess() {
joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? [];
const joinRequestActionPending =
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
const implicitGrantSet = new Set(implicitGrantKeys);
const activeReassignmentUsers = members.filter(
(member) =>
member.status === "active" &&
@@ -271,11 +238,14 @@ export function CompanyAccess() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<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>
<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>
<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>
{access && !access.currentUserRole && (
@@ -291,7 +261,7 @@ export function CompanyAccess() {
<h2 className="text-base font-semibold">Humans</h2>
</div>
<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>
</div>
@@ -340,11 +310,10 @@ export function CompanyAccess() {
) : null}
<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>Role</div>
<div>Status</div>
<div>Grants</div>
<div className="text-right">Action</div>
</div>
{members.length === 0 ? (
@@ -356,7 +325,7 @@ export function CompanyAccess() {
return (
<div
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="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
@@ -372,7 +341,6 @@ export function CompanyAccess() {
{member.status.replace("_", " ")}
</Badge>
</div>
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
<div className="space-y-1 text-right">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
@@ -405,7 +373,7 @@ export function CompanyAccess() {
<DialogHeader>
<DialogTitle>Edit member</DialogTitle>
<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>
</DialogHeader>
{editingMember && (
@@ -443,66 +411,6 @@ export function CompanyAccess() {
</select>
</label>
</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>
)}
<DialogFooter>
@@ -516,12 +424,11 @@ export function CompanyAccess() {
memberId: editingMember.id,
membershipRole: draftRole,
status: draftStatus,
grants: [...draftGrants],
});
}}
disabled={updateMemberMutation.isPending}
>
{updateMemberMutation.isPending ? "Saving…" : "Save access"}
{updateMemberMutation.isPending ? "Saving…" : "Save member"}
</Button>
</DialogFooter>
</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) {
if (!member) return "this member";
return member.user?.name?.trim() || member.user?.email || member.principalId;
+2 -1
View File
@@ -152,7 +152,8 @@ describe("CompanyInvites", () => {
expect(container.textContent).toContain("Choose a role");
expect(container.textContent).toContain("Each invite link is single-use.");
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants.");
expect(container.textContent).toContain("Everything in Admin, plus managing members.");
expect(container.textContent).not.toContain("permission grants");
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
+4 -4
View File
@@ -14,8 +14,8 @@ const inviteRoleOptions = [
{
value: "viewer",
label: "Viewer",
description: "Can view company work and follow along without operational permissions.",
gets: "No built-in grants.",
description: "Can view company work and follow along.",
gets: "View-only company membership.",
},
{
value: "operator",
@@ -32,8 +32,8 @@ const inviteRoleOptions = [
{
value: "owner",
label: "Owner",
description: "Full company access, including membership and permission management.",
gets: "Everything in Admin, plus managing members and permission grants.",
description: "Full company access, including membership management.",
gets: "Everything in Admin, plus managing members.",
},
] as const;
@@ -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();
});
});
});
@@ -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"
/>
);
}
+5 -5
View File
@@ -403,16 +403,16 @@ describe("InviteLandingPage", () => {
expect(container.textContent).toContain("Request to join Acme Robotics");
expect(container.textContent).toContain("A company admin must approve your request to join.");
expect(container.textContent).toContain(
"Ask them to visit Company Settings → Access to approve your request.",
"Ask them to visit Company Settings → Members to approve your request.",
);
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
expect(container.textContent).not.toContain("http://localhost/company/settings/access");
expect(container.textContent).not.toContain("http://localhost/company/settings/members");
const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
(link) => link.textContent === "Company Settings → Access",
(link) => link.textContent === "Company Settings → Members",
);
expect(approvalLinks).toHaveLength(2);
const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
const expectedApprovalUrl = `${window.location.origin}/company/settings/members`;
for (const link of approvalLinks) {
expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
}
@@ -471,7 +471,7 @@ describe("InviteLandingPage", () => {
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
expect(container.textContent).toContain("Your request is still awaiting approval.");
expect(container.textContent).toContain(
"Ask them to visit Company Settings → Access to approve your request.",
"Ask them to visit Company Settings → Members to approve your request.",
);
await act(async () => {
+3 -3
View File
@@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({
claimApiKeyPath = null,
onboardingTextUrl = null,
}: AwaitingJoinApprovalPanelProps) {
const approvalUrl = `${window.location.origin}/company/settings/access`;
const approvalUrl = `${window.location.origin}/company/settings/members`;
const approverLabel = invitedByUserName ?? "A company admin";
return (
@@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({
href={approvalUrl}
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
>
Company Settings Access
Company Settings Members
</a>
</div>
<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 className="text-xs text-zinc-500">
Refresh this page after you've been approved — you'll be redirected automatically.
+7 -7
View File
@@ -23,8 +23,8 @@ const inviteRoleOptions = [
{
value: "viewer",
label: "Viewer",
description: "Can view company work and follow along without operational permissions.",
gets: "No built-in grants.",
description: "Can view company work and follow along.",
gets: "View-only company membership.",
},
{
value: "operator",
@@ -41,8 +41,8 @@ const inviteRoleOptions = [
{
value: "owner",
label: "Owner",
description: "Full company access, including membership and permission management.",
gets: "Everything in Admin, plus managing members and permission grants.",
description: "Full company access, including membership management.",
gets: "Everything in Admin, plus managing members.",
},
] as const;
@@ -423,8 +423,8 @@ function InviteResultPreview({
<>
<div className="border border-zinc-800 p-3">
<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">
Company Settings Access
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/members">
Company Settings Members
</a>
</div>
<p className="text-xs text-zinc-500">
@@ -897,7 +897,7 @@ export function InviteUxLab() {
/>
<InviteResultPreview
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>
</LabSection>

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