Merge remote-tracking branch 'public-gh/master' into paperclip-routines

* public-gh/master: (46 commits)
  chore(lockfile): refresh pnpm-lock.yaml (#1377)
  fix: manage codex home per company by default
  Ensure agent home directories exist before use
  Handle directory entries in imported zip archives
  Fix portability import and org chart test blockers
  Fix PR verify failures after merge
  fix: address greptile follow-up feedback
  Address remaining Greptile portability feedback
  docs: clarify quickstart npx usage
  Add guarded dev restart handling
  Fix PAP-576 settings toggles and transcript default
  Add username log censor setting
  fix: use standard toggle component for permission controls
  fix: add missing setPrincipalPermission mock in portability tests
  fix: use fixed 1280x640 dimensions for org chart export image
  Adjust default CEO onboarding task copy
  fix: link Agent Company to agentcompanies.io in export README
  fix: strip agents and projects sections from COMPANY.md export body
  fix: default company export page to README.md instead of first file
  Add default agent instructions bundle
  ...

# Conflicts:
#	packages/adapters/pi-local/src/server/execute.ts
#	packages/db/src/migrations/meta/0039_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
#	server/src/__tests__/agent-permissions-routes.test.ts
#	server/src/__tests__/agent-skills-routes.test.ts
#	server/src/services/company-portability.ts
#	skills/paperclip/references/company-skills.md
#	ui/src/api/agents.ts
This commit is contained in:
dotta 2026-03-20 15:04:55 -05:00
commit e3c92a20f1
96 changed files with 15366 additions and 1684 deletions

View file

@ -76,7 +76,9 @@ const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({}));
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
@ -152,6 +154,23 @@ describe("agent permission routes", () => {
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
bundle: null,
adapterConfig: {
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
promptTemplate: files["AGENTS.md"] ?? "",
},
}),
);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
async (_companyId: string, requested: string[]) => requested,
);
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config }));
mockLogActivity.mockResolvedValue(undefined);

View file

@ -20,7 +20,9 @@ const mockAccessService = vi.hoisted(() => ({
setPrincipalPermission: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({}));
const mockHeartbeatService = vi.hoisted(() => ({}));
const mockIssueApprovalService = vi.hoisted(() => ({
@ -180,13 +182,26 @@ describe("agent skill routes", () => {
budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0),
permissions: null,
}));
mockApprovalService.create = vi.fn(async (_companyId: string, input: Record<string, unknown>) => ({
mockApprovalService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: input.payload ?? {},
}));
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
bundle: null,
adapterConfig: {
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
promptTemplate: files["AGENTS.md"] ?? "",
},
}),
);
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
@ -297,6 +312,95 @@ describe("agent skill routes", () => {
);
});
it("materializes a managed AGENTS.md for directly created local agents", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
adapterType: "claude_local",
}),
{ "AGENTS.md": "You are QA." },
{ entryFile: "AGENTS.md", replaceExisting: false },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({
adapterConfig: expect.objectContaining({
promptTemplate: expect.anything(),
}),
});
});
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "CEO",
role: "ceo",
adapterType: "claude_local",
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "ceo",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are the CEO."),
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
"SOUL.md": expect.stringContaining("CEO Persona"),
"TOOLS.md": expect.stringContaining("# Tools"),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "Engineer",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "engineer",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
it("includes canonical desired skills in hire approvals", async () => {
const db = createDb(true);
@ -324,4 +428,35 @@ describe("agent skill routes", () => {
}),
);
});
it("uses managed AGENTS config in hire approval payloads", async () => {
const res = await request(createApp(createDb(true)))
.post("/api/companies/company-1/agent-hires")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
payload: expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
}),
);
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
| { payload?: { adapterConfig?: Record<string, unknown> } }
| undefined;
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
});
});

View file

@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{
kind: "system",
ts,
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
text: "file changes: update /Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx",
},
]);
});

View file

