mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
Merge master into board UI polish PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
8355331a40
143 files changed
+13547
-451
No files matched your search
@@ -226,6 +226,21 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
|
||||
|
||||
<br/>
|
||||
|
||||
## Paperclip Cloud Sync
|
||||
|
||||
Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing.
|
||||
|
||||
```bash
|
||||
paperclipai cloud connect https://your-stack.paperclip.app
|
||||
paperclipai cloud connect https://your-stack.paperclip.app --no-browser
|
||||
paperclipai cloud push --company <local-company-id> --dry-run
|
||||
paperclipai cloud push --company <local-company-id>
|
||||
```
|
||||
|
||||
`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code.
|
||||
|
||||
`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CompanyPortabilityExportResult } from "@paperclipai/shared";
|
||||
import {
|
||||
assertDiscoveryCompatible,
|
||||
buildBundleFromLocalCompany,
|
||||
cloudCommandExitCodes,
|
||||
connectCloud,
|
||||
resolveDeviceCodeExpiresAt,
|
||||
} from "../commands/client/cloud.js";
|
||||
import {
|
||||
LocalUpstreamPushCoordinator,
|
||||
normalizedContentHash,
|
||||
type LocalUpstreamExportBundle,
|
||||
} from "../commands/client/cloud-transfer.js";
|
||||
import { getCloudConnection } from "../commands/client/cloud-store.js";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
describe("cloud CLI helpers", () => {
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-"));
|
||||
process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("connects with the device-code flow and stores the resulting cloud connection", async () => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
const requestUrl = String(url);
|
||||
if (requestUrl.endsWith("/.well-known/paperclip-upstream")) {
|
||||
return jsonResponse(discovery());
|
||||
}
|
||||
if (requestUrl.endsWith("/api/upstream-sync/device-code")) {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
stackId: "stack-1",
|
||||
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
|
||||
});
|
||||
return jsonResponse({
|
||||
deviceCode: "device-1",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
intervalSeconds: 0,
|
||||
});
|
||||
}
|
||||
if (requestUrl.endsWith("/api/upstream-sync/token")) {
|
||||
return jsonResponse({
|
||||
accessToken: "upt_test",
|
||||
scopes: ["upstream_import:preview"],
|
||||
token: {
|
||||
id: "token-1",
|
||||
companyStackId: "stack-1",
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
sourceInstanceId: "paperclip-local-default",
|
||||
sourceInstanceFingerprint: "sha256:test",
|
||||
scopes: ["upstream_import:preview"],
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({ error: "not_found" }, 404);
|
||||
}) as typeof fetch;
|
||||
|
||||
const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true });
|
||||
|
||||
expect(connection.accessToken).toBe("upt_test");
|
||||
expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1");
|
||||
});
|
||||
|
||||
it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => {
|
||||
expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i);
|
||||
expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
|
||||
});
|
||||
|
||||
it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => {
|
||||
const now = Date.UTC(2026, 4, 22, 13, 0, 0);
|
||||
const validExpiry = "2026-05-22T13:05:00.000Z";
|
||||
|
||||
expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry));
|
||||
expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000);
|
||||
expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000);
|
||||
});
|
||||
|
||||
it("builds deterministic chunks with validated payload hashes", async () => {
|
||||
const bundle = await buildTestBundle();
|
||||
|
||||
expect(bundle.chunks).toHaveLength(2);
|
||||
expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload));
|
||||
expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash);
|
||||
expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey);
|
||||
});
|
||||
|
||||
it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => {
|
||||
const bundle = await buildTestBundle();
|
||||
const calls: Array<{ path: string; body: unknown }> = [];
|
||||
const coordinator = new LocalUpstreamPushCoordinator({
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
paperclipCompanyId: "target-company-1",
|
||||
fetch: async (url, init) => {
|
||||
const parsed = new URL(String(url));
|
||||
const body = init?.body ? JSON.parse(String(init.body)) as unknown : {};
|
||||
calls.push({ path: parsed.pathname, body });
|
||||
if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } });
|
||||
return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } });
|
||||
},
|
||||
});
|
||||
|
||||
await coordinator.apply(bundle);
|
||||
await coordinator.apply(bundle);
|
||||
|
||||
const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } });
|
||||
const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string });
|
||||
expect(runBodies).toHaveLength(2);
|
||||
expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey);
|
||||
expect(chunkBodies[0]).toEqual(chunkBodies[2]);
|
||||
expect(chunkBodies[1]).toEqual(chunkBodies[3]);
|
||||
});
|
||||
});
|
||||
|
||||
async function buildTestBundle(): Promise<LocalUpstreamExportBundle> {
|
||||
return buildBundleFromLocalCompany({
|
||||
localCompanyId: "local-company-1",
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
remoteUrl: "https://cloud.example.test",
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
targetHost: "cloud.example.test",
|
||||
stackId: "stack-1",
|
||||
targetCompanyId: "target-company-1",
|
||||
accessToken: "upt_test",
|
||||
token: {
|
||||
id: "token-1",
|
||||
companyStackId: "stack-1",
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
sourceInstanceId: "paperclip-local-default",
|
||||
sourceInstanceFingerprint: "sha256:test",
|
||||
scopes: ["upstream_import:preview"],
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
privateKeyPem: "unused",
|
||||
sourcePublicKey: "unused",
|
||||
sourceInstanceId: "paperclip-local-default",
|
||||
sourceInstanceFingerprint: "sha256:test",
|
||||
scopes: ["upstream_import:preview"],
|
||||
createdAt: "2026-05-18T00:00:00.000Z",
|
||||
updatedAt: "2026-05-18T00:00:00.000Z",
|
||||
},
|
||||
discovery: discovery(),
|
||||
localApi: {
|
||||
post: async <T>() => portabilityExport() as T,
|
||||
},
|
||||
maxEntitiesPerChunk: 1,
|
||||
mode: "apply",
|
||||
});
|
||||
}
|
||||
|
||||
function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) {
|
||||
return {
|
||||
schema: "paperclip-upstream-discovery-v1",
|
||||
stack: {
|
||||
id: "stack-1",
|
||||
slug: "cloud-test",
|
||||
displayName: "Cloud Test",
|
||||
companyId: "target-company-1",
|
||||
origin: "https://cloud.example.test",
|
||||
},
|
||||
auth: {
|
||||
deviceCode: {
|
||||
deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code",
|
||||
verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve",
|
||||
tokenUrl: "https://cloud.example.test/api/upstream-sync/token",
|
||||
},
|
||||
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
|
||||
},
|
||||
transfer: {
|
||||
supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1,
|
||||
featureFlags: ["cloud_sync"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function portabilityExport(): CompanyPortabilityExportResult {
|
||||
return {
|
||||
rootPath: ".",
|
||||
paperclipExtensionPath: ".paperclip.yaml",
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-05-18T00:00:00.000Z",
|
||||
source: {
|
||||
companyId: "local-company-1",
|
||||
companyName: "Local Company",
|
||||
},
|
||||
includes: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
company: {
|
||||
path: "company.json",
|
||||
name: "Local Company",
|
||||
description: null,
|
||||
brandColor: null,
|
||||
logoPath: null,
|
||||
attachmentMaxBytes: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
},
|
||||
sidebar: null,
|
||||
agents: [],
|
||||
skills: [],
|
||||
projects: [],
|
||||
issues: [],
|
||||
envInputs: [],
|
||||
},
|
||||
files: {
|
||||
"README.md": "Local Company",
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolvePaperclipInstanceRoot } from "../../config/home.js";
|
||||
|
||||
export interface CloudConnectionTokenRecord {
|
||||
id: string;
|
||||
companyStackId: string;
|
||||
targetOrigin: string;
|
||||
sourceInstanceId: string;
|
||||
sourceInstanceFingerprint: string;
|
||||
scopes: string[];
|
||||
expiresAt: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudConnection {
|
||||
id: string;
|
||||
remoteUrl: string;
|
||||
targetOrigin: string;
|
||||
targetHost: string;
|
||||
stackId: string;
|
||||
stackSlug?: string | null;
|
||||
stackDisplayName?: string | null;
|
||||
targetCompanyId: string;
|
||||
accessToken: string;
|
||||
token: CloudConnectionTokenRecord;
|
||||
privateKeyPem: string;
|
||||
sourcePublicKey: string;
|
||||
sourceInstanceId: string;
|
||||
sourceInstanceFingerprint: string;
|
||||
scopes: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CloudConnectionStore {
|
||||
version: 1;
|
||||
connections: Record<string, CloudConnection>;
|
||||
currentConnectionId?: string;
|
||||
}
|
||||
|
||||
function defaultStore(): CloudConnectionStore {
|
||||
return {
|
||||
version: 1,
|
||||
connections: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCloudConnectionStorePath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json");
|
||||
}
|
||||
|
||||
export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore {
|
||||
if (!fs.existsSync(storePath)) return defaultStore();
|
||||
const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial<CloudConnectionStore> | null;
|
||||
const connections: Record<string, CloudConnection> = {};
|
||||
if (raw?.connections && typeof raw.connections === "object") {
|
||||
for (const [id, value] of Object.entries(raw.connections)) {
|
||||
const normalized = normalizeConnection(value);
|
||||
if (normalized) connections[id] = normalized;
|
||||
}
|
||||
}
|
||||
const currentConnectionId =
|
||||
typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId]
|
||||
? raw.currentConnectionId
|
||||
: Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id;
|
||||
return {
|
||||
version: 1,
|
||||
connections,
|
||||
currentConnectionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeCloudConnectionStore(
|
||||
store: CloudConnectionStore,
|
||||
storePath = resolveCloudConnectionStorePath(),
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
||||
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function upsertCloudConnection(
|
||||
connection: CloudConnection,
|
||||
storePath = resolveCloudConnectionStorePath(),
|
||||
): CloudConnection {
|
||||
const store = readCloudConnectionStore(storePath);
|
||||
const existing = store.connections[connection.id];
|
||||
const now = new Date().toISOString();
|
||||
const next = {
|
||||
...connection,
|
||||
createdAt: existing?.createdAt ?? connection.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
store.connections[next.id] = next;
|
||||
store.currentConnectionId = next.id;
|
||||
writeCloudConnectionStore(store, storePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getCloudConnection(
|
||||
remoteUrlOrOrigin?: string,
|
||||
storePath = resolveCloudConnectionStorePath(),
|
||||
): CloudConnection | null {
|
||||
const store = readCloudConnectionStore(storePath);
|
||||
if (remoteUrlOrOrigin?.trim()) {
|
||||
const needle = normalizeRemoteLookup(remoteUrlOrOrigin);
|
||||
return Object.values(store.connections).find((connection) =>
|
||||
normalizeRemoteLookup(connection.remoteUrl) === needle ||
|
||||
normalizeRemoteLookup(connection.targetOrigin) === needle
|
||||
) ?? null;
|
||||
}
|
||||
return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null;
|
||||
}
|
||||
|
||||
function normalizeRemoteLookup(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.origin.replace(/\/+$/u, "");
|
||||
} catch {
|
||||
return value.trim().replace(/\/+$/u, "");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConnection(value: unknown): CloudConnection | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = stringValue(record.id);
|
||||
const remoteUrl = stringValue(record.remoteUrl);
|
||||
const targetOrigin = stringValue(record.targetOrigin);
|
||||
const targetHost = stringValue(record.targetHost);
|
||||
const stackId = stringValue(record.stackId);
|
||||
const targetCompanyId = stringValue(record.targetCompanyId);
|
||||
const accessToken = stringValue(record.accessToken);
|
||||
const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token)
|
||||
? record.token as CloudConnectionTokenRecord
|
||||
: null;
|
||||
const privateKeyPem = stringValue(record.privateKeyPem);
|
||||
const sourcePublicKey = stringValue(record.sourcePublicKey);
|
||||
const sourceInstanceId = stringValue(record.sourceInstanceId);
|
||||
const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint);
|
||||
const createdAt = stringValue(record.createdAt);
|
||||
const updatedAt = stringValue(record.updatedAt);
|
||||
if (
|
||||
!id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId ||
|
||||
!accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId ||
|
||||
!sourceInstanceFingerprint || !createdAt || !updatedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
remoteUrl,
|
||||
targetOrigin,
|
||||
targetHost,
|
||||
stackId,
|
||||
stackSlug: stringValue(record.stackSlug),
|
||||
stackDisplayName: stringValue(record.stackDisplayName),
|
||||
targetCompanyId,
|
||||
accessToken,
|
||||
token,
|
||||
privateKeyPem,
|
||||
sourcePublicKey,
|
||||
sourceInstanceId,
|
||||
sourceInstanceFingerprint,
|
||||
scopes: stringArray(record.scopes),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const upstreamTransferSchema = {
|
||||
family: "paperclip-upstream-transfer",
|
||||
version: "1.0.0",
|
||||
major: 1,
|
||||
minor: 0,
|
||||
} as const;
|
||||
|
||||
export type NormalizedSha256 = `sha256:${string}`;
|
||||
|
||||
export interface SourceEntityKey {
|
||||
sourceInstanceId: string;
|
||||
sourceCompanyId: string;
|
||||
sourceEntityType: string;
|
||||
sourceEntityId: string;
|
||||
sourceNaturalKey?: string;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferWarning {
|
||||
code: string;
|
||||
severity: "info" | "warning" | "blocker";
|
||||
message: string;
|
||||
entity?: SourceEntityKey;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferEntityRecord {
|
||||
key: SourceEntityKey;
|
||||
contentHash: NormalizedSha256;
|
||||
dependencies: SourceEntityKey[];
|
||||
warnings: UpstreamTransferWarning[];
|
||||
}
|
||||
|
||||
export interface UpstreamTransferManifestSource {
|
||||
sourceInstanceId: string;
|
||||
sourceCompanyId: string;
|
||||
sourceInstanceKeyFingerprint: string;
|
||||
exporterVersion: string;
|
||||
sourceSchemaVersion: string;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferManifestTarget {
|
||||
targetStackId: string;
|
||||
targetCompanyId: string;
|
||||
targetOrigin: string;
|
||||
supportedSchemaMajor: number;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferChunk {
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
byteLength: number;
|
||||
sha256: NormalizedSha256;
|
||||
manifestHash: NormalizedSha256;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferManifest {
|
||||
schema: typeof upstreamTransferSchema;
|
||||
source: UpstreamTransferManifestSource;
|
||||
target: UpstreamTransferManifestTarget;
|
||||
runId: string;
|
||||
idempotencyKey: string;
|
||||
generatedAt: string;
|
||||
entityCount: number;
|
||||
entities: UpstreamTransferEntityRecord[];
|
||||
chunks: UpstreamTransferChunk[];
|
||||
warnings: UpstreamTransferWarning[];
|
||||
featureFlags: string[];
|
||||
manifestHash: NormalizedSha256;
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportEntityInput {
|
||||
key: SourceEntityKey;
|
||||
body: Record<string, unknown>;
|
||||
dependencies?: SourceEntityKey[];
|
||||
warnings?: UpstreamTransferWarning[];
|
||||
conflictKeys?: string[];
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportEntity {
|
||||
record: UpstreamTransferEntityRecord;
|
||||
body: Record<string, unknown>;
|
||||
conflictKeys?: string[];
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportChunk {
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
byteLength: number;
|
||||
sha256: NormalizedSha256;
|
||||
payload: {
|
||||
entityKeys: SourceEntityKey[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportBundle {
|
||||
manifest: UpstreamTransferManifest;
|
||||
entities: LocalUpstreamExportEntity[];
|
||||
chunks: LocalUpstreamExportChunk[];
|
||||
}
|
||||
|
||||
export interface BuildLocalUpstreamExportBundleInput {
|
||||
source: UpstreamTransferManifestSource;
|
||||
target: UpstreamTransferManifestTarget;
|
||||
runId: string;
|
||||
idempotencyKey: string;
|
||||
entities: LocalUpstreamExportEntityInput[];
|
||||
warnings?: UpstreamTransferWarning[];
|
||||
featureFlags?: string[];
|
||||
maxEntitiesPerChunk?: number;
|
||||
}
|
||||
|
||||
export interface LocalUpstreamPushCoordinatorOptions {
|
||||
targetOrigin: string;
|
||||
paperclipCompanyId: string;
|
||||
fetch?: typeof fetch;
|
||||
headers?: (input: { method: string; path: string }) => HeadersInit | Promise<HeadersInit>;
|
||||
}
|
||||
|
||||
export class UpstreamImportRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(status: number, message: string, body: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalUpstreamPushCoordinator {
|
||||
readonly #targetOrigin: string;
|
||||
readonly #paperclipCompanyId: string;
|
||||
readonly #fetch: typeof fetch;
|
||||
readonly #headers: NonNullable<LocalUpstreamPushCoordinatorOptions["headers"]>;
|
||||
|
||||
constructor(options: LocalUpstreamPushCoordinatorOptions) {
|
||||
this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, "");
|
||||
this.#paperclipCompanyId = options.paperclipCompanyId;
|
||||
this.#fetch = options.fetch ?? fetch;
|
||||
this.#headers = options.headers ?? (() => ({}));
|
||||
}
|
||||
|
||||
async preview(bundle: LocalUpstreamExportBundle): Promise<unknown> {
|
||||
return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, {
|
||||
manifest: bundle.manifest,
|
||||
entities: bundle.entities,
|
||||
});
|
||||
}
|
||||
|
||||
async apply(bundle: LocalUpstreamExportBundle): Promise<unknown> {
|
||||
const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, {
|
||||
mode: "apply",
|
||||
manifest: bundle.manifest,
|
||||
entities: bundle.entities,
|
||||
}) as { run?: { id?: unknown } };
|
||||
const runId = typeof run.run?.id === "string" ? run.run.id : undefined;
|
||||
if (!runId) {
|
||||
throw new Error("Remote upstream importer did not return a run id");
|
||||
}
|
||||
|
||||
for (const chunk of bundle.chunks) {
|
||||
await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk);
|
||||
}
|
||||
|
||||
return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {});
|
||||
}
|
||||
|
||||
async events(runId: string): Promise<unknown> {
|
||||
return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<unknown> {
|
||||
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
|
||||
method: "GET",
|
||||
headers: await this.#headers({ method: "GET", path }),
|
||||
});
|
||||
return parseCoordinatorResponse(response);
|
||||
}
|
||||
|
||||
private async post(path: string, body: unknown): Promise<unknown> {
|
||||
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(await this.#headers({ method: "POST", path })),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseCoordinatorResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalUpstreamExportBundle(
|
||||
input: BuildLocalUpstreamExportBundleInput,
|
||||
): LocalUpstreamExportBundle {
|
||||
const entities = input.entities.map<LocalUpstreamExportEntity>((entity) => ({
|
||||
record: {
|
||||
key: entity.key,
|
||||
contentHash: normalizedContentHash(entity.body),
|
||||
dependencies: entity.dependencies ?? [],
|
||||
warnings: entity.warnings ?? [],
|
||||
},
|
||||
body: entity.body,
|
||||
conflictKeys: entity.conflictKeys,
|
||||
}));
|
||||
const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100);
|
||||
const manifestWithoutHash = {
|
||||
schema: upstreamTransferSchema,
|
||||
source: input.source,
|
||||
target: input.target,
|
||||
runId: input.runId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
entityCount: entities.length,
|
||||
entities: entities.map((entity) => entity.record),
|
||||
chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk),
|
||||
warnings: input.warnings ?? [],
|
||||
featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(),
|
||||
};
|
||||
const manifestHash = normalizedContentHash(manifestWithoutHash);
|
||||
return {
|
||||
manifest: {
|
||||
...manifestWithoutHash,
|
||||
chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })),
|
||||
manifestHash,
|
||||
},
|
||||
entities,
|
||||
chunks,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizedContentHash(value: unknown): NormalizedSha256 {
|
||||
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(sortJson(value));
|
||||
}
|
||||
|
||||
function buildLocalChunks(
|
||||
entities: LocalUpstreamExportEntity[],
|
||||
maxEntitiesPerChunk: number,
|
||||
): LocalUpstreamExportChunk[] {
|
||||
if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) {
|
||||
throw new Error("maxEntitiesPerChunk must be a positive integer");
|
||||
}
|
||||
if (entities.length === 0) return [];
|
||||
|
||||
const groups: LocalUpstreamExportEntity[][] = [];
|
||||
for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) {
|
||||
groups.push(entities.slice(index, index + maxEntitiesPerChunk));
|
||||
}
|
||||
|
||||
return groups.map((group, index) => {
|
||||
const payload = {
|
||||
entityKeys: group.map((entity) => entity.record.key),
|
||||
};
|
||||
return {
|
||||
chunkIndex: index,
|
||||
totalChunks: groups.length,
|
||||
byteLength: Buffer.byteLength(canonicalJson(payload)),
|
||||
sha256: normalizedContentHash(payload),
|
||||
payload,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sortJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortJson);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, sortJson(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
async function parseCoordinatorResponse(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
const parsed = text.trim() ? safeParseJson(text) : {};
|
||||
if (!response.ok) {
|
||||
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
|
||||
? String((parsed as { error: unknown }).error)
|
||||
: `Upstream importer request failed with ${response.status}`;
|
||||
throw new UpstreamImportRequestError(response.status, message, parsed);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function safeParseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { URL } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import type {
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityFileEntry,
|
||||
InstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import { openUrl } from "../../client/board-auth.js";
|
||||
import { resolvePaperclipInstanceId } from "../../config/home.js";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
import {
|
||||
buildLocalUpstreamExportBundle,
|
||||
LocalUpstreamPushCoordinator,
|
||||
normalizedContentHash,
|
||||
upstreamTransferSchema,
|
||||
UpstreamImportRequestError,
|
||||
type LocalUpstreamExportBundle,
|
||||
type LocalUpstreamExportEntityInput,
|
||||
type SourceEntityKey,
|
||||
type UpstreamTransferManifestSource,
|
||||
type UpstreamTransferManifestTarget,
|
||||
type UpstreamTransferWarning,
|
||||
} from "./cloud-transfer.js";
|
||||
import {
|
||||
getCloudConnection,
|
||||
upsertCloudConnection,
|
||||
type CloudConnection,
|
||||
type CloudConnectionTokenRecord,
|
||||
} from "./cloud-store.js";
|
||||
|
||||
const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2;
|
||||
const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3;
|
||||
const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
|
||||
const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000;
|
||||
|
||||
interface CloudConnectOptions extends BaseClientOptions {
|
||||
noBrowser?: boolean;
|
||||
}
|
||||
|
||||
interface CloudPushOptions extends BaseClientOptions {
|
||||
company?: string;
|
||||
remoteUrl?: string;
|
||||
dryRun?: boolean;
|
||||
maxEntitiesPerChunk?: number;
|
||||
}
|
||||
|
||||
interface UpstreamDiscovery {
|
||||
schema: string;
|
||||
stack: {
|
||||
id: string;
|
||||
slug?: string;
|
||||
displayName?: string;
|
||||
companyId: string;
|
||||
origin: string;
|
||||
};
|
||||
auth: {
|
||||
pkce?: {
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
codeChallengeMethod: string;
|
||||
};
|
||||
deviceCode?: {
|
||||
deviceCodeUrl: string;
|
||||
verificationUrl: string;
|
||||
tokenUrl: string;
|
||||
};
|
||||
scopes?: string[];
|
||||
};
|
||||
transfer: {
|
||||
supportedSchemaMajor: number;
|
||||
featureFlags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
accessToken: string;
|
||||
token: CloudConnectionTokenRecord;
|
||||
scopes?: string[];
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
class CloudAuthRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(status: number, message: string, body: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCloudCommands(program: Command): void {
|
||||
const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands");
|
||||
|
||||
addCommonClientOptions(
|
||||
cloud
|
||||
.command("connect")
|
||||
.description("Authorize this local instance to push into a Paperclip Cloud stack")
|
||||
.argument("<remote-url>", "Paperclip Cloud stack URL")
|
||||
.option("--no-browser", "Use the device-code flow instead of opening a browser", false)
|
||||
.action(async (remoteUrl: string, opts: CloudConnectOptions) => {
|
||||
try {
|
||||
await connectCloud(remoteUrl, opts);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
cloud
|
||||
.command("push")
|
||||
.description("Preview or apply a local company push into the connected Paperclip Cloud stack")
|
||||
.requiredOption("--company <local-company-id>", "Local company ID to export")
|
||||
.option("--remote-url <remote-url>", "Use a specific stored cloud connection")
|
||||
.option("--dry-run", "Preview without applying", false)
|
||||
.option("--max-entities-per-chunk <count>", "Chunk size for upstream uploads", (value) => Number(value), 100)
|
||||
.action(async (opts: CloudPushOptions) => {
|
||||
try {
|
||||
await pushCloud(opts);
|
||||
} catch (err) {
|
||||
if (isSchemaMismatchError(err)) {
|
||||
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE;
|
||||
return;
|
||||
}
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise<CloudConnection> {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const discovery = await discoverUpstream(remoteUrl);
|
||||
assertDiscoveryCompatible(discovery);
|
||||
const source = createSourceIdentity();
|
||||
const token = await authorizeConnection(discovery, source, {
|
||||
noBrowser: Boolean(opts.noBrowser),
|
||||
});
|
||||
const targetOrigin = discovery.stack.origin.replace(/\/+$/u, "");
|
||||
const targetHost = new URL(targetOrigin).host;
|
||||
const now = new Date().toISOString();
|
||||
const connection = upsertCloudConnection({
|
||||
id: connectionId(targetOrigin),
|
||||
remoteUrl,
|
||||
targetOrigin,
|
||||
targetHost,
|
||||
stackId: discovery.stack.id,
|
||||
stackSlug: discovery.stack.slug ?? null,
|
||||
stackDisplayName: discovery.stack.displayName ?? null,
|
||||
targetCompanyId: discovery.stack.companyId,
|
||||
accessToken: token.accessToken,
|
||||
token: token.token,
|
||||
privateKeyPem: source.privateKeyPem,
|
||||
sourcePublicKey: source.sourcePublicKey,
|
||||
sourceInstanceId: source.sourceInstanceId,
|
||||
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
|
||||
scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(redactConnection(connection), { json: true });
|
||||
} else {
|
||||
console.log(pc.bold("Connected to Paperclip Cloud"));
|
||||
console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`);
|
||||
console.log(`origin=${connection.targetOrigin}`);
|
||||
console.log(`company=${connection.targetCompanyId}`);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function pushCloud(opts: CloudPushOptions): Promise<unknown> {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: false });
|
||||
const localCompanyId = requiredString(opts.company, "--company");
|
||||
await assertCloudSyncEnabled(ctx.api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"));
|
||||
const connection = getCloudConnection(opts.remoteUrl);
|
||||
if (!connection) {
|
||||
throw new Error("No cloud connection found. Run `paperclipai cloud connect <remote-url>` first.");
|
||||
}
|
||||
|
||||
const discovery = await discoverUpstream(connection.targetOrigin);
|
||||
assertDiscoveryCompatible(discovery);
|
||||
const bundle = await buildBundleFromLocalCompany({
|
||||
localCompanyId,
|
||||
connection,
|
||||
discovery,
|
||||
localApi: ctx.api,
|
||||
maxEntitiesPerChunk: opts.maxEntitiesPerChunk,
|
||||
mode: opts.dryRun ? "preview" : "apply",
|
||||
});
|
||||
const coordinator = new LocalUpstreamPushCoordinator({
|
||||
targetOrigin: connection.targetOrigin,
|
||||
paperclipCompanyId: connection.targetCompanyId,
|
||||
headers: ({ method, path }) => cloudProofHeaders(connection, method, path),
|
||||
});
|
||||
|
||||
const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle);
|
||||
const runId = getRunId(result);
|
||||
const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null;
|
||||
const summary = summarizeResult(result);
|
||||
const conflictCount = summary.conflict + summary.staleMapping;
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput({ result, events }, { json: true });
|
||||
} else {
|
||||
console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied"));
|
||||
console.log(`run=${runId ?? "-"}`);
|
||||
console.log(`manifest=${bundle.manifest.manifestHash}`);
|
||||
console.log(
|
||||
`create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` +
|
||||
`skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`,
|
||||
);
|
||||
printWarnings(result);
|
||||
printConflicts(result);
|
||||
printEvents(events);
|
||||
}
|
||||
|
||||
if (conflictCount > 0) {
|
||||
process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function discoverUpstream(remoteUrl: string): Promise<UpstreamDiscovery> {
|
||||
const base = new URL(remoteUrl);
|
||||
const discoveryUrl = new URL("/.well-known/paperclip-upstream", base);
|
||||
return requestCloudJson<UpstreamDiscovery>(discoveryUrl.toString(), { method: "GET" });
|
||||
}
|
||||
|
||||
export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void {
|
||||
if (discovery.schema !== "paperclip-upstream-discovery-v1") {
|
||||
throw new Error("Remote URL is not a Paperclip Cloud upstream target.");
|
||||
}
|
||||
if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) {
|
||||
throw new Error(
|
||||
`Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`,
|
||||
);
|
||||
}
|
||||
if (!discovery.transfer.featureFlags?.includes("cloud_sync")) {
|
||||
throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag.");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number {
|
||||
const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS;
|
||||
}
|
||||
|
||||
export async function buildBundleFromLocalCompany(input: {
|
||||
localCompanyId: string;
|
||||
connection: CloudConnection;
|
||||
discovery: UpstreamDiscovery;
|
||||
localApi: {
|
||||
post<T>(path: string, body?: unknown): Promise<T | null>;
|
||||
};
|
||||
maxEntitiesPerChunk?: number;
|
||||
mode: "preview" | "apply";
|
||||
}): Promise<LocalUpstreamExportBundle> {
|
||||
const exported = await input.localApi.post<CompanyPortabilityExportResult>(
|
||||
`/api/companies/${input.localCompanyId}/export`,
|
||||
{
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
expandReferencedSkills: true,
|
||||
},
|
||||
);
|
||||
if (!exported) throw new Error("Local company export returned no data.");
|
||||
|
||||
const sourceHash = normalizedContentHash({
|
||||
manifest: exported.manifest,
|
||||
files: exported.files,
|
||||
});
|
||||
const source: UpstreamTransferManifestSource = {
|
||||
sourceInstanceId: input.connection.sourceInstanceId,
|
||||
sourceCompanyId: input.localCompanyId,
|
||||
sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint,
|
||||
exporterVersion: "paperclipai-cli-cloud-v1",
|
||||
sourceSchemaVersion: "paperclip-local-portability-v1",
|
||||
};
|
||||
const target: UpstreamTransferManifestTarget = {
|
||||
targetStackId: input.discovery.stack.id,
|
||||
targetCompanyId: input.discovery.stack.companyId,
|
||||
targetOrigin: input.discovery.stack.origin,
|
||||
supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor,
|
||||
};
|
||||
const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported);
|
||||
const idempotencyKey = [
|
||||
input.mode,
|
||||
input.connection.sourceInstanceId,
|
||||
input.localCompanyId,
|
||||
input.discovery.stack.id,
|
||||
sourceHash,
|
||||
].join(":");
|
||||
return buildLocalUpstreamExportBundle({
|
||||
source,
|
||||
target,
|
||||
runId: `local-${input.mode}-${shortHash(idempotencyKey)}`,
|
||||
idempotencyKey,
|
||||
entities,
|
||||
warnings: exported.warnings.map((message): UpstreamTransferWarning => ({
|
||||
code: "local_company_export_warning",
|
||||
severity: "warning",
|
||||
message,
|
||||
})),
|
||||
featureFlags: ["cloud_sync"],
|
||||
maxEntitiesPerChunk: input.maxEntitiesPerChunk,
|
||||
});
|
||||
}
|
||||
|
||||
async function authorizeConnection(
|
||||
discovery: UpstreamDiscovery,
|
||||
source: ReturnType<typeof createSourceIdentity>,
|
||||
opts: { noBrowser: boolean },
|
||||
): Promise<TokenResponse> {
|
||||
if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) {
|
||||
try {
|
||||
return await authorizeWithBrowser(discovery, source);
|
||||
} catch (error) {
|
||||
console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`));
|
||||
}
|
||||
}
|
||||
if (!discovery.auth.deviceCode) {
|
||||
throw new Error("Remote Paperclip Cloud stack does not support device-code authorization.");
|
||||
}
|
||||
return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() });
|
||||
}
|
||||
|
||||
async function authorizeWithBrowser(
|
||||
discovery: UpstreamDiscovery,
|
||||
source: ReturnType<typeof createSourceIdentity>,
|
||||
): Promise<TokenResponse> {
|
||||
const pkce = discovery.auth.pkce;
|
||||
if (!pkce) throw new Error("Remote did not advertise PKCE authorization.");
|
||||
const callback = await startPkceCallbackServer();
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
const state = randomUUID();
|
||||
const authorizeUrl = new URL(pkce.authorizeUrl);
|
||||
authorizeUrl.searchParams.set("redirectUri", callback.redirectUri);
|
||||
authorizeUrl.searchParams.set("state", state);
|
||||
authorizeUrl.searchParams.set("codeChallenge", challenge);
|
||||
authorizeUrl.searchParams.set("codeChallengeMethod", "S256");
|
||||
authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId);
|
||||
authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint);
|
||||
authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey);
|
||||
authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" "));
|
||||
|
||||
try {
|
||||
console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`);
|
||||
if (!openUrl(authorizeUrl.toString())) {
|
||||
throw new Error("Could not open a browser.");
|
||||
}
|
||||
const code = await callback.waitForCode(state);
|
||||
return requestCloudJson<TokenResponse>(pkce.tokenUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
grantType: "authorization_code",
|
||||
code,
|
||||
redirectUri: callback.redirectUri,
|
||||
codeVerifier: verifier,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function authorizeWithDeviceCode(
|
||||
discovery: UpstreamDiscovery,
|
||||
source: ReturnType<typeof createSourceIdentity>,
|
||||
opts: { openBrowser: boolean },
|
||||
): Promise<TokenResponse> {
|
||||
const device = discovery.auth.deviceCode;
|
||||
if (!device) throw new Error("Remote did not advertise device-code authorization.");
|
||||
const response = await requestCloudJson<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt?: string;
|
||||
intervalSeconds?: number;
|
||||
}>(device.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
stackId: discovery.stack.id,
|
||||
sourceInstanceId: source.sourceInstanceId,
|
||||
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
|
||||
sourcePublicKey: source.sourcePublicKey,
|
||||
scopes: CLOUD_SYNC_SCOPES,
|
||||
}),
|
||||
});
|
||||
console.error(pc.bold("Cloud device authorization required"));
|
||||
console.error(`Open: ${response.verificationUri}`);
|
||||
console.error(`Code: ${response.userCode}`);
|
||||
if (opts.openBrowser) openUrl(response.verificationUri);
|
||||
|
||||
const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt);
|
||||
const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000);
|
||||
while (Date.now() < expiresAt) {
|
||||
await sleep(intervalMs);
|
||||
try {
|
||||
return await requestCloudJson<TokenResponse>(device.tokenUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
grantType: "device_code",
|
||||
deviceCode: response.deviceCode,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") {
|
||||
const code = (error.body as { error?: unknown }).error;
|
||||
if (code === "authorization_pending") continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Device-code authorization expired before it was approved.");
|
||||
}
|
||||
|
||||
function buildEntitiesFromPortableExport(
|
||||
localCompanyId: string,
|
||||
sourceInstanceId: string,
|
||||
exported: CompanyPortabilityExportResult,
|
||||
): LocalUpstreamExportEntityInput[] {
|
||||
const companyKey: SourceEntityKey = {
|
||||
sourceInstanceId,
|
||||
sourceCompanyId: localCompanyId,
|
||||
sourceEntityType: "company",
|
||||
sourceEntityId: localCompanyId,
|
||||
sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId,
|
||||
};
|
||||
const entities: LocalUpstreamExportEntityInput[] = [
|
||||
{
|
||||
key: companyKey,
|
||||
body: {
|
||||
kind: "paperclip_company_portability_manifest",
|
||||
manifest: exported.manifest,
|
||||
rootPath: exported.rootPath,
|
||||
paperclipExtensionPath: exported.paperclipExtensionPath,
|
||||
fileCount: Object.keys(exported.files).length,
|
||||
},
|
||||
conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`],
|
||||
},
|
||||
];
|
||||
|
||||
for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
entities.push({
|
||||
key: {
|
||||
sourceInstanceId,
|
||||
sourceCompanyId: localCompanyId,
|
||||
sourceEntityType: "company_setting",
|
||||
sourceEntityId: shortHash(filePath),
|
||||
sourceNaturalKey: filePath,
|
||||
},
|
||||
body: {
|
||||
kind: "paperclip_portable_file",
|
||||
path: filePath,
|
||||
entry: normalizePortableFileEntry(entry),
|
||||
},
|
||||
dependencies: [companyKey],
|
||||
conflictKeys: [`portable_file:${filePath}`],
|
||||
});
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record<string, unknown> {
|
||||
if (typeof entry === "string") {
|
||||
return { encoding: "utf8", data: entry };
|
||||
}
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
async function assertCloudSyncEnabled(settingsPromise: Promise<InstanceExperimentalSettings | null>): Promise<void> {
|
||||
const settings = await settingsPromise;
|
||||
if (settings?.enableCloudSync !== true) {
|
||||
throw new Error(
|
||||
"Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record<string, string> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = randomUUID();
|
||||
const payload = [
|
||||
method,
|
||||
connection.targetHost.toLowerCase(),
|
||||
pathAndSearch,
|
||||
connection.token.id,
|
||||
connection.sourceInstanceId,
|
||||
timestamp,
|
||||
nonce,
|
||||
].join("\n");
|
||||
return {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
"X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId,
|
||||
"X-Paperclip-Upstream-Proof-Timestamp": timestamp,
|
||||
"X-Paperclip-Upstream-Proof-Nonce": nonce,
|
||||
"X-Paperclip-Upstream-Proof-Signature": sign(
|
||||
null,
|
||||
Buffer.from(payload, "utf8"),
|
||||
connection.privateKeyPem,
|
||||
).toString("base64url"),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCloudJson<T>(url: string, init: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("accept", "application/json");
|
||||
if (init.body !== undefined && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
const text = await response.text();
|
||||
const parsed = text.trim() ? JSON.parse(text) as unknown : {};
|
||||
if (!response.ok) {
|
||||
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
|
||||
? String((parsed as { error: unknown }).error)
|
||||
: `Cloud request failed with ${response.status}`;
|
||||
throw new CloudAuthRequestError(response.status, message, parsed);
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
function createSourceIdentity() {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||
const sourceInstanceFingerprint = `sha256:${createHash("sha256")
|
||||
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||
.digest("hex")}`;
|
||||
return {
|
||||
sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`,
|
||||
sourceInstanceFingerprint,
|
||||
sourcePublicKey,
|
||||
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function startPkceCallbackServer(): Promise<{
|
||||
redirectUri: string;
|
||||
waitForCode: (state: string) => Promise<string>;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
let resolveCode: ((code: string) => void) | null = null;
|
||||
let rejectCode: ((error: Error) => void) | null = null;
|
||||
let expectedState = "";
|
||||
const codePromise = new Promise<string>((resolve, reject) => {
|
||||
resolveCode = resolve;
|
||||
rejectCode = reject;
|
||||
});
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code || state !== expectedState) {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Paperclip Cloud authorization failed. You can close this tab.");
|
||||
rejectCode?.(new Error("Authorization callback was missing a valid code or state."));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("Paperclip Cloud authorization complete. You can close this tab.");
|
||||
resolveCode?.(code);
|
||||
});
|
||||
await listenOnLoopback(server);
|
||||
const address = server.address();
|
||||
if (typeof address !== "object" || !address?.port) {
|
||||
throw new Error("Failed to start local authorization callback server.");
|
||||
}
|
||||
return {
|
||||
redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`,
|
||||
waitForCode: (state: string) => {
|
||||
expectedState = state;
|
||||
return codePromise;
|
||||
},
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
}
|
||||
|
||||
function listenOnLoopback(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function canOpenBrowser(): boolean {
|
||||
if (process.platform === "darwin" || process.platform === "win32") return true;
|
||||
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
||||
}
|
||||
|
||||
function summarizeResult(result: unknown): {
|
||||
create: number;
|
||||
update: number;
|
||||
adopt: number;
|
||||
skip: number;
|
||||
conflict: number;
|
||||
staleMapping: number;
|
||||
} {
|
||||
const summary = asRecord(asRecord(result)?.summary);
|
||||
return {
|
||||
create: numberValue(summary?.create),
|
||||
update: numberValue(summary?.update),
|
||||
adopt: numberValue(summary?.adopt),
|
||||
skip: numberValue(summary?.skip),
|
||||
conflict: numberValue(summary?.conflict),
|
||||
staleMapping: numberValue(summary?.staleMapping),
|
||||
};
|
||||
}
|
||||
|
||||
function printWarnings(result: unknown): void {
|
||||
const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : [];
|
||||
for (const warning of warnings) {
|
||||
const record = asRecord(warning);
|
||||
console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
function printConflicts(result: unknown): void {
|
||||
const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : [];
|
||||
for (const conflict of conflicts.slice(0, 10)) {
|
||||
const record = asRecord(conflict);
|
||||
console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`));
|
||||
}
|
||||
if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`));
|
||||
}
|
||||
|
||||
function printEvents(events: unknown): void {
|
||||
const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : [];
|
||||
for (const row of rows.slice(-10)) {
|
||||
const event = asRecord(row);
|
||||
console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`));
|
||||
}
|
||||
}
|
||||
|
||||
function getRunId(result: unknown): string | null {
|
||||
const run = asRecord(asRecord(result)?.run);
|
||||
return typeof run?.id === "string" ? run.id : null;
|
||||
}
|
||||
|
||||
function redactConnection(connection: CloudConnection): Record<string, unknown> {
|
||||
return {
|
||||
id: connection.id,
|
||||
remoteUrl: connection.remoteUrl,
|
||||
targetOrigin: connection.targetOrigin,
|
||||
stackId: connection.stackId,
|
||||
targetCompanyId: connection.targetCompanyId,
|
||||
scopes: connection.scopes,
|
||||
expiresAt: connection.token.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function connectionId(targetOrigin: string): string {
|
||||
return `cloud-${shortHash(targetOrigin)}`;
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
throw new Error(`${label} is required.`);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function isSchemaMismatchError(error: unknown): boolean {
|
||||
if (error instanceof UpstreamImportRequestError) {
|
||||
return JSON.stringify(error.body).toLowerCase().includes("schema");
|
||||
}
|
||||
return error instanceof Error && error.message.toLowerCase().includes("schema mismatch");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const cloudCommandExitCodes = {
|
||||
conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE,
|
||||
schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE,
|
||||
} as const;
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
ensurePostgresDatabase,
|
||||
formatEmbeddedPostgresError,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
@@ -116,6 +117,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
await prepareEmbeddedPostgresNativeRuntime();
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
runDatabaseRestore,
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
formatEmbeddedPostgresError,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
@@ -1059,6 +1060,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
await prepareEmbeddedPostgresNativeRuntime();
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { registerRoutineCommands } from "./commands/routines.js";
|
||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||
import { registerSecretCommands } from "./commands/client/secrets.js";
|
||||
import { registerCloudCommands } from "./commands/client/cloud.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
@@ -149,6 +150,7 @@ registerDashboardCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerSecretCommands(program);
|
||||
registerCloudCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
@@ -118,6 +118,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
||||
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
|
||||
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
|
||||
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
|
||||
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
|
||||
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
|
||||
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,43 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensureLinuxSharedLibraryAliases } from "./embedded-postgres-native.js";
|
||||
|
||||
describe("embedded Postgres native runtime", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("creates soname aliases for bundled patch-level shared libraries", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-"));
|
||||
tempDirs.push(tempDir);
|
||||
fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), "");
|
||||
fs.writeFileSync(path.join(tempDir, "libicui18n.so.60.2"), "");
|
||||
fs.writeFileSync(path.join(tempDir, "README.md"), "");
|
||||
|
||||
const created = await ensureLinuxSharedLibraryAliases(tempDir);
|
||||
|
||||
expect(created.map((file) => path.basename(file)).sort()).toEqual([
|
||||
"libicui18n.so.60",
|
||||
"libicuuc.so.60",
|
||||
]);
|
||||
expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("is idempotent when aliases already exist", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-"));
|
||||
tempDirs.push(tempDir);
|
||||
fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), "");
|
||||
|
||||
await ensureLinuxSharedLibraryAliases(tempDir);
|
||||
const second = await ensureLinuxSharedLibraryAliases(tempDir);
|
||||
|
||||
expect(second).toEqual([]);
|
||||
expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function resolveNativePackageName(): string | null {
|
||||
if (process.platform !== "linux") return null;
|
||||
|
||||
switch (process.arch) {
|
||||
case "arm64":
|
||||
return "linux-arm64";
|
||||
case "arm":
|
||||
return "linux-arm";
|
||||
case "ia32":
|
||||
return "linux-ia32";
|
||||
case "ppc64":
|
||||
return "linux-ppc64";
|
||||
case "x64":
|
||||
return "linux-x64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(value: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEmbeddedPostgresPackageRoot(): string | null {
|
||||
try {
|
||||
const entry = require.resolve("embedded-postgres");
|
||||
return path.dirname(path.dirname(entry));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prependPathEnv(name: string, value: string): void {
|
||||
const current = process.env[name] ?? "";
|
||||
const parts = current.split(path.delimiter).filter(Boolean);
|
||||
if (parts.includes(value)) return;
|
||||
process.env[name] = [value, ...parts].join(path.delimiter);
|
||||
}
|
||||
|
||||
export async function ensureLinuxSharedLibraryAliases(libDir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(libDir, { withFileTypes: true });
|
||||
const created: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const match = entry.name.match(/^(lib.+\.so\.\d+)\.\d+(?:\.\d+)?$/);
|
||||
if (!match) continue;
|
||||
|
||||
const aliasName = match[1];
|
||||
const aliasPath = path.join(libDir, aliasName);
|
||||
try {
|
||||
await fs.symlink(entry.name, aliasPath);
|
||||
created.push(aliasPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function prepareEmbeddedPostgresNativeRuntime(): Promise<void> {
|
||||
const nativePackageName = resolveNativePackageName();
|
||||
const packageRoot = resolveEmbeddedPostgresPackageRoot();
|
||||
if (!nativePackageName || !packageRoot) return;
|
||||
|
||||
const nativeRoot = path.resolve(packageRoot, "..", "@embedded-postgres", nativePackageName);
|
||||
const libDir = path.join(nativeRoot, "native", "lib");
|
||||
if (!(await pathExists(libDir))) return;
|
||||
|
||||
prependPathEnv("LD_LIBRARY_PATH", libDir);
|
||||
await ensureLinuxSharedLibraryAliases(libDir);
|
||||
}
|
||||
@@ -30,6 +30,10 @@ export {
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
formatEmbeddedPostgresError,
|
||||
} from "./embedded-postgres-error.js";
|
||||
export {
|
||||
ensureLinuxSharedLibraryAliases,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
} from "./embedded-postgres-native.js";
|
||||
export { issueRelations } from "./schema/issue_relations.js";
|
||||
export { issueReferenceMentions } from "./schema/issue_reference_mentions.js";
|
||||
export * from "./schema/index.js";
|
||||
@@ -3,6 +3,7 @@ import { createServer } from "node:net";
|
||||
import path from "node:path";
|
||||
import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js";
|
||||
import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js";
|
||||
import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
|
||||
import { resolveDatabaseTarget } from "./runtime-config.js";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
@@ -92,6 +93,7 @@ async function ensureEmbeddedPostgresConnection(
|
||||
preferredPort: number,
|
||||
): Promise<MigrationConnection> {
|
||||
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
|
||||
await prepareEmbeddedPostgresNativeRuntime();
|
||||
const selectedPort = await findAvailablePort(preferredPort);
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const pgVersionFile = path.resolve(dataDir, "PG_VERSION");
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,71 @@
|
||||
CREATE TABLE IF NOT EXISTS "cloud_upstream_connections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"remote_url" text NOT NULL,
|
||||
"source_instance_id" text NOT NULL,
|
||||
"source_instance_fingerprint" text NOT NULL,
|
||||
"source_public_key" text NOT NULL,
|
||||
"private_key_pem" text NOT NULL,
|
||||
"token_status" text NOT NULL,
|
||||
"scopes" text[] DEFAULT '{}' NOT NULL,
|
||||
"authorized_global_user_id" text,
|
||||
"access_token" text,
|
||||
"token_id" text,
|
||||
"token_expires_at" timestamp with time zone,
|
||||
"target_stack_id" text NOT NULL,
|
||||
"target_stack_slug" text,
|
||||
"target_stack_display_name" text,
|
||||
"target_company_id" text NOT NULL,
|
||||
"target_origin" text NOT NULL,
|
||||
"target_primary_host" text NOT NULL,
|
||||
"target_product" text NOT NULL,
|
||||
"target_schema_major" integer NOT NULL,
|
||||
"target_max_chunk_bytes" integer NOT NULL,
|
||||
"pending_state" text,
|
||||
"pending_code_verifier" text,
|
||||
"pending_redirect_uri" text,
|
||||
"pending_token_url" text,
|
||||
"last_run_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "cloud_upstream_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"connection_id" uuid NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"remote_run_id" text,
|
||||
"status" text NOT NULL,
|
||||
"active_step" text NOT NULL,
|
||||
"progress_percent" integer DEFAULT 0 NOT NULL,
|
||||
"dry_run" boolean DEFAULT false NOT NULL,
|
||||
"retry_of_run_id" uuid,
|
||||
"summary" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"conflicts" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"events" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"report" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"manifest_hash" text NOT NULL,
|
||||
"target_url" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone
|
||||
);--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_connections_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "cloud_upstream_connections" ADD CONSTRAINT "cloud_upstream_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk') THEN
|
||||
ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."cloud_upstream_connections"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cloud_upstream_connections_company_idx" ON "cloud_upstream_connections" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_company_created_idx" ON "cloud_upstream_runs" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_connection_idx" ON "cloud_upstream_runs" USING btree ("connection_id");
|
||||
@@ -610,6 +610,27 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 89,
|
||||
"version": "7",
|
||||
"when": 1779129600000,
|
||||
"tag": "0089_cloud_upstreams",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const cloudUpstreamConnections = pgTable(
|
||||
"cloud_upstream_connections",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
remoteUrl: text("remote_url").notNull(),
|
||||
sourceInstanceId: text("source_instance_id").notNull(),
|
||||
sourceInstanceFingerprint: text("source_instance_fingerprint").notNull(),
|
||||
sourcePublicKey: text("source_public_key").notNull(),
|
||||
// Stored through the Cloud Upstream service as an encrypted credential envelope.
|
||||
privateKeyPem: text("private_key_pem").notNull(),
|
||||
tokenStatus: text("token_status").notNull(),
|
||||
scopes: text("scopes").array().notNull().default([]),
|
||||
authorizedGlobalUserId: text("authorized_global_user_id"),
|
||||
// Stored through the Cloud Upstream service as an encrypted credential envelope.
|
||||
accessToken: text("access_token"),
|
||||
tokenId: text("token_id"),
|
||||
tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
|
||||
|
||||
targetStackId: text("target_stack_id").notNull(),
|
||||
targetStackSlug: text("target_stack_slug"),
|
||||
targetStackDisplayName: text("target_stack_display_name"),
|
||||
targetCompanyId: text("target_company_id").notNull(),
|
||||
targetOrigin: text("target_origin").notNull(),
|
||||
targetPrimaryHost: text("target_primary_host").notNull(),
|
||||
targetProduct: text("target_product").notNull(),
|
||||
targetSchemaMajor: integer("target_schema_major").notNull(),
|
||||
targetMaxChunkBytes: integer("target_max_chunk_bytes").notNull(),
|
||||
|
||||
pendingState: text("pending_state"),
|
||||
pendingCodeVerifier: text("pending_code_verifier"),
|
||||
pendingRedirectUri: text("pending_redirect_uri"),
|
||||
pendingTokenUrl: text("pending_token_url"),
|
||||
|
||||
lastRunId: uuid("last_run_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("cloud_upstream_connections_company_idx").on(table.companyId),
|
||||
],
|
||||
);
|
||||
|
||||
export const cloudUpstreamRuns = pgTable(
|
||||
"cloud_upstream_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
connectionId: uuid("connection_id").notNull().references(() => cloudUpstreamConnections.id, { onDelete: "cascade" }),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
remoteRunId: text("remote_run_id"),
|
||||
status: text("status").notNull(),
|
||||
activeStep: text("active_step").notNull(),
|
||||
progressPercent: integer("progress_percent").notNull().default(0),
|
||||
dryRun: boolean("dry_run").notNull().default(false),
|
||||
retryOfRunId: uuid("retry_of_run_id"),
|
||||
summary: jsonb("summary").$type<import("@paperclipai/shared").CloudUpstreamSummaryCount[]>().notNull().default([]),
|
||||
warnings: jsonb("warnings").$type<import("@paperclipai/shared").CloudUpstreamWarning[]>().notNull().default([]),
|
||||
conflicts: jsonb("conflicts").$type<import("@paperclipai/shared").CloudUpstreamConflict[]>().notNull().default([]),
|
||||
events: jsonb("events").$type<import("@paperclipai/shared").CloudUpstreamRunEvent[]>().notNull().default([]),
|
||||
report: jsonb("report").$type<Record<string, unknown>>().notNull().default({}),
|
||||
idempotencyKey: text("idempotency_key").notNull(),
|
||||
manifestHash: text("manifest_hash").notNull(),
|
||||
targetUrl: text("target_url"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index("cloud_upstream_runs_company_created_idx").on(table.companyId, table.createdAt),
|
||||
index("cloud_upstream_runs_connection_idx").on(table.connectionId),
|
||||
],
|
||||
);
|
||||
@@ -2,6 +2,7 @@ export { companies } from "./companies.js";
|
||||
export { companyLogos } from "./company_logos.js";
|
||||
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
||||
export { instanceSettings } from "./instance_settings.js";
|
||||
export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.js";
|
||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
|
||||
export { agents } from "./agents.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js";
|
||||
import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
@@ -48,6 +49,7 @@ function getReservedTestPorts(): Set<number> {
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
await prepareEmbeddedPostgresNativeRuntime();
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.INVOCATION_SCOPE_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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -145,6 +149,9 @@ export type {
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
PluginPerformActionActorType,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentDiagnostic,
|
||||
PluginEnvironmentDriverBaseParams,
|
||||
@@ -218,6 +225,17 @@ export type {
|
||||
PluginIssueSubtree,
|
||||
PluginIssueSummariesClient,
|
||||
PluginAgentsClient,
|
||||
PluginAccessClient,
|
||||
PluginAccessMembersClient,
|
||||
PluginAccessInvitesClient,
|
||||
PluginAccessMember,
|
||||
PluginAccessInvite,
|
||||
PluginAuthorizationClient,
|
||||
PluginAuthorizationPolicySummary,
|
||||
PluginAuthorizationPolicyRecord,
|
||||
PluginAssignmentPreviewInput,
|
||||
PluginAuthorizationDecisionResult,
|
||||
PluginAuthorizationAuditEntry,
|
||||
PluginAgentSessionsClient,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
@@ -253,7 +271,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 +376,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 +384,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";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +233,8 @@ export const PLUGIN_RPC_ERROR_CODES = {
|
||||
TIMEOUT: -32003,
|
||||
/** The worker does not implement the requested optional method. */
|
||||
METHOD_NOT_IMPLEMENTED: -32004,
|
||||
/** The worker→host call attempted to escape the current invocation company scope. */
|
||||
INVOCATION_SCOPE_DENIED: -32005,
|
||||
/** A catch-all for errors that do not fit other categories. */
|
||||
UNKNOWN: -32099,
|
||||
} as const;
|
||||
@@ -217,6 +242,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 +357,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. */
|
||||
@@ -313,11 +370,37 @@ export interface GetDataParams {
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||||
*/
|
||||
export type PluginPerformActionActorType = "user" | "agent" | "system";
|
||||
|
||||
export interface PluginPerformActionActorContext {
|
||||
/** Authenticated principal type resolved by the Paperclip host. */
|
||||
type: PluginPerformActionActorType;
|
||||
/** Authenticated board user id when `type === "user"`, otherwise null. */
|
||||
userId: string | null;
|
||||
/** Authenticated agent id when `type === "agent"`, otherwise null. */
|
||||
agentId: string | null;
|
||||
/** Authenticated heartbeat/run id when available. */
|
||||
runId: string | null;
|
||||
/** Company id authorized by the host bridge for this action, when applicable. */
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
export interface PluginPerformActionContext {
|
||||
/** Immutable authenticated actor context supplied by the host. */
|
||||
actor: Readonly<PluginPerformActionActorContext>;
|
||||
/** Convenience alias for `actor.companyId`. */
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
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>;
|
||||
/** Authenticated actor context resolved by the host, never by caller params. */
|
||||
actorContext?: PluginPerformActionActorContext | null;
|
||||
/** Optional launcher/container metadata from the host render environment. */
|
||||
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
|
||||
}
|
||||
@@ -1128,6 +1211,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. */
|
||||
|
||||
@@ -38,6 +38,10 @@ import type {
|
||||
AgentSessionEvent,
|
||||
PluginLocalFolderEntry,
|
||||
PluginLocalFolderStatus,
|
||||
PluginAccessMember,
|
||||
PrincipalPermissionGrant,
|
||||
PermissionKey,
|
||||
PrincipalType,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
@@ -53,6 +57,8 @@ import type {
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
@@ -70,10 +76,24 @@ export interface TestHarnessLogEntry {
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarnessPerformActionOptions {
|
||||
/**
|
||||
* Authenticated actor context to expose to the action handler. Omitted fields
|
||||
* default to null, and `type` defaults to `system`.
|
||||
*/
|
||||
actor?: Partial<PluginPerformActionActorContext> | null;
|
||||
/**
|
||||
* Host-authorized company scope. When provided, this is injected into
|
||||
* `params.companyId` so tests match the production bridge's anti-spoofing
|
||||
* behavior.
|
||||
*/
|
||||
companyId?: string | null;
|
||||
}
|
||||
|
||||
export interface TestHarness {
|
||||
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||
ctx: PluginContext;
|
||||
/** 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 +103,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. */
|
||||
@@ -92,7 +114,11 @@ export interface TestHarness {
|
||||
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
performAction<T = unknown>(
|
||||
key: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): Promise<T>;
|
||||
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||
/** Read raw in-memory state for assertions. */
|
||||
@@ -440,6 +466,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>();
|
||||
@@ -452,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<
|
||||
string,
|
||||
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
|
||||
>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
function localFolderKey(companyId: string, folderKey: string): string {
|
||||
@@ -463,6 +525,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
return `${localFolderKey(companyId, folderKey)}:${relativePath}`;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
|
||||
return value === "user" || value === "agent" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
function actionContextFor(
|
||||
params: Record<string, unknown>,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): PluginPerformActionContext {
|
||||
const actorInput = options?.actor ?? null;
|
||||
const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId);
|
||||
const actor = Object.freeze({
|
||||
type: actorTypeOrSystem(actorInput?.type),
|
||||
userId: stringOrNull(actorInput?.userId),
|
||||
agentId: stringOrNull(actorInput?.agentId),
|
||||
runId: stringOrNull(actorInput?.runId),
|
||||
companyId,
|
||||
});
|
||||
return Object.freeze({ actor, companyId });
|
||||
}
|
||||
|
||||
function paramsWithHostCompanyScope(
|
||||
params: Record<string, unknown>,
|
||||
context: PluginPerformActionContext,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): Record<string, unknown> {
|
||||
if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) {
|
||||
return context.companyId ? { ...params, companyId: context.companyId } : { ...params };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function normalizeLocalFolderRelativePath(relativePath: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const segment of relativePath.split(/[\\/]+/)) {
|
||||
@@ -1983,6 +2080,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 +2312,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 };
|
||||
@@ -2107,10 +2360,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
async performAction<T = unknown>(
|
||||
key: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
) {
|
||||
const handler = actionHandlers.get(key);
|
||||
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
const context = actionContextFor(params, options);
|
||||
return await handler(paramsWithHostCompanyScope(params, context, options), context) as T;
|
||||
},
|
||||
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||
const handler = toolHandlers.get(name);
|
||||
|
||||
@@ -39,7 +39,14 @@ import type {
|
||||
RoutineRun,
|
||||
Agent,
|
||||
Goal,
|
||||
HumanCompanyMembershipRole,
|
||||
InviteJoinType,
|
||||
MembershipStatus,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
} from "@paperclipai/shared";
|
||||
import type { PluginPerformActionContext } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports from @paperclipai/shared (plugin authors import from one place)
|
||||
@@ -120,6 +127,12 @@ export type {
|
||||
IssueSurfaceVisibility,
|
||||
Agent,
|
||||
Goal,
|
||||
HumanCompanyMembershipRole,
|
||||
InviteJoinType,
|
||||
MembershipStatus,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -945,9 +958,12 @@ export interface PluginActionsClient {
|
||||
* Register a handler for a plugin-defined action key.
|
||||
*
|
||||
* @param key - Stable string identifier for this action (e.g. `"resync"`)
|
||||
* @param handler - Async function that receives action params and returns a result
|
||||
* @param handler - Async function that receives action params plus immutable host actor context and returns a result
|
||||
*/
|
||||
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void;
|
||||
register(
|
||||
key: string,
|
||||
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1576,6 +1592,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 +1895,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;
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ export type {
|
||||
// Slot component prop interfaces
|
||||
export type {
|
||||
PluginPageProps,
|
||||
PluginCompanySettingsPageProps,
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
|
||||
@@ -54,6 +54,7 @@ export type {
|
||||
* Error codes:
|
||||
* - `WORKER_UNAVAILABLE` — plugin worker is not running
|
||||
* - `CAPABILITY_DENIED` — plugin lacks the required capability
|
||||
* - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope
|
||||
* - `WORKER_ERROR` — worker returned an error from its handler
|
||||
* - `TIMEOUT` — worker did not respond within the configured timeout
|
||||
* - `UNKNOWN` — unexpected bridge-level failure
|
||||
@@ -229,6 +230,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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
@@ -76,6 +78,8 @@ import type {
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
@@ -85,6 +89,7 @@ import type {
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginInvocationContext,
|
||||
WorkerToHostMethodName,
|
||||
WorkerToHostMethods,
|
||||
} from "./protocol.js";
|
||||
@@ -279,13 +284,17 @@ 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[] = [];
|
||||
const jobHandlers = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launcherRegistrations = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<
|
||||
string,
|
||||
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
|
||||
>();
|
||||
const toolHandlers = new Map<string, {
|
||||
declaration: Pick<import("@paperclipai/shared").PluginToolDeclaration, "displayName" | "description" | "parametersSchema">;
|
||||
fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>;
|
||||
@@ -365,7 +374,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 +391,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 +1103,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);
|
||||
@@ -1093,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
|
||||
actions: {
|
||||
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
|
||||
register(
|
||||
key: string,
|
||||
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
|
||||
): void {
|
||||
actionHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
@@ -1175,7 +1274,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 +1515,36 @@ 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 }),
|
||||
});
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
|
||||
return value === "user" || value === "agent" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext {
|
||||
const rawActor = params.actorContext && typeof params.actorContext === "object"
|
||||
? params.actorContext
|
||||
: null;
|
||||
const actor = Object.freeze({
|
||||
type: actorTypeOrSystem(rawActor?.type),
|
||||
userId: stringOrNull(rawActor?.userId),
|
||||
agentId: stringOrNull(rawActor?.agentId),
|
||||
runId: stringOrNull(rawActor?.runId),
|
||||
companyId: stringOrNull(rawActor?.companyId),
|
||||
});
|
||||
return Object.freeze({
|
||||
actor,
|
||||
companyId: actor.companyId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
|
||||
@@ -1426,9 +1553,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
throw new Error(`No action handler registered for key "${params.key}"`);
|
||||
}
|
||||
return handler(
|
||||
params.renderEnvironment === undefined
|
||||
? params.params
|
||||
: { ...params.params, renderEnvironment: params.renderEnvironment },
|
||||
{
|
||||
...params.params,
|
||||
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
|
||||
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
|
||||
},
|
||||
actionContextFromParams(params),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1597,14 +1727,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,175 @@
|
||||
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";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
|
||||
|
||||
describe("createHostClientHandlers invocation company scope", () => {
|
||||
it("rejects company-scoped host calls outside the current invocation company", async () => {
|
||||
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);
|
||||
await expect(
|
||||
handlers["projects.list"](
|
||||
{ companyId: "company-b" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
});
|
||||
expect(projectsList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestHarness } from "../src/testing.js";
|
||||
import type { PaperclipPluginManifestV1 } from "../src/types.js";
|
||||
|
||||
const manifest = {
|
||||
id: "paperclip.test-actions",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Test Actions",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: {},
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
|
||||
describe("createTestHarness action context", () => {
|
||||
it("passes immutable authenticated actor context and overrides caller company scope", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
harness.ctx.actions.register("inspect", async (params, context) => ({
|
||||
paramsCompanyId: params.companyId,
|
||||
actor: context.actor,
|
||||
companyId: context.companyId,
|
||||
contextFrozen: Object.isFrozen(context),
|
||||
actorFrozen: Object.isFrozen(context.actor),
|
||||
}));
|
||||
|
||||
const result = await harness.performAction<{
|
||||
paramsCompanyId: unknown;
|
||||
actor: {
|
||||
type: string;
|
||||
userId: string | null;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
companyId: string | null;
|
||||
};
|
||||
companyId: string | null;
|
||||
contextFrozen: boolean;
|
||||
actorFrozen: boolean;
|
||||
}>(
|
||||
"inspect",
|
||||
{ companyId: "spoofed-company", value: true },
|
||||
{
|
||||
companyId: "host-company",
|
||||
actor: {
|
||||
type: "user",
|
||||
userId: "board-user-1",
|
||||
runId: "run-1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.paramsCompanyId).toBe("host-company");
|
||||
expect(result.companyId).toBe("host-company");
|
||||
expect(result.actor).toEqual({
|
||||
type: "user",
|
||||
userId: "board-user-1",
|
||||
agentId: null,
|
||||
runId: "run-1",
|
||||
companyId: "host-company",
|
||||
});
|
||||
expect(result.contextFrozen).toBe(true);
|
||||
expect(result.actorFrozen).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps existing one-argument action handlers compatible", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.ctx.actions.register("legacy", async (params) => ({ ok: params.ok }));
|
||||
|
||||
await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -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,229 @@ describe("isWorkerEntrypoint", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker performAction context", () => {
|
||||
it("does not derive context companyId from caller params without host actor context", async () => {
|
||||
const hostToWorker = new PassThrough();
|
||||
const workerToHost = new PassThrough();
|
||||
const hostReadline = createInterface({ input: workerToHost });
|
||||
const pending = new Map<string, (response: JsonRpcResponse) => void>();
|
||||
let nextRequestId = 1;
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.actions.register("inspect", async (params, context) => ({
|
||||
paramsCompanyId: params.companyId,
|
||||
actor: context.actor,
|
||||
companyId: context.companyId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
const worker = startWorkerRpcHost({
|
||||
plugin,
|
||||
stdin: hostToWorker,
|
||||
stdout: workerToHost,
|
||||
});
|
||||
|
||||
function callWorker(method: string, params: unknown) {
|
||||
const id = `host-${nextRequestId++}`;
|
||||
const result = new Promise<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(createRequest(method, params, id)));
|
||||
return result;
|
||||
}
|
||||
|
||||
hostReadline.on("line", (line) => {
|
||||
const message = parseMessage(line);
|
||||
if (!isJsonRpcResponse(message)) return;
|
||||
pending.get(String(message.id))?.(message);
|
||||
pending.delete(String(message.id));
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(callWorker("initialize", {
|
||||
manifest: {
|
||||
id: "paperclip.test-worker-context",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Worker Context Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: {},
|
||||
},
|
||||
config: {},
|
||||
databaseNamespace: null,
|
||||
})).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await expect(callWorker("performAction", {
|
||||
key: "inspect",
|
||||
params: { companyId: "spoofed-company" },
|
||||
})).resolves.toEqual({
|
||||
paramsCompanyId: "spoofed-company",
|
||||
actor: {
|
||||
type: "system",
|
||||
userId: null,
|
||||
agentId: null,
|
||||
runId: null,
|
||||
companyId: null,
|
||||
},
|
||||
companyId: null,
|
||||
});
|
||||
} finally {
|
||||
worker.stop();
|
||||
hostReadline.close();
|
||||
hostToWorker.destroy();
|
||||
workerToHost.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker invocation scope propagation", () => {
|
||||
it("keeps overlapping company scopes local to each getData invocation", async () => {
|
||||
const hostToWorker = new PassThrough();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -1070,6 +1095,7 @@ export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number];
|
||||
export const PLUGIN_BRIDGE_ERROR_CODES = [
|
||||
"WORKER_UNAVAILABLE",
|
||||
"CAPABILITY_DENIED",
|
||||
"INVOCATION_SCOPE_DENIED",
|
||||
"WORKER_ERROR",
|
||||
"TIMEOUT",
|
||||
"UNKNOWN",
|
||||
|
||||
@@ -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,
|
||||
@@ -686,6 +688,22 @@ export {
|
||||
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
} from "./types/instance.js";
|
||||
|
||||
export type {
|
||||
CloudUpstreamConnectStartResponse,
|
||||
CloudUpstreamActivationDecision,
|
||||
CloudUpstreamActivationEntityType,
|
||||
CloudUpstreamConnection,
|
||||
CloudUpstreamConflict,
|
||||
CloudUpstreamPreview,
|
||||
CloudUpstreamRun,
|
||||
CloudUpstreamRunEvent,
|
||||
CloudUpstreamsState,
|
||||
CloudUpstreamStep,
|
||||
CloudUpstreamSummaryCount,
|
||||
CloudUpstreamTarget,
|
||||
CloudUpstreamWarning,
|
||||
} from "./types/cloud-upstream.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
export type CloudUpstreamStep = "connect" | "scan" | "preview" | "push" | "verify" | "activate";
|
||||
|
||||
export type CloudUpstreamRunStatus = "previewed" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
|
||||
export type CloudUpstreamActivationEntityType = "agents" | "routines" | "monitors";
|
||||
|
||||
export interface CloudUpstreamActivationDecision {
|
||||
entityType: CloudUpstreamActivationEntityType;
|
||||
count: number;
|
||||
status: "paused" | "activated";
|
||||
activatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamTarget {
|
||||
stackId: string;
|
||||
stackSlug: string | null;
|
||||
stackDisplayName: string | null;
|
||||
companyId: string;
|
||||
primaryHost: string;
|
||||
origin: string;
|
||||
product: string;
|
||||
schemaMajor: number;
|
||||
maxChunkBytes: number;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamConnection {
|
||||
id: string;
|
||||
companyId: string;
|
||||
remoteUrl: string;
|
||||
target: CloudUpstreamTarget;
|
||||
tokenStatus: "pending" | "connected" | "expired" | "revoked";
|
||||
scopes: string[];
|
||||
authorizedGlobalUserId: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastRunId: string | null;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamSummaryCount {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamWarning {
|
||||
code: string;
|
||||
severity: "warning" | "blocker";
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamConflict {
|
||||
id: string;
|
||||
entityType: string;
|
||||
sourceLabel: string;
|
||||
targetLabel: string;
|
||||
plannedAction: "create" | "update" | "skip" | "blocked";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamPreview {
|
||||
connectionId: string;
|
||||
sourceCompanyId: string;
|
||||
target: CloudUpstreamTarget;
|
||||
schemaCompatible: boolean;
|
||||
summary: CloudUpstreamSummaryCount[];
|
||||
warnings: CloudUpstreamWarning[];
|
||||
conflicts: CloudUpstreamConflict[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamRunEvent {
|
||||
id: string;
|
||||
at: string;
|
||||
phase: CloudUpstreamStep;
|
||||
type: "created" | "updated" | "skipped" | "conflict" | "retrying" | "failed" | "completed";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamRun {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
companyId: string;
|
||||
status: CloudUpstreamRunStatus;
|
||||
activeStep: CloudUpstreamStep;
|
||||
progressPercent: number;
|
||||
dryRun: boolean;
|
||||
summary: CloudUpstreamSummaryCount[];
|
||||
warnings: CloudUpstreamWarning[];
|
||||
conflicts: CloudUpstreamConflict[];
|
||||
events: CloudUpstreamRunEvent[];
|
||||
targetUrl: string | null;
|
||||
report: Record<string, unknown>;
|
||||
retryOfRunId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CloudUpstreamsState {
|
||||
connections: CloudUpstreamConnection[];
|
||||
runs: CloudUpstreamRun[];
|
||||
}
|
||||
|
||||
export interface CloudUpstreamConnectStartResponse {
|
||||
pendingConnectionId: string;
|
||||
authorizationUrl: string;
|
||||
connection: CloudUpstreamConnection;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
|
||||
export interface InstanceExperimentalSettings {
|
||||
enableEnvironments: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableCloudSync: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: number;
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
|
||||
export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableEnvironments: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableCloudSync: z.boolean().default(false),
|
||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: z
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createDb,
|
||||
documents,
|
||||
documentRevisions,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
@@ -42,6 +43,7 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueReadStates);
|
||||
await db.delete(issueComments);
|
||||
@@ -228,4 +230,32 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes heartbeat events by run id before deleting company-owned runs", async () => {
|
||||
const { agentId, companyId, runId } = await seedFixture();
|
||||
const otherCompanyId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: otherCompanyId,
|
||||
name: "Other Company",
|
||||
issuePrefix: `O${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId: otherCompanyId,
|
||||
runId,
|
||||
agentId,
|
||||
seq: 1,
|
||||
eventType: "output",
|
||||
message: "event with mismatched company scope",
|
||||
});
|
||||
|
||||
const removed = await companyService(db).remove(companyId);
|
||||
|
||||
expect(removed?.id).toBe(companyId);
|
||||
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(companies).where(eq(companies.id, otherCompanyId))).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { companies, cloudUpstreamConnections, cloudUpstreamRuns, companySkills, createDb } from "@paperclipai/db";
|
||||
|
||||
import { HttpError } from "../errors.js";
|
||||
import {
|
||||
cloudUpstreamRemoteFailureReport,
|
||||
cloudUpstreamService,
|
||||
reconcileCloudUpstreamRunsOnStartup,
|
||||
sealCloudUpstreamCredential,
|
||||
unsealCloudUpstreamCredential,
|
||||
} from "../services/cloud-upstreams.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres cloud upstream tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("cloud upstream remote failures", () => {
|
||||
it("preserves the cloud response body and message on run reports", () => {
|
||||
const body = {
|
||||
error: "bad_request",
|
||||
message: "entities[42].body must be an object",
|
||||
errors: [{ path: "entities[42].body" }],
|
||||
};
|
||||
|
||||
expect(cloudUpstreamRemoteFailureReport(new HttpError(400, "bad_request", body))).toEqual({
|
||||
error: "bad_request",
|
||||
errorMessage: "entities[42].body must be an object",
|
||||
details: body,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the thrown error message for non-remote failures", () => {
|
||||
expect(cloudUpstreamRemoteFailureReport(new Error("network failed"))).toEqual({
|
||||
error: "network failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloud upstream credential storage", () => {
|
||||
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (previousMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("stores new credentials as encrypted envelopes and preserves legacy plaintext reads", async () => {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
|
||||
const sealed = await sealCloudUpstreamCredential("cloud-access-token");
|
||||
|
||||
expect(sealed).toMatch(/^paperclip-cloud-credential:/);
|
||||
expect(sealed).not.toContain("cloud-access-token");
|
||||
await expect(unsealCloudUpstreamCredential(sealed)).resolves.toBe("cloud-access-token");
|
||||
await expect(unsealCloudUpstreamCredential("legacy-plaintext-token")).resolves.toBe("legacy-plaintext-token");
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("cloud upstream persistence", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-upstreams-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(cloudUpstreamRuns);
|
||||
await db.delete(cloudUpstreamConnections);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
|
||||
}
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("encrypts stored upstream credentials while keeping connection flows usable", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
const tokenUrl = "https://cloud.example.test/oauth/token";
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("https://cloud.example.test/.well-known/paperclip-upstream")) {
|
||||
return jsonResponse({
|
||||
product: "Paperclip Cloud",
|
||||
stack: {
|
||||
id: "stack-1",
|
||||
companyId: "cloud-company-1",
|
||||
origin: "https://cloud.example.test",
|
||||
primaryHost: "cloud.example.test",
|
||||
},
|
||||
transfer: {
|
||||
supportedSchemaMajor: 1,
|
||||
maxChunkBytes: 8192,
|
||||
},
|
||||
auth: {
|
||||
scopes: ["upstream_import:write"],
|
||||
pkce: {
|
||||
authorizeUrl: "https://cloud.example.test/oauth/authorize",
|
||||
tokenUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url === tokenUrl && init?.method === "POST") {
|
||||
const payload = JSON.parse(String(init.body));
|
||||
expect(payload.codeVerifier).toEqual(expect.any(String));
|
||||
expect(payload.codeVerifier).not.toContain("paperclip-cloud-credential:");
|
||||
return jsonResponse({
|
||||
accessToken: "cloud-access-token",
|
||||
token: {
|
||||
id: "token-1",
|
||||
expiresAt: "2026-05-22T13:00:00.000Z",
|
||||
globalUserId: "user-1",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const service = cloudUpstreamService(db, { instanceId: "test" });
|
||||
const started = await service.startConnect({
|
||||
companyId,
|
||||
remoteUrl: "https://cloud.example.test",
|
||||
redirectUri: "http://localhost:3100/callback",
|
||||
});
|
||||
await service.finishConnect({
|
||||
pendingConnectionId: started.pendingConnectionId,
|
||||
code: "auth-code",
|
||||
state: new URL(started.authorizationUrl).searchParams.get("state") ?? "",
|
||||
});
|
||||
|
||||
const [row] = await db.select().from(cloudUpstreamConnections);
|
||||
expect(row.privateKeyPem).toMatch(/^paperclip-cloud-credential:/);
|
||||
expect(row.privateKeyPem).not.toContain("BEGIN PRIVATE KEY");
|
||||
expect(row.accessToken).toMatch(/^paperclip-cloud-credential:/);
|
||||
expect(row.accessToken).not.toContain("cloud-access-token");
|
||||
});
|
||||
|
||||
it("marks orphaned running runs failed during startup reconciliation", async () => {
|
||||
const companyId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
const runningRunId = randomUUID();
|
||||
const succeededRunId = randomUUID();
|
||||
const reconciledAt = new Date("2026-05-22T13:00:00.000Z");
|
||||
await seedCompany(companyId);
|
||||
await db.insert(cloudUpstreamConnections).values({
|
||||
id: connectionId,
|
||||
companyId,
|
||||
remoteUrl: "https://cloud.example.test",
|
||||
sourceInstanceId: "source-1",
|
||||
sourceInstanceFingerprint: "sha256:test",
|
||||
sourcePublicKey: "public-key",
|
||||
privateKeyPem: "legacy-private-key",
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
accessToken: "legacy-token",
|
||||
tokenId: "token-1",
|
||||
targetStackId: "stack-1",
|
||||
targetCompanyId: "cloud-company-1",
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
targetPrimaryHost: "cloud.example.test",
|
||||
targetProduct: "Paperclip Cloud",
|
||||
targetSchemaMajor: 1,
|
||||
targetMaxChunkBytes: 8192,
|
||||
});
|
||||
await db.insert(cloudUpstreamRuns).values([
|
||||
cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
|
||||
cloudRunRow({ id: succeededRunId, connectionId, companyId, status: "succeeded", completedAt: reconciledAt }),
|
||||
]);
|
||||
|
||||
await expect(reconcileCloudUpstreamRunsOnStartup(db, reconciledAt)).resolves.toEqual({ reconciled: 1 });
|
||||
|
||||
const rows = await db.select().from(cloudUpstreamRuns);
|
||||
const running = rows.find((row) => row.id === runningRunId);
|
||||
const succeeded = rows.find((row) => row.id === succeededRunId);
|
||||
expect(running?.status).toBe("failed");
|
||||
expect(running?.completedAt?.toISOString()).toBe(reconciledAt.toISOString());
|
||||
expect(running?.events.at(-1)?.message).toContain("server startup");
|
||||
expect(running?.report).toMatchObject({
|
||||
error: "orphaned_running_run",
|
||||
reconciledAt: reconciledAt.toISOString(),
|
||||
});
|
||||
expect(succeeded?.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("rejects a new run when the connection already has a running run", async () => {
|
||||
const companyId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
const runningRunId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
|
||||
await db.insert(cloudUpstreamRuns).values(
|
||||
cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
|
||||
);
|
||||
|
||||
await expect(cloudUpstreamService(db).createRun({ connectionId, companyId })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { runId: runningRunId },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a cancelled run when an in-flight createRun tries to finish", async () => {
|
||||
const companyId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
|
||||
|
||||
const service = cloudUpstreamService(db);
|
||||
const remoteCalls: string[] = [];
|
||||
globalThis.fetch = vi.fn(async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
remoteCalls.push(path);
|
||||
if (path.endsWith("/upstream-imports/runs")) {
|
||||
return jsonResponse({ run: { id: "remote-run-1" } });
|
||||
}
|
||||
if (path.endsWith("/chunks")) {
|
||||
const run = await db.select().from(cloudUpstreamRuns).then((rows) => rows[0]);
|
||||
expect(run?.status).toBe("running");
|
||||
await service.cancelRun(connectionId, run.id, companyId);
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (path.endsWith("/cancel")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (path.endsWith("/apply")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (path.endsWith("/events")) {
|
||||
return jsonResponse({ events: [] });
|
||||
}
|
||||
return jsonResponse({ error: "not_found" }, 404);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await service.createRun({ connectionId, companyId });
|
||||
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(remoteCalls.some((path) => path.endsWith("/apply"))).toBe(false);
|
||||
const rows = await db.select().from(cloudUpstreamRuns);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
async function seedCompany(companyId: string) {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function cloudConnectionRow(input: { id: string; companyId: string }) {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return {
|
||||
id: input.id,
|
||||
companyId: input.companyId,
|
||||
remoteUrl: "https://cloud.example.test",
|
||||
sourceInstanceId: "source-1",
|
||||
sourceInstanceFingerprint: "sha256:test",
|
||||
sourcePublicKey: "public-key",
|
||||
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
accessToken: "legacy-token",
|
||||
tokenId: "token-1",
|
||||
targetStackId: "stack-1",
|
||||
targetCompanyId: "cloud-company-1",
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
targetPrimaryHost: "cloud.example.test",
|
||||
targetProduct: "Paperclip Cloud",
|
||||
targetSchemaMajor: 1,
|
||||
targetMaxChunkBytes: 8192,
|
||||
};
|
||||
}
|
||||
|
||||
function cloudRunRow(input: {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
completedAt?: Date;
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
connectionId: input.connectionId,
|
||||
companyId: input.companyId,
|
||||
status: input.status,
|
||||
activeStep: "push",
|
||||
progressPercent: input.status === "running" ? 45 : 100,
|
||||
dryRun: false,
|
||||
summary: [],
|
||||
warnings: [],
|
||||
conflicts: [],
|
||||
events: [],
|
||||
report: {},
|
||||
idempotencyKey: `key-${input.id}`,
|
||||
manifestHash: `sha256:${input.id.replace(/-/g, "")}`,
|
||||
targetUrl: "https://cloud.example.test",
|
||||
completedAt: input.completedAt,
|
||||
};
|
||||
}
|
||||
@@ -139,6 +139,58 @@ function createExportResult() {
|
||||
};
|
||||
}
|
||||
|
||||
const importRequest = {
|
||||
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
|
||||
include: { company: true, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company", companyId },
|
||||
collisionStrategy: "rename",
|
||||
};
|
||||
|
||||
const cloudHeaders = {
|
||||
"x-paperclip-cloud-stack-id": "stack-alpha",
|
||||
"x-paperclip-cloud-paperclip-company-id": companyId,
|
||||
};
|
||||
|
||||
function cloudTenantActor() {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "cloud-user-1",
|
||||
userName: "Cloud User",
|
||||
userEmail: "cloud-user@example.com",
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
|
||||
isInstanceAdmin: true,
|
||||
source: "cloud_tenant",
|
||||
};
|
||||
}
|
||||
|
||||
function createImportResult(action = "updated") {
|
||||
return {
|
||||
company: { id: companyId, action },
|
||||
agents: [{ id: "agent-1" }],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForImportJobStatus(app: express.Express, statusUrl: string, status: string) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const res = await request(app).get(statusUrl).set(cloudHeaders);
|
||||
if (res.body.job?.status === status) {
|
||||
return res;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Timed out waiting for import job to reach ${status}`);
|
||||
}
|
||||
|
||||
async function waitForCondition(condition: () => boolean, label: string) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (condition()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
describe.sequential("company portability routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -426,4 +478,116 @@ describe.sequential("company portability routes", () => {
|
||||
expect(res.body.error).toContain("Instance admin");
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.sequential("accepts trusted Cloud async import jobs and reports success by job id", async () => {
|
||||
let resolveImport: (value: ReturnType<typeof createImportResult>) => void = () => undefined;
|
||||
const pendingImport = new Promise<ReturnType<typeof createImportResult>>((resolve) => {
|
||||
resolveImport = resolve;
|
||||
});
|
||||
mockCompanyPortabilityService.importBundle.mockReturnValueOnce(pendingImport);
|
||||
const app = await createApp(cloudTenantActor());
|
||||
|
||||
const accepted = await request(app)
|
||||
.post("/api/companies/import")
|
||||
.set("x-paperclip-cloud-async-import", "1")
|
||||
.set(cloudHeaders)
|
||||
.send(importRequest);
|
||||
|
||||
expect(accepted.status).toBe(202);
|
||||
expect(accepted.body.job.status).toBe("running");
|
||||
expect(accepted.body.statusUrl).toMatch(/^\/api\/companies\/import\/jobs\/tenant-import-/);
|
||||
expect(accepted.body.retryAfterMs).toBe(1000);
|
||||
await waitForCondition(() => mockCompanyPortabilityService.importBundle.mock.calls.length === 1, "import job start");
|
||||
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1");
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
|
||||
resolveImport(createImportResult("updated"));
|
||||
const succeeded = await waitForImportJobStatus(app, accepted.body.statusUrl, "succeeded");
|
||||
|
||||
expect(succeeded.status).toBe(200);
|
||||
expect(succeeded.body.job.status).toBe("succeeded");
|
||||
expect(succeeded.body.job.result.companyId).toBe(companyId);
|
||||
expect(succeeded.body.retryAfterMs).toBeUndefined();
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.imported",
|
||||
companyId,
|
||||
details: expect.objectContaining({
|
||||
agentCount: 1,
|
||||
warningCount: 0,
|
||||
companyAction: "updated",
|
||||
}),
|
||||
}));
|
||||
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(succeeded.body.job.completedAt) + (5 * 60 * 1000) + 1);
|
||||
try {
|
||||
const expired = await request(app).get(accepted.body.statusUrl).set(cloudHeaders);
|
||||
expect(expired.status).toBe(404);
|
||||
expect(expired.body.error).toBe("Import job not found");
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.sequential("reports trusted Cloud async import job failures with the tenant error message", async () => {
|
||||
mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("tenant import exploded"));
|
||||
const app = await createApp(cloudTenantActor());
|
||||
|
||||
const accepted = await request(app)
|
||||
.post("/api/companies/import")
|
||||
.set("x-paperclip-cloud-async-import", "1")
|
||||
.set(cloudHeaders)
|
||||
.send(importRequest);
|
||||
|
||||
expect(accepted.status).toBe(202);
|
||||
const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed");
|
||||
|
||||
expect(failed.status).toBe(200);
|
||||
expect(failed.body.job.status).toBe("failed");
|
||||
expect(failed.body.job.error.message).toBe("tenant import exploded");
|
||||
expect(failed.body.retryAfterMs).toBeUndefined();
|
||||
expect(failed.body.message).toBe("tenant import exploded");
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.sequential("accepts trusted Cloud async import jobs before validating the full import payload", async () => {
|
||||
const app = await createApp(cloudTenantActor());
|
||||
|
||||
const accepted = await request(app)
|
||||
.post("/api/companies/import")
|
||||
.set("x-paperclip-cloud-async-import", "1")
|
||||
.set(cloudHeaders)
|
||||
.send({ target: { mode: "existing_company", companyId } });
|
||||
|
||||
expect(accepted.status).toBe(202);
|
||||
expect(accepted.body.job.status).toBe("running");
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
|
||||
const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed");
|
||||
|
||||
expect(failed.status).toBe(200);
|
||||
expect(failed.body.job.status).toBe("failed");
|
||||
expect(failed.body.job.error.message).toEqual(expect.any(String));
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.sequential("keeps global import apply synchronous when Cloud async opt-in is absent", async () => {
|
||||
mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created"));
|
||||
const app = await createApp(cloudTenantActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/import")
|
||||
.set(cloudHeaders)
|
||||
.send(importRequest);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.company.id).toBe(companyId);
|
||||
expect(res.body.company.action).toBe("created");
|
||||
expect(res.body.job).toBeUndefined();
|
||||
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.imported",
|
||||
companyId,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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", "performAction"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "getData" || method === "performAction") {
|
||||
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}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,7 @@ describe("instance settings routes", () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
@@ -81,6 +82,7 @@ describe("instance settings routes", () => {
|
||||
experimental: {
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
@@ -123,6 +125,7 @@ describe("instance settings routes", () => {
|
||||
expect(getRes.body).toEqual({
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeExperimentalSettings } from "../services/instance-settings.js";
|
||||
|
||||
describe("instance settings service", () => {
|
||||
it("ignores retired experimental flags without resetting current settings", () => {
|
||||
expect(normalizeExperimentalSettings({
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 48,
|
||||
enableNewestFirstIssueThread: true,
|
||||
})).toEqual({
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 48,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -773,6 +773,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
const normalIssueId = randomUUID();
|
||||
const pluginVisibleIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
const typedOperationIssueId = randomUUID();
|
||||
const legacyContentMachineOperationIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -826,12 +828,36 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
originId: "mission-alpha:operation-1",
|
||||
},
|
||||
{
|
||||
id: typedOperationIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Typed plugin operation issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclip.missions:operation:evaluation",
|
||||
originId: "mission-alpha:operation-2",
|
||||
},
|
||||
{
|
||||
id: legacyContentMachineOperationIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Legacy Content Machine operation issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclipai.content-machine:evaluation",
|
||||
originId: "content-machine-operation-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
||||
expect(defaultIssueIds).toContain(normalIssueId);
|
||||
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
|
||||
expect(defaultIssueIds).not.toContain(operationIssueId);
|
||||
expect(defaultIssueIds).not.toContain(typedOperationIssueId);
|
||||
expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
||||
|
||||
const inboxIssueIds = (await svc.list(companyId, {
|
||||
assigneeAgentId: agentId,
|
||||
@@ -840,17 +866,28 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
})).map((issue) => issue.id);
|
||||
expect(inboxIssueIds).toContain(normalIssueId);
|
||||
expect(inboxIssueIds).not.toContain(operationIssueId);
|
||||
expect(inboxIssueIds).not.toContain(typedOperationIssueId);
|
||||
expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
||||
|
||||
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" }))
|
||||
.resolves.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: operationIssueId }),
|
||||
expect.objectContaining({ id: typedOperationIssueId }),
|
||||
]));
|
||||
|
||||
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
||||
expect(projectIssueIds).toContain(operationIssueId);
|
||||
expect(projectIssueIds).toContain(typedOperationIssueId);
|
||||
expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId);
|
||||
|
||||
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
||||
expect(advancedIssueIds).toContain(operationIssueId);
|
||||
expect(advancedIssueIds).toContain(typedOperationIssueId);
|
||||
expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId);
|
||||
});
|
||||
|
||||
it("excludes plugin operation issues from unread inbox counts", async () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,17 @@ function boardActor(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function agentActor(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId: agentA,
|
||||
companyId: companyA,
|
||||
runId: runA,
|
||||
source: "agent_jwt",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function readyPlugin() {
|
||||
mockRegistry.getById.mockResolvedValue({
|
||||
id: pluginId,
|
||||
@@ -613,6 +624,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 });
|
||||
@@ -634,10 +667,160 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
|
||||
key: "sync",
|
||||
params: {},
|
||||
actorContext: {
|
||||
type: "user",
|
||||
userId: "admin-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
companyId: null,
|
||||
},
|
||||
renderEnvironment: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes authenticated actor context and overrides spoofed company scope for plugin actions", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { app } = await createApp(boardActor({ runId: runA }), {}, {
|
||||
bridgeDeps: {
|
||||
workerManager: { call },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/actions/sync`)
|
||||
.send({
|
||||
companyId: companyA,
|
||||
params: {
|
||||
companyId: companyB,
|
||||
reviewerUserId: "spoofed-user",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
|
||||
key: "sync",
|
||||
params: {
|
||||
companyId: companyA,
|
||||
reviewerUserId: "spoofed-user",
|
||||
},
|
||||
actorContext: {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
agentId: null,
|
||||
runId: runA,
|
||||
companyId: companyA,
|
||||
},
|
||||
renderEnvironment: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses null for board actor userId when no authenticated user id is present", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { app } = await createApp(boardActor({ userId: undefined }), {}, {
|
||||
bridgeDeps: {
|
||||
workerManager: { call },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/actions/sync`)
|
||||
.send({ companyId: companyA });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "performAction", expect.objectContaining({
|
||||
actorContext: expect.objectContaining({
|
||||
type: "user",
|
||||
userId: null,
|
||||
companyId: companyA,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows agent-scoped plugin actions with authenticated actor context", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { app } = await createApp(agentActor(), {}, {
|
||||
bridgeDeps: {
|
||||
workerManager: { call },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/actions/sync`)
|
||||
.send({
|
||||
companyId: companyA,
|
||||
params: {
|
||||
companyId: companyB,
|
||||
reviewerAgentId: "spoofed-agent",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
|
||||
key: "sync",
|
||||
params: {
|
||||
companyId: companyA,
|
||||
reviewerAgentId: "spoofed-agent",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: agentA,
|
||||
runId: runA,
|
||||
companyId: companyA,
|
||||
},
|
||||
renderEnvironment: null,
|
||||
});
|
||||
|
||||
call.mockClear();
|
||||
const legacyRes = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/bridge/action`)
|
||||
.send({
|
||||
key: "sync",
|
||||
companyId: companyA,
|
||||
params: {
|
||||
companyId: companyB,
|
||||
reviewerAgentId: "spoofed-agent",
|
||||
},
|
||||
});
|
||||
|
||||
expect(legacyRes.status).toBe(200);
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
|
||||
key: "sync",
|
||||
params: {
|
||||
companyId: companyA,
|
||||
reviewerAgentId: "spoofed-agent",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: agentA,
|
||||
runId: runA,
|
||||
companyId: companyA,
|
||||
},
|
||||
renderEnvironment: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects agent plugin actions outside the authenticated company scope", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { app } = await createApp(agentActor(), {}, {
|
||||
bridgeDeps: {
|
||||
workerManager: { call },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/actions/sync`)
|
||||
.send({ companyId: companyB });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches worker bridge errors to the HTTP logger context", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockRejectedValue(new Error("missing source_objects column"));
|
||||
|
||||
@@ -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,236 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes performAction invocation scope to nested worker host calls", async () => {
|
||||
const companiesGet = vi.fn(async (
|
||||
params: { companyId: string },
|
||||
context?: { invocationScope?: { companyId?: string | null } | null },
|
||||
) => ({
|
||||
id: params.companyId,
|
||||
scopedCompanyId: context?.invocationScope?.companyId ?? null,
|
||||
}));
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: {
|
||||
"companies.get": companiesGet as never,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("performAction", {
|
||||
key: "probe",
|
||||
params: {
|
||||
mode: "echo",
|
||||
requestedCompanyId: "company-a",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-a",
|
||||
},
|
||||
renderEnvironment: null,
|
||||
})).resolves.toEqual({
|
||||
id: "company-a",
|
||||
scopedCompanyId: "company-a",
|
||||
});
|
||||
expect(companiesGet).toHaveBeenCalledWith(
|
||||
{ companyId: "company-a" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes echoed invocation scope to worker-to-host handlers", async () => {
|
||||
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
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 performAction nested host calls that omit the invocation id", async () => {
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "test.plugin",
|
||||
capabilities: ["companies.read"],
|
||||
services: {
|
||||
companies: {
|
||||
list: vi.fn(async () => []),
|
||||
get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })),
|
||||
},
|
||||
} as unknown as HostServices,
|
||||
});
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: handlers,
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("performAction", {
|
||||
key: "probe",
|
||||
params: {
|
||||
requestedCompanyId: "company-b",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-a",
|
||||
},
|
||||
renderEnvironment: null,
|
||||
})).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
message: expect.stringContaining("unknown invocation scope"),
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects nested worker host calls that forge an unknown invocation id", async () => {
|
||||
const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId }));
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "test.plugin",
|
||||
capabilities: ["companies.read"],
|
||||
services: {
|
||||
companies: {
|
||||
get: companiesGet,
|
||||
},
|
||||
} as unknown as HostServices,
|
||||
});
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: handlers,
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("performAction", {
|
||||
key: "probe",
|
||||
params: {
|
||||
mode: "unknown",
|
||||
requestedCompanyId: "company-a",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-a",
|
||||
},
|
||||
renderEnvironment: null,
|
||||
})).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
message: expect.stringContaining("unknown invocation scope"),
|
||||
});
|
||||
expect(companiesGet).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
|
||||
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
|
||||
const hostHandlers = createHostClientHandlers({
|
||||
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.INVOCATION_SCOPE_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),
|
||||
@@ -162,6 +166,7 @@ vi.mock("../services/index.js", () => ({
|
||||
},
|
||||
})),
|
||||
})),
|
||||
reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
routineService: vi.fn(() => ({
|
||||
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
|
||||
|
||||
+8
-1
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
inspectMigrations,
|
||||
applyPendingMigrations,
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
reconcilePendingMigrationHistory,
|
||||
formatDatabaseBackupResult,
|
||||
runDatabaseBackup,
|
||||
@@ -30,8 +31,10 @@ import { logger } from "./middleware/logger.js";
|
||||
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||
import {
|
||||
feedbackService,
|
||||
backfillPrincipalAccessCompatibility,
|
||||
heartbeatService,
|
||||
instanceSettingsService,
|
||||
reconcileCloudUpstreamRunsOnStartup,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
routineService,
|
||||
} from "./services/index.js";
|
||||
@@ -316,6 +319,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
"Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.",
|
||||
);
|
||||
}
|
||||
await prepareEmbeddedPostgresNativeRuntime();
|
||||
|
||||
const dataDir = resolve(config.embeddedPostgresDataDir);
|
||||
const configuredPort = config.embeddedPostgresPort;
|
||||
@@ -512,6 +516,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,
|
||||
@@ -694,6 +702,19 @@ export async function startServer(): Promise<StartedServer> {
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
|
||||
});
|
||||
|
||||
void reconcileCloudUpstreamRunsOnStartup(db as any)
|
||||
.then((result) => {
|
||||
if (result.reconciled > 0) {
|
||||
logger.warn(
|
||||
{ reconciled: result.reconciled },
|
||||
"reconciled cloud upstream runs from a previous server process",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup reconciliation of cloud upstream runs failed");
|
||||
});
|
||||
|
||||
if (config.heartbeatSchedulerEnabled) {
|
||||
const heartbeat = heartbeatService(db as any, { pluginWorkerManager });
|
||||
|
||||
+38
-48
@@ -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 }) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { badRequest, notFound } from "../errors.js";
|
||||
import { assertBoardOrgAccess } from "./authz.js";
|
||||
import { cloudUpstreamService, instanceSettingsService } from "../services/index.js";
|
||||
|
||||
export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {}) {
|
||||
const router = Router();
|
||||
const service = cloudUpstreamService(db, options);
|
||||
const settings = instanceSettingsService(db);
|
||||
|
||||
async function assertEnabled() {
|
||||
const experimental = await settings.getExperimental();
|
||||
if (experimental.enableCloudSync !== true) {
|
||||
throw notFound("Cloud sync is not enabled");
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/cloud-upstreams", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
const companyId = stringQuery(req.query.companyId, "companyId");
|
||||
res.json(await service.list(companyId));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/connect/start", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
const companyId = stringBody(req.body, "companyId");
|
||||
const remoteUrl = stringBody(req.body, "remoteUrl");
|
||||
const redirectUri = stringBody(req.body, "redirectUri");
|
||||
res.json(await service.startConnect({ companyId, remoteUrl, redirectUri }));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/connect/finish", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.finishConnect({
|
||||
pendingConnectionId: stringBody(req.body, "pendingConnectionId"),
|
||||
code: stringBody(req.body, "code"),
|
||||
state: stringBody(req.body, "state"),
|
||||
}));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs/preview", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.preview(req.params.connectionId, stringBody(req.body, "companyId")));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.createRun({
|
||||
connectionId: req.params.connectionId,
|
||||
companyId: stringBody(req.body, "companyId"),
|
||||
retryOfRunId: optionalString(req.body?.retryOfRunId),
|
||||
}));
|
||||
});
|
||||
|
||||
router.get("/cloud-upstreams/:connectionId/push-runs/:runId", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.readRun(req.params.connectionId, req.params.runId, stringQuery(req.query.companyId, "companyId")));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs/:runId/cancel", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.cancelRun(req.params.connectionId, req.params.runId, stringBody(req.body, "companyId")));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs/:runId/activation", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.activateRunEntities({
|
||||
connectionId: req.params.connectionId,
|
||||
runId: req.params.runId,
|
||||
companyId: stringBody(req.body, "companyId"),
|
||||
entityType: activationEntityTypeBody(req.body),
|
||||
}));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function stringQuery(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw badRequest(`${label} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringBody(body: unknown, key: string): string {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw badRequest(`${key} is required`);
|
||||
}
|
||||
const value = (body as Record<string, unknown>)[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw badRequest(`${key} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function activationEntityTypeBody(body: unknown): "agents" | "routines" | "monitors" {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw badRequest("entityType is required");
|
||||
}
|
||||
const value = (body as Record<string, unknown>).entityType;
|
||||
if (value !== "agents" && value !== "routines" && value !== "monitors") {
|
||||
throw badRequest("entityType must be agents, routines, or monitors");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+213
-20
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -35,6 +36,8 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
||||
const access = accessService(db);
|
||||
const budgets = budgetService(db);
|
||||
const feedback = feedbackService(db);
|
||||
const importJobs = new Map<string, ImportJobRecord>();
|
||||
const importJobTerminalRetentionMs = 5 * 60 * 1000;
|
||||
|
||||
function parseBooleanQuery(value: unknown) {
|
||||
return value === true || value === "true" || value === "1";
|
||||
@@ -177,27 +180,47 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
router.post(COMPANY_IMPORT_ROUTE_PATH, validate(companyPortabilityImportSchema), async (req, res) => {
|
||||
router.get("/import/jobs/:jobId", async (req, res) => {
|
||||
assertCloudTenantCaller(req);
|
||||
cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
|
||||
const job = importJobs.get(req.params.jobId as string);
|
||||
if (!job || job.cloudTenantKey !== cloudTenantRequestKey(req)) {
|
||||
res.status(404).json({ error: "Import job not found" });
|
||||
return;
|
||||
}
|
||||
res.json(importJobResponse(job));
|
||||
});
|
||||
|
||||
router.post(COMPANY_IMPORT_ROUTE_PATH, async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertImportTargetAccess(req, req.body.target);
|
||||
const rawImportBody: unknown = req.body;
|
||||
const actor = getActorInfo(req);
|
||||
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
|
||||
await logActivity(db, {
|
||||
companyId: result.company.id,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: "company.imported",
|
||||
entityType: "company",
|
||||
entityId: result.company.id,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
details: {
|
||||
include: req.body.include ?? null,
|
||||
agentCount: result.agents.length,
|
||||
warningCount: result.warnings.length,
|
||||
companyAction: result.company.action,
|
||||
},
|
||||
});
|
||||
const boardUserId = req.actor.type === "board" ? req.actor.userId : null;
|
||||
if (req.header("x-paperclip-cloud-async-import") === "1") {
|
||||
assertCloudTenantCaller(req);
|
||||
cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
|
||||
const job = createImportJob(cloudTenantRequestKey(req));
|
||||
importJobs.set(job.id, job);
|
||||
const operation = async () => {
|
||||
const importBody = companyPortabilityImportSchema.parse(rawImportBody);
|
||||
assertImportTargetAccess(req, importBody.target);
|
||||
const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
|
||||
const result = await portability.importBundle(importBody, boardUserId);
|
||||
await logImportedCompanyActivity(db, activity, result);
|
||||
return result;
|
||||
};
|
||||
res.status(202).json(importJobAcceptedResponse(job));
|
||||
setImmediate(() => {
|
||||
void runImportJob(job, operation);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const importBody = companyPortabilityImportSchema.parse(rawImportBody);
|
||||
assertImportTargetAccess(req, importBody.target);
|
||||
const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
|
||||
const result = await portability.importBundle(importBody, boardUserId);
|
||||
await logImportedCompanyActivity(db, activity, result);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -271,7 +294,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",
|
||||
@@ -412,3 +442,166 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
type CompanyImportResult = {
|
||||
company: { id: string; action: unknown };
|
||||
agents: unknown[];
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
interface ImportJobRecord {
|
||||
id: string;
|
||||
cloudTenantKey: string;
|
||||
status: "running" | "succeeded" | "failed";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
error?: { message: string };
|
||||
result?: {
|
||||
companyId: string;
|
||||
agentCount: number;
|
||||
warningCount: number;
|
||||
companyAction: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportedCompanyActivityContext {
|
||||
actorType: "user" | "agent";
|
||||
actorId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
include: unknown;
|
||||
}
|
||||
|
||||
function assertCloudTenantCaller(req: Request) {
|
||||
if (req.actor.source !== "cloud_tenant") {
|
||||
throw forbidden("Trusted Cloud tenant access required");
|
||||
}
|
||||
}
|
||||
|
||||
function cloudTenantRequestKey(req: Request) {
|
||||
return [
|
||||
req.actor.userId ?? "",
|
||||
req.header("x-paperclip-cloud-stack-id")?.trim() ?? "",
|
||||
req.header("x-paperclip-cloud-paperclip-company-id")?.trim() ?? "",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function createImportJob(cloudTenantKey: string): ImportJobRecord {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `tenant-import-${randomUUID()}`,
|
||||
cloudTenantKey,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function runImportJob(
|
||||
job: ImportJobRecord,
|
||||
operation: () => Promise<CompanyImportResult>,
|
||||
) {
|
||||
try {
|
||||
const result = await operation();
|
||||
const now = new Date().toISOString();
|
||||
job.status = "succeeded";
|
||||
job.updatedAt = now;
|
||||
job.completedAt = now;
|
||||
job.result = {
|
||||
companyId: result.company.id,
|
||||
agentCount: result.agents.length,
|
||||
warningCount: result.warnings.length,
|
||||
companyAction: result.company.action,
|
||||
};
|
||||
} catch (error) {
|
||||
const now = new Date().toISOString();
|
||||
job.status = "failed";
|
||||
job.updatedAt = now;
|
||||
job.completedAt = now;
|
||||
job.error = { message: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function importedCompanyActivityContext(
|
||||
actor: ReturnType<typeof getActorInfo>,
|
||||
include: unknown,
|
||||
): ImportedCompanyActivityContext {
|
||||
return {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
include,
|
||||
};
|
||||
}
|
||||
|
||||
async function logImportedCompanyActivity(
|
||||
db: Db,
|
||||
activity: ImportedCompanyActivityContext,
|
||||
result: CompanyImportResult,
|
||||
) {
|
||||
await logActivity(db, {
|
||||
companyId: result.company.id,
|
||||
actorType: activity.actorType,
|
||||
actorId: activity.actorId,
|
||||
action: "company.imported",
|
||||
entityType: "company",
|
||||
entityId: result.company.id,
|
||||
agentId: activity.agentId,
|
||||
runId: activity.runId,
|
||||
details: {
|
||||
include: activity.include,
|
||||
agentCount: result.agents.length,
|
||||
warningCount: result.warnings.length,
|
||||
companyAction: result.company.action,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function importJobAcceptedResponse(job: ImportJobRecord) {
|
||||
return {
|
||||
job: {
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
},
|
||||
statusUrl: `/api/companies/import/jobs/${encodeURIComponent(job.id)}`,
|
||||
retryAfterMs: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function importJobResponse(job: ImportJobRecord) {
|
||||
const isTerminal = job.status === "succeeded" || job.status === "failed";
|
||||
const response: Record<string, unknown> = {
|
||||
job: {
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
...(job.completedAt ? { completedAt: job.completedAt } : {}),
|
||||
...(job.error ? { error: job.error } : {}),
|
||||
...(job.result ? { result: job.result } : {}),
|
||||
},
|
||||
...(isTerminal ? {} : { retryAfterMs: 1000 }),
|
||||
};
|
||||
if (job.error?.message) {
|
||||
response.error = job.error.message;
|
||||
response.message = job.error.message;
|
||||
response.reason = job.error.message;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function cleanupTerminalImportJobs(importJobs: Map<string, ImportJobRecord>, terminalRetentionMs: number) {
|
||||
const now = Date.now();
|
||||
for (const [jobId, job] of importJobs) {
|
||||
if (job.status === "running" || !job.completedAt) continue;
|
||||
if (now - Date.parse(job.completedAt) > terminalRetentionMs) {
|
||||
importJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error && error.message.trim() ? error.message : String(error);
|
||||
}
|
||||
@@ -19,3 +19,4 @@ export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
|
||||
export { cloudUpstreamRoutes } from "./cloud-upstreams.js";
|
||||
+88
-48
@@ -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);
|
||||
|
||||
@@ -55,7 +55,7 @@ import type { PluginJobStore } from "../services/plugin-job-store.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
|
||||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
|
||||
import type { PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk";
|
||||
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
assertAuthenticated,
|
||||
@@ -572,6 +572,43 @@ export function pluginRoutes(
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function performActionActorContext(req: Request, companyId: string | undefined): PluginPerformActionActorContext {
|
||||
const scopedCompanyId = companyId ?? null;
|
||||
if (req.actor.type === "agent") {
|
||||
return {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: req.actor.agentId ?? null,
|
||||
runId: req.actor.runId ?? null,
|
||||
companyId: scopedCompanyId,
|
||||
};
|
||||
}
|
||||
if (req.actor.type === "board") {
|
||||
return {
|
||||
type: "user",
|
||||
userId: req.actor.userId ?? null,
|
||||
agentId: null,
|
||||
runId: req.actor.runId ?? null,
|
||||
companyId: scopedCompanyId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "system",
|
||||
userId: null,
|
||||
agentId: null,
|
||||
runId: req.actor.runId ?? null,
|
||||
companyId: scopedCompanyId,
|
||||
};
|
||||
}
|
||||
|
||||
function actionParamsWithAuthorizedCompanyScope(
|
||||
params: Record<string, unknown> | undefined,
|
||||
companyId: string | undefined,
|
||||
): Record<string, unknown> {
|
||||
const base = params ?? {};
|
||||
return companyId === undefined ? base : { ...base, companyId };
|
||||
}
|
||||
|
||||
async function validateToolRunContextScope(runContext: ToolRunContext): Promise<string | null> {
|
||||
const [agent] = await db
|
||||
.select({ companyId: agents.companyId })
|
||||
@@ -984,6 +1021,12 @@ export function pluginRoutes(
|
||||
message: err.message,
|
||||
details: err.data,
|
||||
};
|
||||
case PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED:
|
||||
return {
|
||||
code: "INVOCATION_SCOPE_DENIED",
|
||||
message: err.message,
|
||||
details: err.data,
|
||||
};
|
||||
case PLUGIN_RPC_ERROR_CODES.TIMEOUT:
|
||||
return {
|
||||
code: "TIMEOUT",
|
||||
@@ -1116,7 +1159,7 @@ export function pluginRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
assertPluginBridgeScope(req, body.companyId);
|
||||
const companyId = assertPluginBridgeScope(req, body.companyId);
|
||||
|
||||
try {
|
||||
const result = await bridgeDeps.workerManager.call(
|
||||
@@ -1124,6 +1167,7 @@ export function pluginRoutes(
|
||||
"getData",
|
||||
{
|
||||
key: body.key,
|
||||
...(companyId ? { companyId } : {}),
|
||||
params: body.params ?? {},
|
||||
renderEnvironment: body.renderEnvironment ?? null,
|
||||
},
|
||||
@@ -1170,7 +1214,7 @@ export function pluginRoutes(
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
router.post("/plugins/:pluginId/bridge/action", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertAuthenticated(req);
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -1208,7 +1252,7 @@ export function pluginRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
assertPluginBridgeScope(req, body.companyId);
|
||||
const companyId = assertPluginBridgeScope(req, body.companyId);
|
||||
|
||||
try {
|
||||
const result = await bridgeDeps.workerManager.call(
|
||||
@@ -1216,7 +1260,8 @@ export function pluginRoutes(
|
||||
"performAction",
|
||||
{
|
||||
key: body.key,
|
||||
params: body.params ?? {},
|
||||
params: actionParamsWithAuthorizedCompanyScope(body.params, companyId),
|
||||
actorContext: performActionActorContext(req, companyId),
|
||||
renderEnvironment: body.renderEnvironment ?? null,
|
||||
},
|
||||
);
|
||||
@@ -1301,7 +1346,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 +1354,7 @@ export function pluginRoutes(
|
||||
"getData",
|
||||
{
|
||||
key,
|
||||
...(companyId ? { companyId } : {}),
|
||||
params: body?.params ?? {},
|
||||
renderEnvironment: body?.renderEnvironment ?? null,
|
||||
},
|
||||
@@ -1352,7 +1398,7 @@ export function pluginRoutes(
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
router.post("/plugins/:pluginId/actions/:key", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertAuthenticated(req);
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -1390,7 +1436,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,7 +1444,8 @@ export function pluginRoutes(
|
||||
"performAction",
|
||||
{
|
||||
key,
|
||||
params: body?.params ?? {},
|
||||
params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId),
|
||||
actorContext: performActionActorContext(req, companyId),
|
||||
renderEnvironment: body?.renderEnvironment ?? null,
|
||||
},
|
||||
);
|
||||
@@ -1602,7 +1649,10 @@ export function pluginRoutes(
|
||||
} catch (err) {
|
||||
const status = typeof (err as { status?: unknown }).status === "number"
|
||||
? (err as { status: number }).status
|
||||
: err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED
|
||||
: err instanceof JsonRpcCallError && (
|
||||
err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED ||
|
||||
err.code === PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
|
||||
)
|
||||
? 403
|
||||
: err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
|
||||
? 501
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -265,7 +265,17 @@ export function companyService(db: Db) {
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
// Delete from child tables in dependency order
|
||||
const companyRunIds = await tx
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.companyId, id));
|
||||
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
|
||||
if (companyRunIds.length > 0) {
|
||||
await tx
|
||||
.delete(heartbeatRunEvents)
|
||||
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
|
||||
}
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
||||
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user