paperclip/server/src/__tests__/company-portability.test.ts
Dotta 778e775c35
Add secrets provider vaults and remote import (#5429)
## Thinking Path

> - Paperclip orchestrates AI-agent companies and needs secrets handling
to work across local development, hosted operators, and governed agent
execution.
> - The affected subsystem is the company-scoped secrets control plane:
database schema, server services/routes, CLI workflows, and the Secrets
settings UI.
> - The gap was that secrets were local-only and operators could not
manage provider vaults or import existing remote references without
exposing plaintext.
> - This branch adds provider vault configuration plus an AWS Secrets
Manager remote-import path while preserving company boundaries, binding
context, and audit trails.
> - I kept the PR to a single branch PR, removed unrelated
lockfile/package drift, rebased the full branch onto the current
`public-gh/master`, and addressed fresh Greptile findings.
> - The benefit is a reviewable implementation of provider-backed
secrets with focused tests covering provider selection, import
conflicts, deleted secret reuse, rotation guards, and AWS signing
behavior.

## What Changed

- Added provider vault support for company secrets, including provider
config storage, default vault handling, health checks, binding usage,
access events, and remote import preview/commit.
- Added an AWS Secrets Manager provider using SigV4 request signing,
bounded request timeouts, namespace guardrails, cached runtime
credential resolution, and external-reference linking without plaintext
reads.
- Added Secrets UI surfaces for vault management and remote import, plus
CLI/API documentation for setup and operations.
- Stabilized routine webhook secret binding paths and SSH
environment-driver fixture bindings discovered during verification.
- Addressed Greptile and CI findings: no lockfile/package drift,
monotonic migration metadata, disabled-vault default races, soft-deleted
secret hiding/recreate behavior, remove behavior with disabled vaults,
soft-deleted external-reference re-import, non-active rotation guards,
managed-secret soft deletion through PATCH, and per-call AWS SDK
credential client churn.
- Rebased this branch onto `public-gh/master` at `0e1a5828` and
force-pushed with lease to keep this as the single PR for the branch.

## Verification

- `git fetch public-gh master`
- `git rebase public-gh/master`
- `git diff --name-only public-gh/master...HEAD | grep
'^pnpm-lock\.yaml$' || true` confirmed `pnpm-lock.yaml` is not in the PR
diff.
- Confirmed migration ordering: master ends at `0081_optimal_dormammu`;
this PR adds `0082_dry_vision` and
`0083_company_secret_provider_configs`.
- Inspected migrations for repeat safety: new tables/indexes use `IF NOT
EXISTS`; foreign keys are guarded by `DO $$ ... IF NOT EXISTS`; column
additions use `ADD COLUMN IF NOT EXISTS`.
- `pnpm -r typecheck` passed before the Greptile follow-up commits.
- `pnpm test:run` ran the full stable Vitest path before the Greptile
follow-up commits; it completed with 3 timing-related failures under
parallel load: `codex-local-execute.test.ts`,
`cursor-local-execute.test.ts`, and `environment-service.test.ts`.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/codex-local-execute.test.ts
src/__tests__/cursor-local-execute.test.ts
src/__tests__/environment-service.test.ts` passed on targeted rerun
(`24/24`).
- `pnpm build` passed before the Greptile follow-up commits. Vite
reported existing chunk-size/dynamic-import warnings.
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
exec vitest run src/__tests__/secrets-service.test.ts` passed (`26/26`).
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
exec vitest run src/__tests__/aws-secrets-manager-provider.test.ts
src/__tests__/secrets-service.test.ts` passed (`39/39`).
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
typecheck` passed.
- Captured Storybook screenshots from `ui/storybook-static` for visual
review.
- Latest PR checks on `5ca3a5cf`: `policy`, serialized server suites
1/4-4/4, `Canary Dry Run`, `e2e`, `security/snyk`, and `Greptile Review`
pass; aggregate `verify` is still registering the completed child
checks.
- Greptile review loop continued through the latest requested pass; all
Greptile review threads are resolved and the latest `Greptile Review`
check on `5ca3a5cf` passed with 0 comments added.

## Screenshots

Before: the provider-vault and remote-import surfaces did not exist on
`master`; these are after-state screenshots from the Storybook fixtures.

![Secrets
inventory](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/secrets-inventory.png)

![Secret binding
picker](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/secret-binding-picker.png)

![Environment editor with
secrets](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/env-editor-with-secrets.png)

## Risks

- Migration risk: this adds new secret provider tables and extends
existing secret rows. The migrations were checked for monotonic ordering
and idempotent guards, but reviewers should still inspect upgrade
behavior carefully.
- Provider risk: AWS support uses direct SigV4 requests. Automated tests
cover signing, request timeouts, vault-config selection, namespace
guardrails, pending-version archival, sanitized provider errors, and
service-level cleanup paths. A real-vault AWS smoke test remains
deployment validation for an operator with AWS credentials rather than
an unverified merge blocker in this local branch.
- UI risk: the Secrets page and import dialog are large new surfaces;
screenshots are included above for reviewer inspection.
- Verification risk: the full local stable test command hit
parallel-load timing failures, although the exact failed files passed
when rerun directly.
- Operational risk: remote import intentionally avoids plaintext reads;
operators must understand that imported external references resolve at
runtime and may fail if AWS permissions change.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent with local shell/tool use in the
Paperclip worktree. Exact context-window size was not exposed by the
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 18:22:17 -05:00

3165 lines
100 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const companySvc = {
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
const agentSvc = {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
const accessSvc = {
ensureMembership: vi.fn(),
listActiveUserMemberships: vi.fn(),
copyActiveUserMemberships: vi.fn(),
setPrincipalPermission: vi.fn(),
};
const projectSvc = {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
listWorkspaces: vi.fn(),
};
const issueSvc = {
list: vi.fn(),
listComments: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
};
const routineSvc = {
list: vi.fn(),
getDetail: vi.fn(),
create: vi.fn(),
createTrigger: vi.fn(),
};
const companySkillSvc = {
list: vi.fn(),
listFull: vi.fn(),
readFile: vi.fn(),
importPackageFiles: vi.fn(),
};
const assetSvc = {
getById: vi.fn(),
create: vi.fn(),
};
const secretSvc = {
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
};
const agentInstructionsSvc = {
exportFiles: vi.fn(),
materializeManagedBundle: vi.fn(),
};
vi.mock("../services/companies.js", () => ({
companyService: () => companySvc,
}));
vi.mock("../services/agents.js", () => ({
agentService: () => agentSvc,
}));
vi.mock("../services/access.js", () => ({
accessService: () => accessSvc,
}));
vi.mock("../services/projects.js", () => ({
projectService: () => projectSvc,
}));
vi.mock("../services/issues.js", () => ({
issueService: () => issueSvc,
}));
vi.mock("../services/routines.js", () => ({
routineService: () => routineSvc,
}));
vi.mock("../services/company-skills.js", () => ({
companySkillService: () => companySkillSvc,
}));
vi.mock("../services/assets.js", () => ({
assetService: () => assetSvc,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => secretSvc,
}));
vi.mock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => agentInstructionsSvc,
}));
vi.mock("../routes/org-chart-svg.js", () => ({
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
}));
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
expect(typeof entry).toBe("string");
return typeof entry === "string" ? entry : "";
}
describe("company portability", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const companyPlaybookKey = "company/company-1/company-playbook";
beforeEach(() => {
vi.clearAllMocks();
secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
secretSvc.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({
config,
secretKeys: new Set<string>(),
}));
issueSvc.listComments.mockResolvedValue([]);
issueSvc.addComment.mockResolvedValue({
id: "comment-imported",
body: "Imported comment",
authorType: "system",
presentation: null,
metadata: null,
});
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
description: null,
issuePrefix: "PAP",
brandColor: "#5c5fff",
logoAssetId: null,
logoUrl: null,
requireBoardApprovalForNewAgents: false,
});
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
requireBoardApprovalForNewAgents: false,
});
agentSvc.list.mockResolvedValue([
{
id: "agent-1",
name: "ClaudeCoder",
status: "idle",
role: "engineer",
title: "Software Engineer",
icon: "code",
reportsTo: null,
capabilities: "Writes code",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are ClaudeCoder.",
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
instructionsFilePath: "/tmp/ignored.md",
cwd: "/tmp/ignored",
command: "/Users/dotta/.local/bin/claude",
model: "claude-opus-4-6",
env: {
ANTHROPIC_API_KEY: {
type: "secret_ref",
secretId: "secret-1",
version: "latest",
},
GH_TOKEN: {
type: "secret_ref",
secretId: "secret-2",
version: "latest",
},
PATH: {
type: "plain",
value: "/usr/bin:/bin",
},
},
},
runtimeConfig: {
heartbeat: {
intervalSec: 3600,
},
},
budgetMonthlyCents: 0,
permissions: {
canCreateAgents: false,
},
metadata: null,
},
{
id: "agent-2",
name: "CMO",
status: "idle",
role: "cmo",
title: "Chief Marketing Officer",
icon: "globe",
reportsTo: null,
capabilities: "Owns marketing",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are CMO.",
},
runtimeConfig: {
heartbeat: {
intervalSec: 3600,
},
},
budgetMonthlyCents: 0,
permissions: {
canCreateAgents: false,
},
metadata: null,
},
]);
projectSvc.list.mockResolvedValue([]);
projectSvc.createWorkspace.mockResolvedValue(null);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([]);
issueSvc.getById.mockResolvedValue(null);
issueSvc.getByIdentifier.mockResolvedValue(null);
routineSvc.list.mockResolvedValue([]);
routineSvc.getDetail.mockImplementation(async (id: string) => {
const rows = await routineSvc.list();
return rows.find((row: { id: string }) => row.id === id) ?? null;
});
routineSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "routine-created",
companyId: "company-1",
projectId: input.projectId,
goalId: null,
parentIssueId: null,
title: input.title,
description: input.description ?? null,
assigneeAgentId: input.assigneeAgentId,
priority: input.priority ?? "medium",
status: input.status ?? "active",
concurrencyPolicy: input.concurrencyPolicy ?? "coalesce_if_active",
catchUpPolicy: input.catchUpPolicy ?? "skip_missed",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}));
routineSvc.createTrigger.mockImplementation(async (_routineId: string, input: Record<string, unknown>) => ({
id: "trigger-created",
companyId: "company-1",
routineId: "routine-created",
kind: input.kind,
label: input.label ?? null,
enabled: input.enabled ?? true,
cronExpression: input.kind === "schedule" ? input.cronExpression ?? null : null,
timezone: input.kind === "schedule" ? input.timezone ?? null : null,
nextRunAt: null,
lastFiredAt: null,
publicId: null,
secretId: null,
signingMode: input.kind === "webhook" ? input.signingMode ?? "bearer" : null,
replayWindowSec: input.kind === "webhook" ? input.replayWindowSec ?? 300 : null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
}));
const companySkills = [
{
id: "skill-1",
companyId: "company-1",
key: paperclipKey,
slug: "paperclip",
name: "paperclip",
description: "Paperclip coordination skill",
markdown: "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n",
sourceType: "github",
sourceLocator: "https://github.com/paperclipai/paperclip/tree/master/skills/paperclip",
sourceRef: "0123456789abcdef0123456789abcdef01234567",
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/api.md", kind: "reference" },
],
metadata: {
sourceKind: "github",
owner: "paperclipai",
repo: "paperclip",
ref: "0123456789abcdef0123456789abcdef01234567",
trackingRef: "master",
repoSkillDir: "skills/paperclip",
},
},
{
id: "skill-2",
companyId: "company-1",
key: companyPlaybookKey,
slug: "company-playbook",
name: "company-playbook",
description: "Internal company skill",
markdown: "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n",
sourceType: "local_path",
sourceLocator: "/tmp/company-playbook",
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/checklist.md", kind: "reference" },
],
metadata: {
sourceKind: "local_path",
},
},
];
companySkillSvc.list.mockResolvedValue(companySkills);
companySkillSvc.listFull.mockResolvedValue(companySkills);
companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => {
if (skillId === "skill-2") {
return {
skillId,
path: relativePath,
kind: relativePath === "SKILL.md" ? "skill" : "reference",
content: relativePath === "SKILL.md"
? "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n"
: "# Checklist\n",
language: "markdown",
markdown: true,
editable: true,
};
}
return {
skillId,
path: relativePath,
kind: relativePath === "SKILL.md" ? "skill" : "reference",
content: relativePath === "SKILL.md"
? "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n"
: "# API\n",
language: "markdown",
markdown: true,
editable: false,
};
});
companySkillSvc.importPackageFiles.mockResolvedValue([]);
assetSvc.getById.mockReset();
assetSvc.getById.mockResolvedValue(null);
assetSvc.create.mockReset();
accessSvc.setPrincipalPermission.mockResolvedValue(undefined);
assetSvc.create.mockResolvedValue({
id: "asset-created",
});
accessSvc.listActiveUserMemberships.mockResolvedValue([
{
id: "membership-1",
companyId: "company-1",
principalType: "user",
principalId: "user-1",
membershipRole: "owner",
status: "active",
},
]);
accessSvc.copyActiveUserMemberships.mockResolvedValue([]);
agentInstructionsSvc.exportFiles.mockImplementation(async (agent: { name: string }) => ({
files: { "AGENTS.md": agent.name === "CMO" ? "You are CMO." : "You are ClaudeCoder." },
entryFile: "AGENTS.md",
warnings: [],
}));
agentInstructionsSvc.materializeManagedBundle.mockImplementation(async (agent: { adapterConfig: Record<string, unknown> }) => ({
bundle: null,
adapterConfig: {
...agent.adapterConfig,
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${agent.id}`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${agent.id}/AGENTS.md`,
},
}));
});
it("parses canonical GitHub import URLs with explicit ref and package path", () => {
expect(
parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"),
).toEqual({
hostname: "github.com",
owner: "paperclipai",
repo: "companies",
ref: "feature/demo",
basePath: "gstack",
companyPath: "gstack/COMPANY.md",
});
});
it("parses canonical GitHub import URLs with explicit companyPath", () => {
expect(
parseGitHubSourceUrl(
"https://github.com/paperclipai/companies?ref=abc123&companyPath=gstack%2FCOMPANY.md",
),
).toEqual({
hostname: "github.com",
owner: "paperclipai",
repo: "companies",
ref: "abc123",
basePath: "gstack",
companyPath: "gstack/COMPANY.md",
});
});
it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
expect(asTextFile(exported.files["COMPANY.md"])).toContain('name: "Paperclip"');
expect(asTextFile(exported.files["COMPANY.md"])).toContain('schema: "agentcompanies/v1"');
expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain("You are ClaudeCoder.");
expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain("skills:");
expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain(`- "${paperclipKey}"`);
expect(asTextFile(exported.files["agents/cmo/AGENTS.md"])).not.toContain("skills:");
expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain("metadata:");
expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain('kind: "github-dir"');
expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toBeUndefined();
expect(asTextFile(exported.files["skills/company/PAP/company-playbook/SKILL.md"])).toContain("# Company Playbook");
expect(asTextFile(exported.files["skills/company/PAP/company-playbook/references/checklist.md"])).toContain("# Checklist");
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('schema: "paperclip/v1"');
expect(extension).not.toContain("promptTemplate");
expect(extension).not.toContain("instructionsFilePath");
expect(extension).not.toContain("command:");
expect(extension).not.toContain("secretId");
expect(extension).not.toContain('type: "secret_ref"');
expect(extension).toContain("inputs:");
expect(extension).toContain("ANTHROPIC_API_KEY:");
expect(extension).toContain('requirement: "optional"');
expect(extension).toContain('default: ""');
expect(extension).not.toContain("paperclipSkillSync");
expect(extension).not.toContain("PATH:");
expect(extension).not.toContain("requireBoardApprovalForNewAgents: true");
expect(extension).not.toContain("budgetMonthlyCents: 0");
expect(exported.warnings).toContain("Agent claudecoder command /Users/dotta/.local/bin/claude was omitted from export because it is system-dependent.");
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
});
it("exports hire approval policy only when approval is required", async () => {
const portability = companyPortabilityService({} as any);
companySvc.getById.mockResolvedValueOnce({
id: "company-1",
name: "Paperclip",
description: null,
issuePrefix: "PAP",
brandColor: "#5c5fff",
logoAssetId: null,
logoUrl: null,
requireBoardApprovalForNewAgents: true,
});
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: false,
projects: false,
issues: false,
},
});
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("requireBoardApprovalForNewAgents: true");
});
it("exports legacy inline sensitive env values as declarations without values", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.list.mockResolvedValue([
{
id: "agent-inline-secret",
name: "InlineSecretAgent",
status: "idle",
role: "engineer",
title: null,
icon: null,
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {
env: {
OPENAI_API_KEY: "sk-inline-secret-value",
NODE_ENV: {
type: "plain",
value: "development",
},
},
},
runtimeConfig: {},
budgetMonthlyCents: 0,
permissions: {
canCreateAgents: false,
},
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
const serialized = JSON.stringify(exported);
expect(serialized).not.toContain("sk-inline-secret-value");
expect(exported.manifest.envInputs).toContainEqual({
key: "OPENAI_API_KEY",
description: "Optional default for OPENAI_API_KEY on agent inlinesecretagent",
agentSlug: "inlinesecretagent",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
expect(exported.manifest.envInputs).toContainEqual({
key: "NODE_ENV",
description: "Optional default for NODE_ENV on agent inlinesecretagent",
agentSlug: "inlinesecretagent",
projectSlug: null,
kind: "plain",
requirement: "optional",
defaultValue: "development",
portability: "portable",
});
});
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-2",
companyId: "company-1",
name: "Zulu",
urlKey: "zulu",
description: null,
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
workspaces: [],
},
{
id: "project-1",
companyId: "company-1",
name: "Alpha",
urlKey: "alpha",
description: null,
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
workspaces: [],
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: true,
issues: false,
},
});
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain([
"sidebar:",
" agents:",
' - "claudecoder"',
' - "cmo"',
" projects:",
' - "alpha"',
' - "zulu"',
].join("\n"));
expect(exported.manifest.sidebar).toEqual({
agents: ["claudecoder", "cmo"],
projects: ["alpha", "zulu"],
});
});
it("expands referenced skills when requested", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
expandReferencedSkills: true,
});
expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain("# Paperclip");
expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain("metadata:");
expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"])).toContain("# API");
});
it("exports only selected skills when skills filter is provided", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
skills: ["company-playbook"],
});
expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toBeDefined();
expect(asTextFile(exported.files["skills/company/PAP/company-playbook/SKILL.md"])).toContain("# Company Playbook");
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeUndefined();
});
it("warns and exports all skills when skills filter matches nothing", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
skills: ["nonexistent-skill"],
});
expect(exported.warnings).toContainEqual(expect.stringContaining("nonexistent-skill"));
expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toBeDefined();
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeDefined();
});
it("exports the company logo into images/ and references it from .paperclip.yaml", async () => {
const storage = {
getObject: vi.fn().mockResolvedValue({
stream: Readable.from([Buffer.from("png-bytes")]),
}),
};
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
description: null,
issuePrefix: "PAP",
brandColor: "#5c5fff",
logoAssetId: "logo-1",
logoUrl: "/api/assets/logo-1/content",
requireBoardApprovalForNewAgents: true,
});
assetSvc.getById.mockResolvedValue({
id: "logo-1",
companyId: "company-1",
objectKey: "assets/companies/logo-1",
contentType: "image/png",
originalFilename: "logo.png",
});
const portability = companyPortabilityService({} as any, storage as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: false,
projects: false,
issues: false,
},
});
expect(storage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1");
expect(exported.files["images/company-logo.png"]).toEqual({
encoding: "base64",
data: Buffer.from("png-bytes").toString("base64"),
contentType: "image/png",
});
expect(exported.files[".paperclip.yaml"]).toContain('logoPath: "images/company-logo.png"');
});
it("exports duplicate skill slugs into readable namespaced paths", async () => {
const portability = companyPortabilityService({} as any);
companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => {
if (skillId === "skill-local") {
return {
skillId,
path: relativePath,
kind: "skill",
content: "---\nname: release-changelog\n---\n\n# Local Release Changelog\n",
language: "markdown",
markdown: true,
editable: true,
};
}
return {
skillId,
path: relativePath,
kind: "skill",
content: "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n",
language: "markdown",
markdown: true,
editable: false,
};
});
companySkillSvc.listFull.mockResolvedValue([
{
id: "skill-local",
companyId: "company-1",
key: "local/36dfd631da/release-changelog",
slug: "release-changelog",
name: "release-changelog",
description: "Local release changelog skill",
markdown: "---\nname: release-changelog\n---\n\n# Local Release Changelog\n",
sourceType: "local_path",
sourceLocator: "/tmp/release-changelog",
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: {
sourceKind: "local_path",
},
},
{
id: "skill-paperclip",
companyId: "company-1",
key: "paperclipai/paperclip/release-changelog",
slug: "release-changelog",
name: "release-changelog",
description: "Bundled release changelog skill",
markdown: "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n",
sourceType: "github",
sourceLocator: "https://github.com/paperclipai/paperclip/tree/master/skills/release-changelog",
sourceRef: "0123456789abcdef0123456789abcdef01234567",
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: {
sourceKind: "paperclip_bundled",
owner: "paperclipai",
repo: "paperclip",
ref: "0123456789abcdef0123456789abcdef01234567",
trackingRef: "master",
repoSkillDir: "skills/release-changelog",
},
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
expect(asTextFile(exported.files["skills/local/release-changelog/SKILL.md"])).toContain("# Local Release Changelog");
expect(asTextFile(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"])).toContain("metadata:");
expect(asTextFile(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"])).toContain("paperclipai/paperclip/release-changelog");
});
it("builds export previews without tasks by default", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Write launch task",
description: "Task body",
projectId: "project-1",
assigneeAgentId: "agent-1",
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const preview = await portability.previewExport("company-1", {
include: {
company: true,
agents: true,
projects: true,
},
});
expect(preview.counts.issues).toBe(0);
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
});
it("exports portable project workspace metadata and remaps it on import", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: "2026-03-31",
color: "#123456",
status: "planned",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: "workspace-1",
workspaceStrategy: {
type: "project_primary",
},
},
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Main Repo",
sourceType: "git_repo",
cwd: "/Users/dotta/paperclip",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
setupCommand: "pnpm install",
cleanupCommand: "rm -rf .paperclip-tmp",
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: {
language: "typescript",
},
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
{
id: "workspace-2",
companyId: "company-1",
projectId: "project-1",
name: "Local Scratch",
sourceType: "local_path",
cwd: "/tmp/paperclip-local",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "advanced",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: false,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Write launch task",
description: "Task body",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: "agent-1",
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: {
mode: "shared_workspace",
},
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: false,
projects: true,
issues: true,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("workspaces:");
expect(extension).toContain("main-repo:");
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
expect(extension).toContain('defaultProjectWorkspaceKey: "main-repo"');
expect(extension).toContain('projectWorkspaceKey: "main-repo"');
expect(extension).not.toContain("/Users/dotta/paperclip");
expect(extension).not.toContain("workspace-1");
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
projectSvc.create.mockResolvedValue({
id: "project-imported",
name: "Launch",
urlKey: "launch",
});
projectSvc.update.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
id: projectId,
name: "Launch",
urlKey: "launch",
...data,
}));
projectSvc.createWorkspace.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
id: "workspace-imported",
companyId: "company-imported",
projectId,
name: `${data.name ?? "Workspace"}`,
sourceType: `${data.sourceType ?? "git_repo"}`,
cwd: null,
repoUrl: typeof data.repoUrl === "string" ? data.repoUrl : null,
repoRef: typeof data.repoRef === "string" ? data.repoRef : null,
defaultRef: typeof data.defaultRef === "string" ? data.defaultRef : null,
visibility: `${data.visibility ?? "default"}`,
setupCommand: typeof data.setupCommand === "string" ? data.setupCommand : null,
cleanupCommand: typeof data.cleanupCommand === "string" ? data.cleanupCommand : null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
isPrimary: Boolean(data.isPrimary),
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
}));
issueSvc.create.mockResolvedValue({
id: "issue-imported",
title: "Write launch task",
});
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: false,
projects: true,
issues: true,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
collisionStrategy: "rename",
}, "user-1");
expect(projectSvc.createWorkspace).toHaveBeenCalledWith("project-imported", expect.objectContaining({
name: "Main Repo",
sourceType: "git_repo",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
}));
expect(projectSvc.update).toHaveBeenCalledWith("project-imported", expect.objectContaining({
executionWorkspacePolicy: expect.objectContaining({
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: "workspace-imported",
}),
}));
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
projectId: "project-imported",
projectWorkspaceId: "workspace-imported",
title: "Write launch task",
}));
});
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
const portability = companyPortabilityService({} as any);
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
execFileSync("git", ["checkout", "-b", "main"], { cwd: repoDir, stdio: "ignore" });
execFileSync("git", ["remote", "add", "origin", "https://github.com/paperclipai/paperclip.git"], {
cwd: repoDir,
stdio: "ignore",
});
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Paperclip App",
urlKey: "paperclip-app",
description: "Ship it",
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: "workspace-1",
},
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "paperclip",
sourceType: "local_path",
cwd: repoDir,
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Task one",
description: "Task body",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: true,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
expect(extension).toContain('projectWorkspaceKey: "paperclip"');
expect(exported.warnings).not.toContainEqual(expect.stringContaining("does not have a portable repoUrl"));
expect(exported.warnings).not.toContainEqual(expect.stringContaining("reference workspace workspace-1"));
});
it("collapses repeated task workspace warnings into one summary per missing workspace", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Local Scratch",
sourceType: "local_path",
cwd: "/tmp/local-only",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Task one",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
{
id: "issue-2",
identifier: "PAP-2",
title: "Task two",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
{
id: "issue-3",
identifier: "PAP-3",
title: "Task three",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: true,
},
});
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
expect(exported.warnings).toContain("Tasks pap-1, pap-2, pap-3 reference workspace workspace-1, but that workspace could not be exported portably.");
expect(exported.warnings.filter((warning) => warning.includes("workspace reference workspace-1 was omitted from export"))).toHaveLength(0);
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.envInputs).toEqual([
{
key: "ANTHROPIC_API_KEY",
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
agentSlug: "claudecoder",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
},
{
key: "GH_TOKEN",
description: "Provide GH_TOKEN for agent claudecoder",
agentSlug: "claudecoder",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
},
]);
});
it("exports project env as portable inputs without concrete values", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
env: {
OPENAI_API_KEY: {
type: "plain",
value: "sk-project-secret",
},
DOCS_MODE: {
type: "plain",
value: "strict",
},
GITHUB_TOKEN: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "latest",
},
},
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("OPENAI_API_KEY:");
expect(extension).toContain("DOCS_MODE:");
expect(extension).toContain("GITHUB_TOKEN:");
expect(extension).not.toContain("sk-project-secret");
expect(extension).not.toContain('type: "secret_ref"');
expect(extension).not.toContain("11111111-1111-1111-1111-111111111111");
expect(extension).toContain('default: "strict"');
expect(extension).toContain('kind: "secret"');
expect(extension).toContain('kind: "plain"');
});
it("reads project env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
env: {
OPENAI_API_KEY: {
type: "plain",
value: "sk-project-secret",
},
},
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
});
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.envInputs).toContainEqual({
key: "OPENAI_API_KEY",
description: "Optional default for OPENAI_API_KEY on project launch",
agentSlug: null,
projectSlug: "launch",
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
});
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
},
]);
routineSvc.list.mockResolvedValue([
{
id: "routine-1",
companyId: "company-1",
projectId: "project-1",
goalId: null,
parentIssueId: null,
title: "Monday Review",
description: "Review pipeline health",
assigneeAgentId: "agent-1",
priority: "high",
status: "paused",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
triggers: [
{
id: "trigger-1",
companyId: "company-1",
routineId: "routine-1",
kind: "schedule",
label: "Weekly cadence",
enabled: true,
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
nextRunAt: null,
lastFiredAt: null,
publicId: "public-1",
secretId: "secret-1",
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "trigger-2",
companyId: "company-1",
routineId: "routine-1",
kind: "webhook",
label: "External nudge",
enabled: false,
cronExpression: null,
timezone: null,
nextRunAt: null,
lastFiredAt: null,
publicId: "public-2",
secretId: "secret-2",
signingMode: "hmac_sha256",
replayWindowSec: 120,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
lastRun: null,
activeIssue: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: false,
},
});
expect(asTextFile(exported.files["tasks/monday-review/TASK.md"])).toContain('recurring: true');
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("routines:");
expect(extension).toContain("monday-review:");
expect(extension).toContain('cronExpression: "0 9 * * 1"');
expect(extension).toContain('signingMode: "hmac_sha256"');
expect(extension).not.toContain("secretId");
expect(extension).not.toContain("publicId");
expect(exported.manifest.issues).toEqual([
expect.objectContaining({
slug: "monday-review",
recurring: true,
status: "paused",
priority: "high",
routine: expect.objectContaining({
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
triggers: expect.arrayContaining([
expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1", timezone: "America/Chicago" }),
expect.objectContaining({ kind: "webhook", enabled: false, signingMode: "hmac_sha256", replayWindowSec: 120 }),
]),
}),
}),
]);
});
it("imports recurring task packages as routines instead of one-time issues", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
projectSvc.create.mockResolvedValue({
id: "project-created",
name: "Launch",
urlKey: "launch",
});
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
const files = {
"COMPANY.md": [
"---",
'schema: "agentcompanies/v1"',
'name: "Imported Paperclip"',
"---",
"",
].join("\n"),
"agents/claudecoder/AGENTS.md": [
"---",
'name: "ClaudeCoder"',
"---",
"",
"You write code.",
"",
].join("\n"),
"projects/launch/PROJECT.md": [
"---",
'name: "Launch"',
"---",
"",
].join("\n"),
"tasks/monday-review/TASK.md": [
"---",
'name: "Monday Review"',
'project: "launch"',
'assignee: "claudecoder"',
"recurring: true",
"---",
"",
"Review pipeline health.",
"",
].join("\n"),
".paperclip.yaml": [
'schema: "paperclip/v1"',
"routines:",
" monday-review:",
' status: "paused"',
' priority: "high"',
' concurrencyPolicy: "always_enqueue"',
' catchUpPolicy: "enqueue_missed_with_cap"',
" triggers:",
" - kind: schedule",
' cronExpression: "0 9 * * 1"',
' timezone: "America/Chicago"',
' - kind: webhook',
' enabled: false',
' signingMode: "hmac_sha256"',
' replayWindowSec: 120',
"",
].join("\n"),
};
const preview = await portability.previewImport({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.plan.issuePlans).toEqual([
expect.objectContaining({
slug: "monday-review",
reason: "Recurring task will be imported as a routine.",
}),
]);
const result = await portability.importBundle({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
projectId: "project-created",
title: "Monday Review",
assigneeAgentId: "agent-created",
priority: "high",
status: "paused",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
}), expect.any(Object));
expect(result.warnings).not.toContain(
"Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.",
);
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "schedule",
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
}), expect.any(Object));
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "webhook",
enabled: false,
signingMode: "hmac_sha256",
replayWindowSec: 120,
}), expect.any(Object));
expect(issueSvc.create).not.toHaveBeenCalled();
});
it("migrates legacy schedule.recurrence imports into routine triggers", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
projectSvc.create.mockResolvedValue({
id: "project-created",
name: "Launch",
urlKey: "launch",
});
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
const files = {
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
"agents/claudecoder/AGENTS.md": ['---', 'name: "ClaudeCoder"', "---", "", "You write code.", ""].join("\n"),
"projects/launch/PROJECT.md": ['---', 'name: "Launch"', "---", ""].join("\n"),
"tasks/monday-review/TASK.md": [
"---",
'name: "Monday Review"',
'project: "launch"',
'assignee: "claudecoder"',
"schedule:",
' timezone: "America/Chicago"',
' startsAt: "2026-03-16T09:00:00-05:00"',
" recurrence:",
' frequency: "weekly"',
" interval: 1",
" weekdays:",
' - "monday"',
"---",
"",
"Review pipeline health.",
"",
].join("\n"),
};
const preview = await portability.previewImport({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.manifest.issues[0]).toEqual(expect.objectContaining({
recurring: true,
legacyRecurrence: expect.objectContaining({ frequency: "weekly" }),
}));
await portability.importBundle({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "schedule",
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
}), expect.any(Object));
expect(issueSvc.create).not.toHaveBeenCalled();
});
it("flags recurring task imports that are missing routine-required fields", async () => {
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: "paperclip-demo",
files: {
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
"tasks/monday-review/TASK.md": [
"---",
'name: "Monday Review"',
"recurring: true",
"---",
"",
"Review pipeline health.",
"",
].join("\n"),
},
},
include: { company: true, agents: false, projects: false, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
collisionStrategy: "rename",
});
expect(preview.errors).toContain("Recurring task monday-review must declare a project to import as a routine.");
expect(preview.errors).toContain("Recurring task monday-review must declare an assignee to import as a routine.");
});
it("imports a vendor-neutral package without .paperclip.yaml", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: "paperclip-demo",
files: {
"COMPANY.md": [
"---",
'schema: "agentcompanies/v1"',
'name: "Imported Paperclip"',
'description: "Portable company package"',
"---",
"",
"# Imported Paperclip",
"",
].join("\n"),
"agents/claudecoder/AGENTS.md": [
"---",
'name: "ClaudeCoder"',
'title: "Software Engineer"',
"---",
"",
"# ClaudeCoder",
"",
"You write code.",
"",
].join("\n"),
},
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.manifest.company?.name).toBe("Imported Paperclip");
expect(preview.manifest.agents).toEqual([
expect.objectContaining({
slug: "claudecoder",
name: "ClaudeCoder",
adapterType: "process",
}),
]);
expect(preview.envInputs).toEqual([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: "paperclip-demo",
files: {
"COMPANY.md": [
"---",
'schema: "agentcompanies/v1"',
'name: "Imported Paperclip"',
'description: "Portable company package"',
"---",
"",
"# Imported Paperclip",
"",
].join("\n"),
"agents/claudecoder/AGENTS.md": [
"---",
'name: "ClaudeCoder"',
'title: "Software Engineer"',
"---",
"",
"# ClaudeCoder",
"",
"You write code.",
"",
].join("\n"),
},
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({
name: "Imported Paperclip",
description: "Portable company package",
}));
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
name: "ClaudeCoder",
adapterType: "process",
}));
});
it("preserves agent role from frontmatter when extension block omits it", async () => {
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: "ceo-package",
files: {
"COMPANY.md": [
"---",
'schema: "agentcompanies/v1"',
'name: "CEO Role Test"',
"---",
"",
].join("\n"),
"agents/ceo/AGENTS.md": [
"---",
'name: "CEO"',
'role: "ceo"',
"---",
"",
"# CEO",
"",
"You run the company.",
"",
].join("\n"),
},
},
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "new_company", newCompanyName: "CEO Role Test" },
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.manifest.agents).toEqual([
expect.objectContaining({
slug: "ceo",
name: "CEO",
role: "ceo",
}),
]);
});
it("treats no-separator auth and api key env names as secrets during export", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.list.mockResolvedValue([
{
id: "agent-1",
name: "ClaudeCoder",
status: "idle",
role: "engineer",
title: "Software Engineer",
icon: "code",
reportsTo: null,
capabilities: "Writes code",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are ClaudeCoder.",
env: {
APIKEY: {
type: "plain",
value: "sk-plain-api",
},
GITHUBAUTH: {
type: "plain",
value: "gh-auth-token",
},
PRIVATEKEY: {
type: "plain",
value: "private-key-value",
},
},
},
runtimeConfig: {},
budgetMonthlyCents: 0,
permissions: {},
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("APIKEY:");
expect(extension).toContain("GITHUBAUTH:");
expect(extension).toContain("PRIVATEKEY:");
expect(extension).not.toContain("sk-plain-api");
expect(extension).not.toContain("gh-auth-token");
expect(extension).not.toContain("private-key-value");
expect(extension).toContain('kind: "secret"');
});
it("imports packaged skills and restores desired skill refs on agents", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, "user-1");
const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string"));
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, {
onConflict: "replace",
});
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
}),
}));
});
it("imports a packaged company logo and attaches it to the target company", async () => {
const storage = {
putFile: vi.fn().mockResolvedValue({
provider: "local_disk",
objectKey: "assets/companies/imported-logo",
contentType: "image/png",
byteSize: 9,
sha256: "logo-sha",
originalFilename: "company-logo.png",
}),
};
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
logoAssetId: null,
});
companySvc.update.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
logoAssetId: "asset-created",
});
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const portability = companyPortabilityService({} as any, storage as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
exported.files["images/company-logo.png"] = {
encoding: "base64",
data: Buffer.from("png-bytes").toString("base64"),
contentType: "image/png",
};
exported.files[".paperclip.yaml"] = `${exported.files[".paperclip.yaml"]}`.replace(
'brandColor: "#5c5fff"\n',
'brandColor: "#5c5fff"\n logoPath: "images/company-logo.png"\n',
);
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(storage.putFile).toHaveBeenCalledWith(expect.objectContaining({
companyId: "company-imported",
namespace: "assets/companies",
originalFilename: "company-logo.png",
contentType: "image/png",
body: Buffer.from("png-bytes"),
}));
expect(assetSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
objectKey: "assets/companies/imported-logo",
contentType: "image/png",
createdByUserId: "user-1",
}));
expect(companySvc.update).toHaveBeenCalledWith("company-imported", {
logoAssetId: "asset-created",
});
});
it("copies source company memberships for safe new-company imports", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, null, {
mode: "agent_safe",
sourceCompanyId: "company-1",
});
expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1");
expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported");
expect(accessSvc.ensureMembership).not.toHaveBeenCalledWith("company-imported", "user", expect.anything(), "owner", "active");
const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string"));
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, {
onConflict: "rename",
});
});
it("disables timer heartbeats on imported agents", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: `agent-${String(input.name).toLowerCase()}`,
name: input.name,
adapterConfig: input.adapterConfig,
runtimeConfig: input.runtimeConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, "user-1");
const createdClaude = agentSvc.create.mock.calls.find(([, input]) => input.name === "ClaudeCoder");
expect(createdClaude?.[1]).toMatchObject({
runtimeConfig: {
heartbeat: {
enabled: false,
maxConcurrentRuns: 20,
},
},
});
});
it("imports only selected files and leaves unchecked company metadata alone", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
description: "Existing company",
brandColor: "#123456",
requireBoardApprovalForNewAgents: false,
});
agentSvc.create.mockResolvedValue({
id: "agent-cmo",
name: "CMO",
});
const result = await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: true,
issues: true,
},
selectedFiles: ["agents/cmo/AGENTS.md"],
target: {
mode: "existing_company",
companyId: "company-1",
},
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(companySvc.update).not.toHaveBeenCalled();
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
"COMPANY.md": expect.any(String),
"agents/cmo/AGENTS.md": expect.any(String),
}),
{
onConflict: "replace",
},
);
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith(
"company-1",
expect.not.objectContaining({
"agents/claudecoder/AGENTS.md": expect.any(String),
}),
{
onConflict: "replace",
},
);
expect(agentSvc.create).toHaveBeenCalledTimes(1);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
name: "CMO",
runtimeConfig: {
heartbeat: {
enabled: false,
maxConcurrentRuns: 20,
},
},
}));
expect(result.company.action).toBe("unchanged");
expect(result.agents).toEqual([
{
slug: "cmo",
id: "agent-cmo",
action: "created",
name: "CMO",
reason: null,
},
]);
});
it("applies adapter overrides while keeping imported AGENTS content implicit", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
dangerouslyBypassApprovalsAndSandbox: true,
instructionsFilePath: "/tmp/should-not-survive.md",
},
},
},
}, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
dangerouslyBypassApprovalsAndSandbox: true,
}),
}));
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.not.objectContaining({
instructionsFilePath: expect.anything(),
promptTemplate: expect.anything(),
}),
}));
expect(agentInstructionsSvc.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({ name: "ClaudeCoder" }),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are ClaudeCoder."),
}),
expect.objectContaining({
clearLegacyPromptTemplate: true,
replaceExisting: true,
}),
);
const materializedFiles = agentInstructionsSvc.materializeManagedBundle.mock.calls[0]?.[1] as Record<string, string>;
expect(materializedFiles["AGENTS.md"]).not.toMatch(/^---\n/);
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("does not implicitly add local adapter permission bypass defaults on import", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
// Imports must preserve safe-by-default local adapter settings unless the package says otherwise.
const firstCreateInput = agentSvc.create.mock.calls[0]?.[1] as Record<string, any>;
expect(firstCreateInput?.adapterConfig).toBeTruthy();
expect(firstCreateInput.adapterConfig?.dangerouslySkipPermissions).toBeUndefined();
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
},
},
},
}, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
extraArgs: ["--skip-git-repo-check"],
args: ["--legacy-arg"],
}),
}));
const lastCreateInput = agentSvc.create.mock.calls.at(-1)?.[1] as Record<string, any>;
expect(lastCreateInput?.adapterConfig).toBeTruthy();
expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined();
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: null,
status: "active",
leadAgentId: null,
metadata: null,
defaultProjectWorkspaceId: null,
},
]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Labelled task",
description: "Has labels",
projectId: "project-1",
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: ["label-a", "label-b"],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: true, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("labelIds:");
expect(extension).toContain("label-a");
expect(extension).toContain("label-b");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
projectSvc.create.mockResolvedValue({ id: "project-imported", name: "Launch", urlKey: "launch" });
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Labelled task" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: true, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.create).toHaveBeenCalledWith(
"company-imported",
expect.objectContaining({
labelIds: ["label-a", "label-b"],
}),
);
});
it("preserves issue comment presentation fields through export and import", async () => {
const portability = companyPortabilityService({} as any);
const presentation = { kind: "system_notice", tone: "warning", detailsDefaultOpen: false };
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Needs disposition",
description: "System notice source",
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("comments:");
expect(extension).toContain("system_notice");
expect(extension).toContain("successful_run_missing_state");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Needs disposition" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation,
metadata,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
});
it("does not export raw comment author user ids", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Private board note",
description: null,
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Need private follow-up.",
presentation: null,
metadata: null,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('authorType: "user"');
expect(extension).not.toContain("authorUserId: local-board");
});
it("downgrades user-authored imported comments to system when no importing user exists", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Private board note",
description: null,
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Need private follow-up.",
presentation: null,
metadata: null,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Private board note" });
const result = await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, null);
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Need private follow-up.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation: null,
metadata: null,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
expect(result.warnings).toContain(
"Comment on task pap-1 was imported as a system comment because no importing user was available.",
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
const originalAgentsMarkdown = exported.files["agents/claudecoder/AGENTS.md"];
expect(typeof originalAgentsMarkdown).toBe("string");
const files = {
...exported.files,
"agents/claudecoder/nested/AGENTS.md": originalAgentsMarkdown!,
};
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
dangerouslyBypassApprovalsAndSandbox: true,
},
},
},
}, "user-1");
const nestedMaterializedFiles = agentInstructionsSvc.materializeManagedBundle.mock.calls
.map(([, filesArg]) => filesArg as Record<string, string>)
.find((filesArg) => typeof filesArg["nested/AGENTS.md"] === "string");
expect(nestedMaterializedFiles).toBeDefined();
expect(nestedMaterializedFiles?.["nested/AGENTS.md"]).toContain("You are ClaudeCoder.");
expect(nestedMaterializedFiles?.["AGENTS.md"]).toContain("You are ClaudeCoder.");
expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toMatch(/^---\n/);
expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("rejects dangerous adapter types on agent-safe imports", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await expect(portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "process",
adapterConfig: {
command: "/bin/sh",
args: ["-c", "id"],
},
},
},
}, "user-1", {
mode: "agent_safe",
sourceCompanyId: "company-1",
})).rejects.toThrow('Adapter type "process" is not allowed in safe imports');
expect(agentSvc.create).not.toHaveBeenCalled();
});
it("imports new agents as active while preserving future hire approval settings", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
normalized: true,
env: {
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-1",
version: "latest",
},
},
});
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
status: input.status,
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-imported",
expect.anything(),
{ strictMode: false },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.objectContaining({
normalized: true,
}),
status: "idle",
}));
expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({
requireBoardApprovalForNewAgents: false,
}));
});
it("normalizes adapter config on replace imports before updating existing agents", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
normalized: "updated",
});
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => ({
id,
name: "ClaudeCoder",
adapterType: patch.adapterType,
adapterConfig: patch.adapterConfig,
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
agents: ["claudecoder"],
collisionStrategy: "replace",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
model: "gpt-5.4",
},
},
},
}, "user-1");
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
model: "gpt-5.4",
extraArgs: ["--skip-git-repo-check"],
}),
{ strictMode: false },
);
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: {
normalized: "updated",
},
}));
});
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { company: false, agents: true, projects: false, issues: false },
});
// Simulate existing agents so collision detection triggers rename
agentSvc.list.mockResolvedValue([
{ id: "existing-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null },
]);
const preview = await portability.previewImport({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: false, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "company-1" },
agents: ["claudecoder"],
collisionStrategy: "rename",
nameOverrides: { claudecoder: "ClaudeCoder" },
});
// The override reverts the renamed agent back to its original collision name.
// This is a known limitation: nameOverrides bypass collision checks.
const plan = preview.plan.agentPlans.find((p) => p.slug === "claudecoder");
expect(plan).toBeDefined();
expect(plan!.action).toBe("create");
expect(plan!.plannedName).toBe("ClaudeCoder");
});
it("handles circular reportsTo chains without infinite recursion during export", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.list.mockResolvedValue([
{
id: "agent-a", name: "AgentA", status: "idle", role: "engineer", title: null, icon: null,
reportsTo: "agent-b", capabilities: null, adapterType: "claude_local",
adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null,
},
{
id: "agent-b", name: "AgentB", status: "idle", role: "manager", title: null, icon: null,
reportsTo: "agent-a", capabilities: null, adapterType: "claude_local",
adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null,
},
]);
agentInstructionsSvc.exportFiles.mockResolvedValue({
files: { "AGENTS.md": "Instructions" }, entryFile: "AGENTS.md", warnings: [],
});
// Export should complete without infinite recursion in org chart building
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: true, projects: false, issues: false },
});
expect(exported.manifest.agents).toHaveLength(2);
// Both agents should appear in the export
const slugs = exported.manifest.agents.map((a) => a.slug);
expect(slugs).toContain("agenta");
expect(slugs).toContain("agentb");
});
it("resolves issue assignee to existing agent when agent is skipped", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([{
id: "project-1", companyId: "company-1", name: "TestProject", urlKey: "testproject",
description: null, leadAgentId: null, targetDate: null, color: null, status: "planned",
executionWorkspacePolicy: null, archivedAt: null, workspaces: [],
}]);
issueSvc.list.mockResolvedValue([{
id: "issue-1", companyId: "company-1", title: "Test task", identifier: "PAP-1",
description: "A test task", status: "todo", priority: "medium",
assigneeAgentId: "agent-1", projectId: "project-1", projectWorkspaceId: null,
goalId: null, parentId: null, billingCode: null, labelIds: [],
executionWorkspaceSettings: null, assigneeAdapterOverrides: null, metadata: null,
}]);
const exported = await portability.exportBundle("company-1", {
include: { company: false, agents: true, projects: true, issues: true },
});
// Re-import into same company with skip collision strategy
// Both agents exist so both will be skipped; the existing agent should resolve for issue assignment
agentSvc.list.mockResolvedValue([
{ id: "agent-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null },
{ id: "agent-2", name: "CMO", status: "idle", role: "cmo", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null },
]);
projectSvc.list.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([]);
projectSvc.create.mockResolvedValue({ id: "project-new", companyId: "company-1", urlKey: "testproject" });
issueSvc.create.mockResolvedValue({ id: "issue-new", identifier: "PAP-100" });
const result = await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: false, agents: true, projects: true, issues: true },
target: { mode: "existing_company", companyId: "company-1" },
agents: "all",
collisionStrategy: "skip",
}, "user-1");
// Both agents should be skipped (already exist)
const agentResult = result.agents.find((a) => a.slug === "claudecoder");
expect(agentResult).toBeDefined();
expect(agentResult!.action).toBe("skipped");
// Issue should still be created and reference the existing agent
expect(issueSvc.create).toHaveBeenCalled();
const issueCreateCall = issueSvc.create.mock.calls[0];
// The assigneeAgentId should resolve to the existing agent via existingSlugToAgentId
expect(issueCreateCall[1]).toEqual(expect.objectContaining({
assigneeAgentId: "agent-1",
}));
});
it("handles a package with only skills (no agents or projects)", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { company: false, agents: false, projects: false, issues: false, skills: true },
expandReferencedSkills: true,
});
expect(exported.manifest.agents).toHaveLength(0);
expect(exported.manifest.projects).toHaveLength(0);
expect(exported.manifest.issues).toHaveLength(0);
// Skills should still be exported
expect(exported.manifest.skills.length).toBeGreaterThanOrEqual(0);
});
it("preview import detects no agents to import when agents are excluded", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: true, projects: false, issues: false },
});
agentSvc.list.mockResolvedValue([]);
const preview = await portability.previewImport({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: false, agents: false, projects: false, issues: false },
target: { mode: "existing_company", companyId: "company-1" },
agents: "all",
collisionStrategy: "rename",
});
expect(preview.plan.agentPlans).toHaveLength(0);
expect(preview.plan.projectPlans).toHaveLength(0);
expect(preview.plan.issuePlans).toHaveLength(0);
});
});