@ -41,6 +41,104 @@ type LogEntry = {
};
describe("codex execute", () => {
it("uses a Paperclip-managed CODEX_HOME outside worktree mode while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-default-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "codex");
const capturePath = path.join(root, "capture.json");
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const managedCodexHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"codex-home",
);
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8");
await writeFakeCodexCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
const previousCodexHome = process.env.CODEX_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_IN_WORKTREE;
process.env.CODEX_HOME = sharedCodexHome;
try {
const logs: LogEntry[] = [];
const result = await execute({
runId: "run-default",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(managedCodexHome);
const managedAuth = path.join(managedCodexHome, "auth.json");
const managedConfig = path.join(managedCodexHome, "config.toml");
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
expect((await fs.lstat(managedConfig)).isFile()).toBe(true);
expect(await fs.readFile(managedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
await expect(fs.lstat(path.join(sharedCodexHome, "companies", "company-1"))).rejects.toThrow();
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
chunk: expect.stringContaining("Using Paperclip-managed Codex home"),
}),
);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
const workspace = path.join(root, "workspace");

View file

@ -97,7 +97,7 @@ describe("company portability routes", () => {
});
mockCompanyPortabilityService.previewExport.mockResolvedValue({
rootPath: "paperclip",
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
files: {},
fileInventory: [],
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },

View file

@ -1,5 +1,6 @@
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const companySvc = {
getById: vi.fn(),
@ -82,8 +83,17 @@ vi.mock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => agentInstructionsSvc,
}));
vi.mock("../routes/org-chart-svg.js", () => ({
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
}));
const { companyPortabilityService } = 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";
@ -303,19 +313,19 @@ describe("company portability", () => {
},
});
expect(exported.files["COMPANY.md"]).toContain('name: "Paperclip"');
expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"');
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder.");
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("skills:");
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain(`- "${paperclipKey}"`);
expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:");
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("metadata:");
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain('kind: "github-dir"');
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(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toContain("# Company Playbook");
expect(exported.files["skills/company/PAP/company-playbook/references/checklist.md"]).toContain("# Checklist");
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 = exported.files[".paperclip.yaml"];
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('schema: "paperclip/v1"');
expect(extension).not.toContain("promptTemplate");
expect(extension).not.toContain("instructionsFilePath");
@ -347,9 +357,45 @@ describe("company portability", () => {
expandReferencedSkills: true,
});
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("# Paperclip");
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("metadata:");
expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toContain("# API");
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 () => {
@ -476,9 +522,9 @@ describe("company portability", () => {
},
});
expect(exported.files["skills/local/release-changelog/SKILL.md"]).toContain("# Local Release Changelog");
expect(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"]).toContain("metadata:");
expect(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"]).toContain("paperclipai/paperclip/release-changelog");
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 () => {
@ -582,6 +628,181 @@ describe("company portability", () => {
]);
});
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("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);
@ -626,7 +847,8 @@ describe("company portability", () => {
collisionStrategy: "rename",
}, "user-1");
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, {
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({
@ -772,7 +994,8 @@ describe("company portability", () => {
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");
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, {
const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string"));
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, {
onConflict: "rename",
});
});

View file

@ -35,14 +35,36 @@ describe("company skill import source parsing", () => {
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.requestedSkillSlug).toBe("find-skills");
expect(parsed.originalSkillsShUrl).toBeNull();
expect(parsed.warnings).toEqual([]);
});
it("parses owner/repo/skill shorthand as a GitHub repo plus requested skill", () => {
it("parses owner/repo/skill shorthand as skills.sh-managed", () => {
const parsed = parseSkillImportSourceInput("vercel-labs/skills/find-skills");
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.requestedSkillSlug).toBe("find-skills");
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills/find-skills");
});
it("resolves skills.sh URL with org/repo/skill to GitHub repo and preserves original URL", () => {
const parsed = parseSkillImportSourceInput(
"https://skills.sh/google-labs-code/stitch-skills/design-md",
);
expect(parsed.resolvedSource).toBe("https://github.com/google-labs-code/stitch-skills");
expect(parsed.requestedSkillSlug).toBe("design-md");
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/google-labs-code/stitch-skills/design-md");
});
it("resolves skills.sh URL with org/repo (no skill) to GitHub repo and preserves original URL", () => {
const parsed = parseSkillImportSourceInput(
"https://skills.sh/vercel-labs/skills",
);
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.requestedSkillSlug).toBeNull();
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills");
});
it("parses skills.sh commands whose requested skill differs from the folder name", () => {
@ -52,6 +74,14 @@ describe("company skill import source parsing", () => {
expect(parsed.resolvedSource).toBe("https://github.com/remotion-dev/skills");
expect(parsed.requestedSkillSlug).toBe("remotion-best-practices");
expect(parsed.originalSkillsShUrl).toBeNull();
});
it("does not set originalSkillsShUrl for owner/repo shorthand", () => {
const parsed = parseSkillImportSourceInput("vercel-labs/skills");
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.originalSkillsShUrl).toBeNull();
});
});
@ -108,6 +138,45 @@ describe("project workspace skill discovery", () => {
expect(imported.fileInventory.map((entry) => entry.kind)).toContain("script");
expect(imported.metadata?.sourceKind).toBe("project_scan");
});
it("parses inline object array items in skill frontmatter metadata", async () => {
const workspace = await makeTempDir("paperclip-inline-skill-yaml-");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "SKILL.md"),
[
"---",
"name: Inline Metadata Skill",
"metadata:",
" sources:",
" - kind: github-dir",
" repo: paperclipai/paperclip",
" path: skills/paperclip",
"---",
"",
"# Inline Metadata Skill",
"",
].join("\n"),
"utf8",
);
const imported = await readLocalSkillImportFromDirectory(
"33333333-3333-4333-8333-333333333333",
workspace,
{ inventoryMode: "full" },
);
expect(imported.metadata).toMatchObject({
sourceKind: "local_path",
sources: [
{
kind: "github-dir",
repo: "paperclipai/paperclip",
path: "skills/paperclip",
},
],
});
});
});
describe("missing local skill reconciliation", () => {

View file

@ -0,0 +1,66 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
const tempDirs = [];
function createTempStatusFile(payload: unknown) {
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-status-"));
tempDirs.push(dir);
const filePath = path.join(dir, "dev-server-status.json");
writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
return filePath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("dev server status helpers", () => {
it("reads and normalizes persisted supervisor state", () => {
const filePath = createTempStatusFile({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 4,
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
pendingMigrations: ["0040_restart_banner.sql"],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toEqual({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 4,
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
pendingMigrations: ["0040_restart_banner.sql"],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
});
it("derives waiting-for-idle health state", () => {
const health = toDevServerHealthStatus(
{
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 2,
changedPathsSample: ["server/src/app.ts"],
pendingMigrations: [],
lastRestartAt: "2026-03-20T11:30:00.000Z",
},
{ autoRestartEnabled: true, activeRunCount: 3 },
);
expect(health).toMatchObject({
enabled: true,
restartRequired: true,
reason: "backend_changes",
autoRestartEnabled: true,
activeRunCount: 3,
waitingForIdle: true,
});
});
});

View file

@ -5,7 +5,9 @@ import { errorHandler } from "../middleware/index.js";
import { instanceSettingsRoutes } from "../routes/instance-settings.js";
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(),
getExperimental: vi.fn(),
updateGeneral: vi.fn(),
updateExperimental: vi.fn(),
listCompanyIds: vi.fn(),
}));
@ -31,13 +33,24 @@ function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
});
mockInstanceSettingsService.updateGeneral.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
},
});
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
id: "instance-settings-1",
experimental: {
enableIsolatedWorkspaces: true,
autoRestartDevServerWhenIdle: false,
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
@ -53,7 +66,10 @@ describe("instance settings routes", () => {
const getRes = await request(app).get("/api/instance/settings/experimental");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ enableIsolatedWorkspaces: false });
expect(getRes.body).toEqual({
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
});
const patchRes = await request(app)
.patch("/api/instance/settings/experimental")
@ -66,6 +82,47 @@ describe("instance settings routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("allows local board users to update guarded dev-server auto-restart", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({ autoRestartDevServerWhenIdle: true })
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
autoRestartDevServerWhenIdle: true,
});
});
it("allows local board users to read and update general settings", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
const getRes = await request(app).get("/api/instance/settings/general");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ censorUsernameInLogs: false });
const patchRes = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("rejects non-admin board users", async () => {
const app = createApp({
type: "board",
@ -75,10 +132,10 @@ describe("instance settings routes", () => {
companyIds: ["company-1"],
});
const res = await request(app).get("/api/instance/settings/experimental");
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getExperimental).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {
@ -90,10 +147,10 @@ describe("instance settings routes", () => {
});
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
CURRENT_USER_REDACTION_TOKEN,
maskUserNameForLogs,
redactCurrentUserText,
redactCurrentUserValue,
} from "../log-redaction.js";
@ -8,6 +8,7 @@ import {
describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const input = [
`cwd=/Users/${userName}/paperclip`,
`home=/home/${userName}/workspace`,
@ -19,14 +20,15 @@ describe("log redaction", () => {
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
});
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
expect(result).toContain(`cwd=/Users/${maskedUserName}/paperclip`);
expect(result).toContain(`home=/home/${maskedUserName}/workspace`);
expect(result).toContain(`win=C:\\Users\\${maskedUserName}\\paperclip`);
expect(result).not.toContain(userName);
});
it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{
@ -36,12 +38,13 @@ describe("log redaction", () => {
);
expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
`user ${maskedUserName} said ${maskedUserName}/project should stay but apaperclipuserz should not change`,
);
});
it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserValue({
cwd: `/Users/${userName}/paperclip`,
prompt: `open /Users/${userName}/paperclip/ui`,
@ -55,12 +58,17 @@ describe("log redaction", () => {
});
expect(result).toEqual({
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
cwd: `/Users/${maskedUserName}/paperclip`,
prompt: `open /Users/${maskedUserName}/paperclip/ui`,
nested: {
author: CURRENT_USER_REDACTION_TOKEN,
author: maskedUserName,
},
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
values: [maskedUserName, `/home/${maskedUserName}/project`],
});
});
it("skips redaction when disabled", () => {
const input = "cwd=/Users/paperclipuser/paperclip";
expect(redactCurrentUserText(input, { enabled: false })).toBe(input);
});
});