PAP-10287 finalize teams catalog extraction

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 03:00:20 +00:00
1 parent 7b9b79860e
commit 515e628642
13 files changed
+793 -53

No files matched your search

+188 -1
View File
@@ -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<void> {
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<string, unknown> = {}) {
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<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
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(
+149
View File
@@ -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.',
+86 -2
View File
@@ -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<Company[]>("/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<Company>(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<T>(path: string): Promise<T | null> };
}): Promise<Company[]> {
try {
return (await ctx.api.get<Company[]>("/api/companies")) ?? [];
} catch (error) {
if (!isBoardAccessRequiredError(error)) {
throw error;
}
}
const companyId = await resolveCurrentCompanyId(ctx);
const scopedCompany = await ctx.api.get<Company>(apiPath`/api/companies/${companyId}`);
return scopedCompany ? [scopedCompany] : [];
}
async function createCompanyForContext(ctx: {
api: { post<T>(path: string, body?: unknown): Promise<T | null> };
}, payload: unknown): Promise<unknown> {
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<T>(path: string): Promise<T | null> } }): Promise<string> {
const fromContext = ctx.companyId?.trim();
if (fromContext) return fromContext;
let agent: AgentMeResponse | null = null;
try {
agent = await ctx.api.get<AgentMeResponse>("/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
+124 -2
View File
@@ -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 <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<CatalogTeamInstallResult>(
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<TeamInstallApprovalFallbackResult> {
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<Approval>(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<Record<string, unknown>>): void {
if (rows.length === 0) {
printOutput([], { json: false });