From 515e628642e3ca3aa119ed1edb73718050caa348 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 5 Jun 2026 02:59:43 +0000 Subject: [PATCH] PAP-10287 finalize teams catalog extraction Co-Authored-By: Paperclip --- cli/src/__tests__/company.test.ts | 189 +++++++++++++++- cli/src/__tests__/teams.test.ts | 149 +++++++++++++ cli/src/commands/client/company.ts | 88 +++++++- cli/src/commands/client/teams.ts | 126 ++++++++++- doc/CLI.md | 18 ++ docs/cli/control-plane-commands.md | 8 + .../product-engineering/.paperclip.yaml | 5 + packages/teams-catalog/generated/catalog.json | 12 +- .../agent-permissions-service.test.ts | 19 ++ ...teams-catalog-install-no-overrides.test.ts | 2 + .../__tests__/teams-catalog-service.test.ts | 25 +++ server/src/services/agent-permissions.ts | 2 +- server/src/services/teams-catalog.ts | 203 ++++++++++++++---- 13 files changed, 793 insertions(+), 53 deletions(-) create mode 100644 packages/teams-catalog/catalog/bundled/software-development/product-engineering/.paperclip.yaml create mode 100644 server/src/__tests__/agent-permissions-service.test.ts diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index 2736dd40b00..4cf27e8f676 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared"; import { buildCompanyDashboardUrl, @@ -8,10 +9,196 @@ import { buildSelectedFilesFromImportSelection, renderCompanyImportPreview, renderCompanyImportResult, + registerCompanyCommands, resolveCompanyImportApplyConfirmationMode, resolveCompanyImportApiPath, } from "../commands/client/company.js"; +const ORIGINAL_ENV = { ...process.env }; +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; + +function makeProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: () => undefined, + writeErr: () => undefined, + }); + registerCompanyCommands(program); + return program; +} + +async function runCommand(args: string[]): Promise { + await makeProgram().parseAsync(args, { from: "user" }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function company(overrides: Record = {}) { + return { + id: COMPANY_ID, + name: "Paperclip", + description: null, + status: "active", + issuePrefix: "PAP", + issueCounter: 1, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + attachmentMaxBytes: 1073741824, + requireBoardApprovalForNewAgents: false, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + brandColor: "#5c5fff", + logoAssetId: null, + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + logoUrl: null, + ...overrides, + }; +} + +describe("company CLI commands", () => { + let fetchMock: ReturnType; + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_API_URL; + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_COMPANY_ID; + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("gets the current company from an explicit company context without board-wide listing", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(company())); + + await runCommand([ + "company", + "current", + "--company-id", + COMPANY_ID, + "--api-base", + "http://paperclip.test", + "--api-key", + "agent-token", + "--json", + ]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `http://paperclip.test/api/companies/${COMPANY_ID}`, + expect.objectContaining({ method: "GET" }), + ); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ id: COMPANY_ID, name: "Paperclip" }); + }); + + it("gets the current company from agent authentication when no company context is set", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ id: "agent-1", companyId: COMPANY_ID })) + .mockResolvedValueOnce(jsonResponse(company())); + + await runCommand([ + "company", + "current", + "--api-base", + "http://paperclip.test", + "--api-key", + "agent-token", + "--json", + ]); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "http://paperclip.test/api/agents/me", + expect.objectContaining({ method: "GET" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `http://paperclip.test/api/companies/${COMPANY_ID}`, + expect.objectContaining({ method: "GET" }), + ); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ id: COMPANY_ID, name: "Paperclip" }); + }); + + it("lists the scoped agent company when board-wide company listing is denied", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ error: "Board access required" }, 403)) + .mockResolvedValueOnce(jsonResponse({ id: "agent-1", companyId: COMPANY_ID })) + .mockResolvedValueOnce(jsonResponse(company())); + + await runCommand([ + "company", + "list", + "--api-base", + "http://paperclip.test", + "--api-key", + "agent-token", + "--json", + ]); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "http://paperclip.test/api/companies", + expect.objectContaining({ method: "GET" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "http://paperclip.test/api/agents/me", + expect.objectContaining({ method: "GET" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + `http://paperclip.test/api/companies/${COMPANY_ID}`, + expect.objectContaining({ method: "GET" }), + ); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject([{ id: COMPANY_ID, name: "Paperclip" }]); + }); + + it("explains that company creation requires board instance-admin authentication under agent auth", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: "Board access required" }, 403)); + vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { + throw new Error(`exit:${code ?? 0}`); + }) as typeof process.exit); + + await expect(runCommand([ + "company", + "create", + "--payload-json", + "{\"name\":\"Disposable\"}", + "--api-base", + "http://paperclip.test", + "--api-key", + "agent-token", + "--json", + ])).rejects.toThrow("exit:1"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://paperclip.test/api/companies", + expect.objectContaining({ method: "POST" }), + ); + const rendered = String(errorSpy.mock.calls[0]?.[0]); + expect(rendered).toContain("Creating companies requires board/instance-admin authentication"); + expect(rendered).toContain("company list --json"); + }); +}); + describe("resolveCompanyImportApiPath", () => { it("uses company-scoped preview route for existing-company dry runs", () => { expect( diff --git a/cli/src/__tests__/teams.test.ts b/cli/src/__tests__/teams.test.ts index 2ae71e45e02..41113566a71 100644 --- a/cli/src/__tests__/teams.test.ts +++ b/cli/src/__tests__/teams.test.ts @@ -353,6 +353,155 @@ describe("teams CLI commands", () => { expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result); }); + it("requests board approval when agent-run install lacks create-agent permission", async () => { + const approval = { + id: "approval-1", + companyId: "company-1", + type: "request_board_approval", + requestedByAgentId: "agent-1", + requestedByUserId: null, + status: "pending", + payload: {}, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }; + fetchMock + .mockResolvedValueOnce(jsonResponse({ error: "Missing permission: can create agents" }, 403)) + .mockResolvedValueOnce(jsonResponse(approval, 201)); + + await runCommand([ + "teams", + "install", + "product-engineering", + "--target-manager-agent-id", + "manager-1", + "--collision-strategy", + "rename", + "--company-id", + "company-1", + "--request-approval-on-forbidden", + "--approval-issue-id", + "11111111-1111-4111-8111-111111111111", + "--api-base", + "http://paperclip.test", + "--api-key", + "token", + "--json", + ]); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=product-engineering", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + targetManagerAgentId: "manager-1", + collisionStrategy: "rename", + }), + }), + ); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "http://paperclip.test/api/companies/company-1/approvals", + expect.objectContaining({ + method: "POST", + body: expect.any(String), + }), + ); + const approvalPayload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)); + expect(approvalPayload).toMatchObject({ + type: "request_board_approval", + issueIds: ["11111111-1111-4111-8111-111111111111"], + payload: { + title: "Approve catalog team install: product-engineering", + installAttempt: { + companyId: "company-1", + catalogRef: "product-engineering", + deniedReason: "Missing permission: can create agents", + options: { + targetManagerAgentId: "manager-1", + collisionStrategy: "rename", + }, + }, + }, + }); + const rendered = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(rendered).toMatchObject({ + status: "approval_requested", + approval, + installAttempt: { + companyId: "company-1", + catalogRef: "product-engineering", + deniedReason: "Missing permission: can create agents", + }, + }); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("auto-requests board approval for forbidden installs inside a Paperclip task run", async () => { + process.env.PAPERCLIP_TASK_ID = "11111111-1111-4111-8111-111111111111"; + const approval = { + id: "approval-2", + companyId: "company-1", + type: "request_board_approval", + requestedByAgentId: "agent-1", + requestedByUserId: null, + status: "pending", + payload: {}, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }; + fetchMock + .mockResolvedValueOnce(jsonResponse({ error: "Missing permission: can create agents" }, 403)) + .mockResolvedValueOnce(jsonResponse(approval, 201)); + + await runCommand([ + "teams", + "install", + "product-engineering", + "--company-id", + "company-1", + "--api-base", + "http://paperclip.test", + "--api-key", + "token", + "--json", + ]); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "http://paperclip.test/api/companies/company-1/approvals", + expect.objectContaining({ + method: "POST", + body: expect.any(String), + }), + ); + const approvalPayload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)); + expect(approvalPayload).toMatchObject({ + type: "request_board_approval", + issueIds: ["11111111-1111-4111-8111-111111111111"], + payload: { + installAttempt: { + companyId: "company-1", + catalogRef: "product-engineering", + deniedReason: "Missing permission: can create agents", + }, + }, + }); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + status: "approval_requested", + approval, + }); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it("surfaces server blocks for unsafe local-path catalog team sources", async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'Local path skill source "../unsafe" is development-only and is not allowed for catalog team install.', diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 8d0c13c67b1..bafdbd00d54 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -36,6 +36,10 @@ interface CompanyJsonOptions extends BaseClientOptions { companyId?: string; payloadJson?: string; } +interface AgentMeResponse { + id: string; + companyId: string; +} type CompanyDeleteSelectorMode = "auto" | "id" | "prefix"; type CompanyImportTargetMode = "new" | "existing"; type CompanyCollisionMode = "rename" | "skip" | "replace"; @@ -1095,7 +1099,7 @@ export function registerCompanyCommands(program: Command): void { .action(async (opts: CompanyCommandOptions) => { try { const ctx = resolveCommandContext(opts); - const rows = (await ctx.api.get("/api/companies")) ?? []; + const rows = await listCompaniesForContext(ctx); if (ctx.json) { printOutput(rows, { json: true }); return; @@ -1139,6 +1143,23 @@ export function registerCompanyCommands(program: Command): void { }), ); + addCommonClientOptions( + company + .command("current") + .description("Get the current scoped company from --company-id, context, env, or agent authentication") + .action(async (opts: CompanyCommandOptions) => { + try { + const ctx = resolveCommandContext(opts); + const companyId = await resolveCurrentCompanyId(ctx); + const row = await ctx.api.get(apiPath`/api/companies/${companyId}`); + printOutput(row, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: true }, + ); + addCommonClientOptions( company .command("stats") @@ -1161,7 +1182,7 @@ export function registerCompanyCommands(program: Command): void { .action(async (opts: CompanyJsonOptions) => { try { const ctx = resolveCommandContext(opts); - printOutput(await ctx.api.post("/api/companies", parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + printOutput(await createCompanyForContext(ctx, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); } catch (err) { handleCommandError(err); } @@ -1673,6 +1694,69 @@ export function registerCompanyCommands(program: Command): void { ); } +async function listCompaniesForContext(ctx: { + companyId?: string; + api: { get(path: string): Promise }; +}): Promise { + try { + return (await ctx.api.get("/api/companies")) ?? []; + } catch (error) { + if (!isBoardAccessRequiredError(error)) { + throw error; + } + } + + const companyId = await resolveCurrentCompanyId(ctx); + const scopedCompany = await ctx.api.get(apiPath`/api/companies/${companyId}`); + return scopedCompany ? [scopedCompany] : []; +} + +async function createCompanyForContext(ctx: { + api: { post(path: string, body?: unknown): Promise }; +}, payload: unknown): Promise { + try { + return await ctx.api.post("/api/companies", payload); + } catch (error) { + if (isBoardAccessRequiredError(error) || isInstanceAdminRequiredError(error)) { + throw new Error( + "Creating companies requires board/instance-admin authentication. Agent API keys are scoped to one company; use `paperclipai company list --json` or `paperclipai company current --json` to select the scoped company, or rerun create with a board token/login.", + ); + } + throw error; + } +} + +async function resolveCurrentCompanyId(ctx: { companyId?: string; api: { get(path: string): Promise } }): Promise { + const fromContext = ctx.companyId?.trim(); + if (fromContext) return fromContext; + + let agent: AgentMeResponse | null = null; + try { + agent = await ctx.api.get("/api/agents/me"); + } catch (error) { + if (error instanceof ApiRequestError && (error.status === 401 || error.status === 403)) { + throw new Error( + "Current company is not available. Pass --company-id, set PAPERCLIP_COMPANY_ID, set a context profile companyId, or authenticate with an agent API key.", + ); + } + throw error; + } + + const fromAgent = agent?.companyId?.trim(); + if (fromAgent) return fromAgent; + throw new Error( + "Current company is not available. Pass --company-id, set PAPERCLIP_COMPANY_ID, set a context profile companyId, or authenticate with an agent API key.", + ); +} + +function isBoardAccessRequiredError(error: unknown): error is ApiRequestError { + return error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required"); +} + +function isInstanceAdminRequiredError(error: unknown): error is ApiRequestError { + return error instanceof ApiRequestError && error.status === 403 && error.message.includes("Instance admin"); +} + function addCompanyJsonPost(parent: Command, name: string, description: string, pathSuffix: string): void { addCommonClientOptions( parent diff --git a/cli/src/commands/client/teams.ts b/cli/src/commands/client/teams.ts index 730073948e2..2b4b9a1a5d2 100644 --- a/cli/src/commands/client/teams.ts +++ b/cli/src/commands/client/teams.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import type { + Approval, CatalogTeam, CatalogTeamImportPreviewResult, CatalogTeamInstallResult, @@ -10,6 +11,7 @@ import type { } from "@paperclipai/shared"; import { addCommonClientOptions, + apiPath, formatInlineRecord, handleCommandError, printOutput, @@ -17,6 +19,7 @@ import { type BaseClientOptions, type ResolvedClientContext, } from "./common.js"; +import { ApiRequestError } from "../../client/http.js"; interface TeamBrowseOptions extends BaseClientOptions { kind?: string; @@ -39,7 +42,21 @@ interface TeamPreviewOptions extends BaseClientOptions { allowLocalPathSources?: boolean; } -interface TeamInstallOptions extends TeamPreviewOptions {} +interface TeamInstallOptions extends TeamPreviewOptions { + requestApprovalOnForbidden?: boolean; + approvalIssueId?: string; +} + +interface TeamInstallApprovalFallbackResult { + status: "approval_requested"; + approval: Approval; + installAttempt: { + companyId: string; + catalogRef: string; + options: CatalogTeamInstallOptions; + deniedReason: string; + }; +} export function registerTeamCommands(program: Command): void { const teams = program.command("teams").description("App-shipped team catalog operations"); @@ -192,12 +209,19 @@ export function registerTeamCommands(program: Command): void { .option("--allow-external-sources", "Allow GitHub, URL, or skills.sh skill sources declared by the catalog team", false) .option("--allow-unpinned-optional-sources", "Allow optional-team external skill sources that are not pinned to a commit", false) .option("--allow-local-path-sources", "Development only: allow local-path skill sources declared by the catalog team", false) + .option( + "--request-approval-on-forbidden", + "When install is denied by agents:create permissions, create a board approval request instead of exiting with the raw 403", + false, + ) + .option("--approval-issue-id ", "Issue ID to link to the fallback approval request; defaults to PAPERCLIP_TASK_ID when set") .action(async (catalogRef: string, opts: TeamInstallOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); + const installOptions = buildTeamInstallOptions(opts); const result = await ctx.api.post( catalogTeamCompanyPath(ctx.companyId, catalogRef, "install"), - buildTeamInstallOptions(opts), + installOptions, ); if (ctx.json) { printOutput(result, { json: true }); @@ -205,6 +229,20 @@ export function registerTeamCommands(program: Command): void { } printCatalogTeamInstall(result); } catch (err) { + if (shouldRequestInstallApproval(err, opts)) { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const fallback = await requestInstallApproval(ctx, catalogRef, buildTeamInstallOptions(opts), opts, err); + if (ctx.json) { + printOutput(fallback, { json: true }); + return; + } + printInstallApprovalRequested(fallback); + return; + } catch (fallbackErr) { + handleCommandError(fallbackErr); + } + } handleCommandError(err); } }), @@ -323,6 +361,76 @@ function buildTeamInstallOptions(opts: TeamInstallOptions): CatalogTeamInstallOp return buildTeamOptions(opts); } +function shouldRequestInstallApproval(error: unknown, opts: TeamInstallOptions): error is ApiRequestError { + return Boolean( + (opts.requestApprovalOnForbidden || isPaperclipTaskRun()) && + error instanceof ApiRequestError && + error.status === 403, + ); +} + +function isPaperclipTaskRun(): boolean { + return Boolean(process.env.PAPERCLIP_TASK_ID?.trim()); +} + +async function requestInstallApproval( + ctx: ResolvedClientContext, + catalogRef: string, + installOptions: CatalogTeamInstallOptions, + opts: TeamInstallOptions, + error: ApiRequestError, +): Promise { + if (!ctx.companyId) throw new Error("Company ID is required."); + const trimmedRef = catalogRef.trim(); + const issueIds = resolveApprovalIssueIds(opts); + const payload = { + type: "request_board_approval", + issueIds, + payload: { + title: `Approve catalog team install: ${trimmedRef}`, + summary: + `A Paperclip CLI agent-run attempted to install catalog team "${trimmedRef}" into company "${ctx.companyId}", ` + + `but the API denied the install with: ${error.message}.`, + recommendedAction: + "Approve the catalog team source and rerun the install with a board or agent-creator token, or grant agents:create to the requesting agent and rerun the same command.", + risks: [ + "Catalog team installation can create agents, projects, tasks, routines, skills, and secret bindings.", + "Only approve after checking the catalog source, selected files, target manager, and collision strategy.", + ], + installAttempt: { + companyId: ctx.companyId, + catalogRef: trimmedRef, + options: installOptions, + deniedReason: error.message, + }, + }, + }; + const approval = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/approvals`, payload); + if (!approval) { + throw new Error("Approval request failed."); + } + return { + status: "approval_requested", + approval, + installAttempt: { + companyId: ctx.companyId, + catalogRef: trimmedRef, + options: installOptions, + deniedReason: error.message, + }, + }; +} + +function resolveApprovalIssueIds(opts: TeamInstallOptions): string[] | undefined { + const issueId = opts.approvalIssueId?.trim() || process.env.PAPERCLIP_TASK_ID?.trim(); + if (!issueId) return undefined; + return isUuidLike(issueId) ? [issueId] : undefined; +} + +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + function buildSourcePolicy(opts: TeamPreviewOptions): CatalogTeamSourcePolicy | undefined { const sourcePolicy = removeUndefined({ allowExternalSources: opts.allowExternalSources || undefined, @@ -505,6 +613,20 @@ function printCatalogTeamInstall(result: CatalogTeamInstallResult | null): void for (const warning of result.warnings) console.log(`warning=${warning}`); } +function printInstallApprovalRequested(result: TeamInstallApprovalFallbackResult): void { + console.log( + formatInlineRecord({ + status: result.status, + approvalId: result.approval.id, + approvalStatus: result.approval.status, + type: result.approval.type, + catalogRef: result.installAttempt.catalogRef, + deniedReason: result.installAttempt.deniedReason, + }), + ); + console.log("Install was not performed. The board must approve the request and rerun the install with an authorized token."); +} + function printTable(rows: Array>): void { if (rows.length === 0) { printOutput([], { json: false }); diff --git a/doc/CLI.md b/doc/CLI.md index 97f87e2c79d..3e31af639f2 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -119,6 +119,7 @@ export PAPERCLIP_API_KEY=... ```sh pnpm paperclipai company list pnpm paperclipai company get +pnpm paperclipai company current [--company-id ] pnpm paperclipai company stats pnpm paperclipai company create --payload-json '{...}' pnpm paperclipai company update --payload-json '{...}' @@ -142,6 +143,12 @@ pnpm paperclipai company delete 5cbe79ee-acb3-4597-896e-7662742593cd --yes --con Notes: +- With agent authentication, `company list` and `company current` are + agent-safe company selectors. `company list` first tries the board-wide list; + if that is forbidden, it uses `--company-id`, `PAPERCLIP_COMPANY_ID`, context, + or `/api/agents/me` and then reads only that scoped company. +- `company create` requires board/instance-admin authentication because it is + an instance-wide setup command. - Deletion is server-gated by `PAPERCLIP_ENABLE_COMPANY_DELETION`. - With agent authentication, company deletion is company-scoped. Use the current company ID/prefix (for example via `--company-id` or `PAPERCLIP_COMPANY_ID`), not another company. @@ -510,6 +517,17 @@ pnpm paperclipai teams install --company-id ` to attach it to a specific issue. During Paperclip + task runs with `PAPERCLIP_TASK_ID` set, this fallback is automatic so + agent-run walkthroughs leave a pending approval path instead of a raw 403. - `--target-manager-agent-id ` or `--target-manager-slug ` reparents catalog root agents under an existing manager. - `--agent ` and `--selected-file ` narrow the import. diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index 8353f2a9ff3..29e1101d011 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -35,6 +35,7 @@ pnpm paperclipai issue release ```sh pnpm paperclipai company list pnpm paperclipai company get +pnpm paperclipai company current [--company-id ] # Export to portable folder package (writes manifest + markdown files) pnpm paperclipai company export --out ./exports/acme --include company,agents @@ -56,6 +57,13 @@ pnpm paperclipai company import \ --include company,agents ``` +With agent authentication, use `company list` or `company current` to resolve +the scoped company. `company list` first tries the board-wide list; if that is +forbidden, it falls back to `--company-id`, `PAPERCLIP_COMPANY_ID`, context, or +`/api/agents/me` and returns only that scoped company. `company create` requires +board/instance-admin authentication because it is an instance-wide setup +command. + ## Agent Commands ```sh diff --git a/packages/teams-catalog/catalog/bundled/software-development/product-engineering/.paperclip.yaml b/packages/teams-catalog/catalog/bundled/software-development/product-engineering/.paperclip.yaml new file mode 100644 index 00000000000..9af60c42458 --- /dev/null +++ b/packages/teams-catalog/catalog/bundled/software-development/product-engineering/.paperclip.yaml @@ -0,0 +1,5 @@ +schema: paperclip/v1 +agents: + cto: + permissions: + canCreateAgents: true diff --git a/packages/teams-catalog/generated/catalog.json b/packages/teams-catalog/generated/catalog.json index 817772688fd..e6228fe3fb8 100644 --- a/packages/teams-catalog/generated/catalog.json +++ b/packages/teams-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/teams-catalog", "packageVersion": "0.1.0", - "generatedAt": "2026-06-03T19:51:52.952Z", + "generatedAt": "2026-06-04T18:03:41.262Z", "teams": [ { "id": "paperclipai:bundled:company-defaults:core-exec-team", @@ -334,6 +334,12 @@ "sizeBytes": 2583, "sha256": "7204eeef78a48714dd3f82b82e171a4039adc6325e91a6bdf51a442105ee33b0" }, + { + "path": ".paperclip.yaml", + "kind": "extension", + "sizeBytes": 81, + "sha256": "54babf05ba130ec54a143abf01581d99c0ac2936fd0d929bf0e0edd00e29d861" + }, { "path": "agents/cto/AGENTS.md", "kind": "agent", @@ -365,9 +371,9 @@ "sha256": "6acf3ed2da3dfa48de163810b4bc9d385dad537fd34037ca881eec713986a2a8" } ], - "trustLevel": "markdown_only", + "trustLevel": "assets", "compatibility": "compatible", - "contentHash": "sha256:c1690ae90dc2049f5e3c7cd60f3206c2d2584d4119197b216aae2c49ec50deb9" + "contentHash": "sha256:74ddeaeb11805835a4a67fc4530b048ab7f8aa1dc7d2b80acea139a933d158d3" }, { "id": "paperclipai:optional:content:content-machine", diff --git a/server/src/__tests__/agent-permissions-service.test.ts b/server/src/__tests__/agent-permissions-service.test.ts new file mode 100644 index 00000000000..2a80d25ab94 --- /dev/null +++ b/server/src/__tests__/agent-permissions-service.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + defaultPermissionsForRole, + normalizeAgentPermissions, +} from "../services/agent-permissions.js"; + +describe("agent permissions service", () => { + it("keeps agent-creation authority least-privileged by default", () => { + expect(defaultPermissionsForRole("ceo").canCreateAgents).toBe(true); + expect(defaultPermissionsForRole("CTO").canCreateAgents).toBe(false); + expect(defaultPermissionsForRole("engineering-manager").canCreateAgents).toBe(false); + expect(defaultPermissionsForRole("engineer").canCreateAgents).toBe(false); + }); + + it("preserves explicit canCreateAgents overrides", () => { + expect(normalizeAgentPermissions({ canCreateAgents: false }, "cto").canCreateAgents).toBe(false); + expect(normalizeAgentPermissions({ canCreateAgents: true }, "engineer").canCreateAgents).toBe(true); + }); +}); diff --git a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts index 152e468bd9a..7e085dd8e69 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -58,6 +58,7 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides name: agents.name, role: agents.role, adapterType: agents.adapterType, + permissions: agents.permissions, }) .from(agents) .where(eq(agents.companyId, companyId)); @@ -112,6 +113,7 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides const adapterTypes = Array.from(byName.values()).map((row) => row.adapterType); expect(adapterTypes).toEqual(["claude_local", "claude_local", "claude_local"]); expect(adapterTypes).not.toContain("process"); + expect(byName.get("CTO")?.permissions).toMatchObject({ canCreateAgents: true }); }); it("honors an explicit caller adapter override for a single slug while defaulting the rest to claude_local", async () => { diff --git a/server/src/__tests__/teams-catalog-service.test.ts b/server/src/__tests__/teams-catalog-service.test.ts index 28b4d419d92..334d08b78db 100644 --- a/server/src/__tests__/teams-catalog-service.test.ts +++ b/server/src/__tests__/teams-catalog-service.test.ts @@ -128,6 +128,31 @@ describe("teamsCatalogService", () => { expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"ceo\"")); }); + it("preserves package-declared Paperclip sidecar permissions while adding generated catalog provenance", async () => { + const svc = teamsCatalogService({} as any); + + const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering"); + + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("permissions:")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("canCreateAgents: true")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogTeam:")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogSlug: \"product-engineering\"")); + }); + + it("preserves package sidecar permissions when generated target-manager metadata is merged onto the same root agent", async () => { + const svc = teamsCatalogService({} as any); + + const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering", { + targetManagerAgentId: "manager-1", + }); + + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("permissions:")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("canCreateAgents: true")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\"")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\"")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogSlug: \"product-engineering\"")); + }); + it("rejects missing target-manager slugs instead of emitting unresolved reparent metadata", async () => { mockAgentService.list.mockResolvedValue([]); const svc = teamsCatalogService({} as any); diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index 97cd13bf584..cd470960b0d 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -4,7 +4,7 @@ export type NormalizedAgentPermissions = Record & { export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions { return { - canCreateAgents: role === "ceo", + canCreateAgents: role.trim().toLowerCase() === "ceo", }; } diff --git a/server/src/services/teams-catalog.ts b/server/src/services/teams-catalog.ts index 85d162a5c79..2fa1e69a9a9 100644 --- a/server/src/services/teams-catalog.ts +++ b/server/src/services/teams-catalog.ts @@ -344,6 +344,59 @@ function renderStringArrayYaml(key: string, values: string[]) { ]; } +function renderYamlBlock(value: unknown, indentLevel = 0): string[] { + const indent = " ".repeat(indentLevel); + + if (Array.isArray(value)) { + if (value.length === 0) return [`${indent}[]`]; + const lines: string[] = []; + for (const entry of value) { + if ( + entry === null || + typeof entry === "string" || + typeof entry === "number" || + typeof entry === "boolean" || + (Array.isArray(entry) && entry.length === 0) || + (isPlainRecord(entry) && Object.keys(entry).length === 0) + ) { + lines.push(`${indent}- ${yamlScalar(entry as string | number | boolean | null)}`); + continue; + } + lines.push(`${indent}-`); + lines.push(...renderYamlBlock(entry, indentLevel + 1)); + } + return lines; + } + + if (isPlainRecord(value)) { + const entries = Object.entries(value).filter(([, entry]) => entry !== undefined); + if (entries.length === 0) return [`${indent}{}`]; + const lines: string[] = []; + for (const [key, entry] of entries) { + if ( + entry === null || + typeof entry === "string" || + typeof entry === "number" || + typeof entry === "boolean" || + (Array.isArray(entry) && entry.length === 0) || + (isPlainRecord(entry) && Object.keys(entry).length === 0) + ) { + lines.push(`${indent}${key}: ${yamlScalar(entry as string | number | boolean | null)}`); + continue; + } + lines.push(`${indent}${key}:`); + lines.push(...renderYamlBlock(entry, indentLevel + 1)); + } + return lines; + } + + return [`${indent}${yamlScalar(String(value))}`]; +} + +function renderYamlFile(value: Record) { + return `${renderYamlBlock(value).join("\n")}\n`; +} + function renderSyntheticCompanyMarkdown(team: CatalogTeam) { const lines = [ "---", @@ -386,43 +439,61 @@ function renderCatalogProvenanceYaml(team: CatalogTeam, targetManager: CatalogTa .map((file) => normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(file.path)))) .filter((slug): slug is string => Boolean(slug)); - const renderEntity = (slug: string, opts?: { reparentRoot?: boolean }) => [ - ` ${slug}:`, - ...(opts?.reparentRoot && targetManager ? [ - ` reportsToExistingAgentId: ${yamlScalar(targetManager.agentId)}`, - ` reportsToExistingAgentSlug: ${yamlScalar(targetManager.slug)}`, - ] : []), - " metadata:", - " paperclip:", - " catalogTeam:", - ` catalogId: ${yamlScalar(provenance.catalogId)}`, - ` catalogKey: ${yamlScalar(provenance.catalogKey)}`, - ` catalogKind: ${yamlScalar(provenance.catalogKind)}`, - ` catalogCategory: ${yamlScalar(provenance.catalogCategory)}`, - ` catalogSlug: ${yamlScalar(provenance.catalogSlug)}`, - ` packageName: ${yamlScalar(provenance.packageName)}`, - ` packageVersion: ${yamlScalar(provenance.packageVersion)}`, - ` originHash: ${yamlScalar(provenance.originHash)}`, - ]; + const renderEntity = (slug: string, opts?: { reparentRoot?: boolean }) => ({ + ...(opts?.reparentRoot && targetManager + ? { + reportsToExistingAgentId: targetManager.agentId, + reportsToExistingAgentSlug: targetManager.slug, + } + : {}), + metadata: { + paperclip: { + catalogTeam: { + catalogId: provenance.catalogId, + catalogKey: provenance.catalogKey, + catalogKind: provenance.catalogKind, + catalogCategory: provenance.catalogCategory, + catalogSlug: provenance.catalogSlug, + packageName: provenance.packageName, + packageVersion: provenance.packageVersion, + originHash: provenance.originHash, + }, + }, + }, + }); - const lines = [ - "schema: paperclip/v1", - "agents:", - ...agentSlugs.flatMap((slug) => + const extension: Record = { + schema: "paperclip/v1", + agents: Object.fromEntries(agentSlugs.map((slug) => [ + slug, renderEntity(slug, { reparentRoot: Boolean(targetManager && team.rootAgentSlugs.includes(slug)), }), - ), - ]; + ])), + }; if (projectSlugs.length > 0) { - lines.push("projects:"); - lines.push(...projectSlugs.flatMap((slug) => renderEntity(slug))); + extension.projects = Object.fromEntries(projectSlugs.map((slug) => [slug, renderEntity(slug)])); } if (taskSlugs.length > 0) { - lines.push("tasks:"); - lines.push(...Array.from(new Set(taskSlugs)).sort().flatMap((slug) => renderEntity(slug))); + extension.tasks = Object.fromEntries(Array.from(new Set(taskSlugs)).sort().map((slug) => [slug, renderEntity(slug)])); } - return `${lines.join("\n")}\n`; + return renderYamlFile(extension); +} + +function mergePlainRecords( + base: Record, + override: Record, +): Record { + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = merged[key]; + if (isPlainRecord(existing) && isPlainRecord(value)) { + merged[key] = mergePlainRecords(existing, value); + continue; + } + merged[key] = value; + } + return merged; } interface MarkdownDoc { @@ -457,30 +528,69 @@ function parseYamlFrontmatter(raw: string): Record { content: line.trim(), })) .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); + if (lines.length === 0) return {}; + const parsed = parseYamlBlock(lines, 0, lines[0]!.indent); + return isPlainRecord(parsed.value) ? parsed.value : {}; +} + +function parseYamlBlock( + lines: Array<{ indent: number; content: string }>, + startIndex: number, + indentLevel: number, +): { value: unknown; nextIndex: number } { + let index = startIndex; + if (index >= lines.length || lines[index]!.indent < indentLevel) { + return { value: {}, nextIndex: index }; + } + + const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-"); + if (isArray) { + const values: unknown[] = []; + while (index < lines.length) { + const line = lines[index]!; + if (line.indent < indentLevel) break; + if (line.indent !== indentLevel || !line.content.startsWith("-")) break; + + const remainder = line.content.slice(1).trim(); + index += 1; + if (!remainder) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + values.push(nested.value); + index = nested.nextIndex; + continue; + } + + values.push(parseYamlScalar(remainder)); + } + return { value: values, nextIndex: index }; + } + const record: Record = {}; - for (let index = 0; index < lines.length; index += 1) { + while (index < lines.length) { const line = lines[index]!; - if (line.indent !== 0) continue; + if (line.indent < indentLevel) break; + if (line.indent !== indentLevel) { + index += 1; + continue; + } + const separatorIndex = line.content.indexOf(":"); - if (separatorIndex <= 0) continue; + if (separatorIndex <= 0) { + index += 1; + continue; + } const key = line.content.slice(0, separatorIndex).trim(); const remainder = line.content.slice(separatorIndex + 1).trim(); + index += 1; if (remainder) { record[key] = parseYamlScalar(remainder); continue; } - const values: string[] = []; - while (index + 1 < lines.length && lines[index + 1]!.indent > line.indent) { - const next = lines[index + 1]!; - if (next.indent === line.indent + 2 && next.content.startsWith("-")) { - const value = next.content.slice(1).trim(); - if (value) values.push(String(parseYamlScalar(value))); - } - index += 1; - } - record[key] = values; + const nested = parseYamlBlock(lines, index, indentLevel + 2); + record[key] = nested.value; + index = nested.nextIndex; } - return record; + return { value: record, nextIndex: index }; } function parseFrontmatterMarkdown(raw: string): MarkdownDoc { @@ -765,7 +875,12 @@ export function teamsCatalogService(db: Db) { const targetManager = await resolveTargetManagerReference(companyId, options); const files = await readCatalogTeamSourceFiles(team); - files[".paperclip.yaml"] = renderCatalogProvenanceYaml(team, targetManager); + const existingExtension = + typeof files[".paperclip.yaml"] === "string" + ? parseYamlFrontmatter(files[".paperclip.yaml"]) + : {}; + const generatedExtension = parseYamlFrontmatter(renderCatalogProvenanceYaml(team, targetManager)); + files[".paperclip.yaml"] = renderYamlFile(mergePlainRecords(existingExtension, generatedExtension)); rewriteAgentCatalogSkillRefs(team, files); return {