diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index a70ff848..9d6c2851 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -476,6 +476,7 @@ export type {
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
+ CompanyPortabilitySecretEntry,
EnvBinding,
AgentEnvConfig,
CompanySecret,
@@ -817,6 +818,7 @@ export {
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillImportSchema,
+ companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts
index 1b30713c..ea5e7b1c 100644
--- a/packages/shared/src/types/company-portability.ts
+++ b/packages/shared/src/types/company-portability.ts
@@ -1,4 +1,4 @@
-import type { AgentEnvConfig } from "./secrets.js";
+import type { AgentEnvConfig, SecretProvider } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityInclude {
@@ -18,6 +18,10 @@ export interface CompanyPortabilityEnvInput {
requirement: "required" | "optional";
defaultValue: string | null;
portability: "portable" | "system_dependent";
+ secretName?: string | null;
+ secretProvider?: string | null;
+ /** Binding type — stored in extension.inputs.env but not in the manifest type itself */
+ type?: "secret_ref" | "plain";
}
export type CompanyPortabilityFileEntry =
@@ -166,6 +170,15 @@ export interface CompanyPortabilityManifest {
projects: CompanyPortabilityProjectManifestEntry[];
issues: CompanyPortabilityIssueManifestEntry[];
envInputs: CompanyPortabilityEnvInput[];
+ secrets?: CompanyPortabilitySecretEntry[];
+}
+
+export interface CompanyPortabilitySecretEntry {
+ name: string;
+ provider: SecretProvider;
+ description: string | null;
+ latestVersion: number;
+ currentValue: string;
}
export interface CompanyPortabilityExportResult {
@@ -317,4 +330,5 @@ export interface CompanyPortabilityExportRequest {
selectedFiles?: string[];
expandReferencedSkills?: boolean;
sidebarOrder?: Partial;
+ includeSecrets?: boolean;
}
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 25166759..e6cce5f8 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -306,6 +306,7 @@ export type {
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
+ CompanyPortabilitySecretEntry,
} from "./company-portability.js";
export type {
JsonSchema,
diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts
index ec9df1c2..e25d17c1 100644
--- a/packages/shared/src/validators/company-portability.ts
+++ b/packages/shared/src/validators/company-portability.ts
@@ -21,6 +21,9 @@ export const portabilityEnvInputSchema = z.object({
requirement: z.enum(["required", "optional"]),
defaultValue: z.string().nullable(),
portability: z.enum(["portable", "system_dependent"]),
+ secretName: z.string().min(1).nullable().optional(),
+ secretProvider: z.string().min(1).nullable().optional(),
+ type: z.enum(["secret_ref", "plain"]).optional(),
});
export const portabilityFileEntrySchema = z.union([
@@ -175,6 +178,13 @@ export const portabilityManifestSchema = z.object({
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
issues: z.array(portabilityIssueManifestEntrySchema).default([]),
envInputs: z.array(portabilityEnvInputSchema).default([]),
+ secrets: z.array(z.object({
+ name: z.string().min(1),
+ provider: z.string().min(1),
+ description: z.string().nullable(),
+ latestVersion: z.number().int().nonnegative(),
+ currentValue: z.string(),
+ })).optional(),
});
export const portabilitySourceSchema = z.discriminatedUnion("type", [
@@ -217,6 +227,7 @@ export const companyPortabilityExportSchema = z.object({
selectedFiles: z.array(z.string().min(1)).optional(),
expandReferencedSkills: z.boolean().optional(),
sidebarOrder: portabilitySidebarOrderSchema.partial().optional(),
+ includeSecrets: z.boolean().optional(),
});
export type CompanyPortabilityExport = z.infer;
diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts
index eb1df5ce..4cfb701c 100644
--- a/packages/shared/src/validators/company-skill.ts
+++ b/packages/shared/src/validators/company-skill.ts
@@ -68,6 +68,11 @@ export const companySkillUpdateStatusSchema = z.object({
export const companySkillImportSchema = z.object({
source: z.string().min(1),
+ authToken: z.string().min(1).optional(),
+});
+
+export const companySkillUpdateAuthSchema = z.object({
+ authToken: z.string().min(1).nullable(),
});
export const companySkillProjectScanRequestSchema = z.object({
@@ -135,3 +140,4 @@ export type CompanySkillImport = z.infer;
export type CompanySkillProjectScan = z.infer;
export type CompanySkillCreate = z.infer;
export type CompanySkillFileUpdate = z.infer;
+export type CompanySkillUpdateAuth = z.infer;
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 7b916e6b..33500e6b 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -63,6 +63,7 @@ export {
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillImportSchema,
+ companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -74,6 +75,7 @@ export {
type CompanySkillProjectScan,
type CompanySkillCreate,
type CompanySkillFileUpdate,
+ type CompanySkillUpdateAuth,
} from "./company-skill.js";
export {
agentSkillStateSchema,
diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts
index fecd7a78..ca49af98 100644
--- a/server/src/__tests__/company-portability.test.ts
+++ b/server/src/__tests__/company-portability.test.ts
@@ -14,6 +14,7 @@ const companySvc = {
const agentSvc = {
list: vi.fn(),
+ getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
@@ -27,6 +28,7 @@ const accessSvc = {
const projectSvc = {
list: vi.fn(),
+ getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
@@ -62,6 +64,26 @@ const assetSvc = {
const secretSvc = {
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config, secretKeys: new Set() })),
+ normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env as Record),
+ getById: vi.fn(async (id: string) => {
+ if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
+ if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
+ return null;
+ }),
+ resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: "latest") => {
+ if (secretId === "secret-1") return "sk-ant-secret-xxx";
+ if (secretId === "secret-2") return "ghp_secretxxx";
+ throw new Error("Secret not found");
+ }),
+ create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
+ id: `new-secret-${input.name}`,
+ companyId,
+ name: input.name,
+ provider: input.provider,
+ description: input.description ?? null,
+ latestVersion: 1,
+ })),
+ getByName: vi.fn(async (_companyId: string, name: string) => null),
};
const agentInstructionsSvc = {
@@ -448,7 +470,6 @@ describe("company portability", () => {
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"');
@@ -1199,6 +1220,9 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
+ secretName: "anthropic-api-key",
+ secretProvider: "local_encrypted",
+ type: "secret_ref",
},
{
key: "GH_TOKEN",
@@ -1209,6 +1233,9 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
+ secretName: "gh-token",
+ secretProvider: "local_encrypted",
+ type: "secret_ref",
},
]);
});
@@ -1332,6 +1359,9 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
+ secretName: null,
+ secretProvider: null,
+ type: "plain",
});
});
@@ -2646,6 +2676,191 @@ describe("company portability", () => {
}));
});
+ describe("secret env vars", () => {
+ beforeEach(() => {
+ // Reset create/getByName to ensure clean state per test
+ secretSvc.create.mockReset();
+ secretSvc.getByName.mockReset();
+ secretSvc.getById.mockImplementation(async (id: string) => {
+ if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
+ if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
+ return null;
+ });
+ secretSvc.resolveSecretValue.mockImplementation(async (_companyId: string, secretId: string) => {
+ if (secretId === "secret-1") return "sk-ant-secret-xxx";
+ if (secretId === "secret-2") return "ghp_secretxxx";
+ throw new Error("Secret not found");
+ });
+ secretSvc.create.mockImplementation(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
+ id: `new-secret-${input.name}`,
+ companyId,
+ name: input.name,
+ provider: input.provider,
+ description: input.description ?? null,
+ latestVersion: 1,
+ }));
+ secretSvc.getByName.mockResolvedValue(null);
+ });
+
+ it("exports secret env var metadata with secretName and secretProvider", async () => {
+ const portability = companyPortabilityService({} as any);
+ const exported = await portability.exportBundle("company-1", {
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ agents: ["claudecoder"],
+ });
+ const secretInput = exported.manifest.envInputs.find(
+ (e: any) => e.key === "ANTHROPIC_API_KEY" && e.kind === "secret",
+ );
+ expect(secretInput).toBeDefined();
+ expect(secretInput.secretName).toBe("anthropic-api-key");
+ expect(secretInput.secretProvider).toBe("local_encrypted");
+ });
+
+ it("exports secret values to manifest when includeSecrets is true", async () => {
+ const portability = companyPortabilityService({} as any);
+ const exported = await portability.exportBundle("company-1", {
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ agents: ["claudecoder"],
+ includeSecrets: true,
+ });
+ expect(exported.manifest.secrets).toBeDefined();
+ expect(exported.manifest.secrets).toContainEqual(expect.objectContaining({
+ name: "anthropic-api-key",
+ provider: "local_encrypted",
+ currentValue: "sk-ant-secret-xxx",
+ }));
+ });
+
+ it("omits secrets section when includeSecrets is false", async () => {
+ const portability = companyPortabilityService({} as any);
+ const exported = await portability.exportBundle("company-1", {
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ agents: ["claudecoder"],
+ includeSecrets: false,
+ });
+ expect(exported.manifest.secrets).toBeUndefined();
+ });
+
+ it("writes placeholder when resolveSecretValue throws (cross-instance decryption failure)", async () => {
+ secretSvc.resolveSecretValue.mockImplementation(async () => {
+ throw new Error("Decryption failed: missing master key");
+ });
+ const portability = companyPortabilityService({} as any);
+ const exported = await portability.exportBundle("company-1", {
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ agents: ["claudecoder"],
+ includeSecrets: true,
+ });
+ const secretEntry = exported.manifest.secrets?.find((s: any) => s.name === "anthropic-api-key");
+ expect(secretEntry?.currentValue).toBe("");
+ expect(exported.warnings).toContainEqual(expect.stringContaining("could not be decrypted during export"));
+ });
+
+ it("imports secrets and remaps secret_ref bindings to new secret IDs", async () => {
+ const portability = companyPortabilityService({} as any);
+ agentSvc.create.mockImplementation(async (companyId: string, patch: Record) => ({
+ id: "new-agent-1",
+ companyId,
+ ...patch,
+ }));
+ agentSvc.update.mockImplementation(async (id: string, patch: Record) => patch as any);
+ agentSvc.getById.mockImplementation(async (id: string) => {
+ if (id === "new-agent-1") {
+ return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
+ }
+ return null;
+ });
+ const exported = await portability.exportBundle("company-1", {
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ agents: ["claudecoder"],
+ includeSecrets: true,
+ });
+ const imported = await portability.importBundle({
+ source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ target: { mode: "existing_company", companyId: "company-imported" },
+ agents: ["claudecoder"],
+ collisionStrategy: "rename",
+ }, "user-1");
+ expect(secretSvc.create).toHaveBeenCalled();
+ expect(agentSvc.update).toHaveBeenCalledWith(
+ "new-agent-1",
+ expect.any(Object),
+ );
+ });
+
+ it("reuses existing secret on conflict during import", async () => {
+ secretSvc.getByName.mockImplementation(async (_companyId: string, name: string) => {
+ if (name === "anthropic-api-key") return { id: "existing-secret-1", name, provider: "local_encrypted" };
+ return null;
+ });
+ const portability = companyPortabilityService({} as any);
+ agentSvc.create.mockImplementation(async (companyId: string, patch: Record) => ({
+ id: "new-agent-1",
+ companyId,
+ ...patch,
+ }));
+ agentSvc.update.mockImplementation(async (id: string, patch: Record) => patch as any);
+ agentSvc.getById.mockImplementation(async (id: string) => {
+ if (id === "new-agent-1") {
+ return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
+ }
+ return null;
+ });
+ const exported = await portability.exportBundle("company-1", {
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ agents: ["claudecoder"],
+ includeSecrets: true,
+ });
+ await portability.importBundle({
+ source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ target: { mode: "existing_company", companyId: "company-imported" },
+ agents: ["claudecoder"],
+ collisionStrategy: "rename",
+ }, "user-1");
+ expect(agentSvc.update).toHaveBeenCalled();
+ });
+
+ it("exports plain env vars faithfully", async () => {
+ agentSvc.list.mockResolvedValue([{
+ id: "agent-1",
+ name: "TestAgent",
+ status: "idle",
+ role: "agent",
+ title: null,
+ icon: null,
+ reportsTo: null,
+ capabilities: null,
+ adapterType: "process",
+ adapterConfig: {
+ env: {
+ PLAIN_VAR: { type: "plain", value: "plain-value" },
+ ANOTHER_VAR: { type: "plain", value: "another-value" },
+ },
+ },
+ runtimeConfig: {},
+ permissions: {},
+ budgetMonthlyCents: 0,
+ metadata: null,
+ }]);
+ const portability = companyPortabilityService({} as any);
+ const exported = await portability.exportBundle("company-1", {
+ include: { agents: true, company: false, projects: false, issues: false, skills: false },
+ agents: ["testagent"],
+ });
+ const plainInputs = exported.manifest.envInputs.filter((e: any) => e.kind === "plain");
+ expect(plainInputs).toContainEqual(expect.objectContaining({
+ key: "PLAIN_VAR",
+ defaultValue: "plain-value",
+ }));
+ expect(plainInputs).toContainEqual(expect.objectContaining({
+ key: "ANOTHER_VAR",
+ defaultValue: "another-value",
+ }));
+ });
+ });
+
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
const portability = companyPortabilityService({} as any);
diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts
index d18bc1f4..86ebc9da 100644
--- a/server/src/__tests__/company-skills-routes.test.ts
+++ b/server/src/__tests__/company-skills-routes.test.ts
@@ -14,6 +14,8 @@ const mockAccessService = vi.hoisted(() => ({
const mockCompanySkillService = vi.hoisted(() => ({
importFromSource: vi.fn(),
deleteSkill: vi.fn(),
+ updateSkillAuth: vi.fn(),
+ scanProjectWorkspaces: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -97,6 +99,15 @@ describe("company skill mutation permissions", () => {
slug: "find-skills",
name: "Find Skills",
});
+ mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
+ scannedProjects: 1,
+ scannedWorkspaces: 2,
+ discovered: [],
+ imported: [],
+ updated: [],
+ conflicts: [],
+ warnings: [],
+ });
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
@@ -294,9 +305,120 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
+ undefined,
);
});
+ it("passes a PAT through skill import requests", async () => {
+ const res = await request(await createApp({
+ type: "board",
+ userId: "local-board",
+ companyIds: ["company-1"],
+ source: "local_implicit",
+ isInstanceAdmin: false,
+ }))
+ .post("/api/companies/company-1/skills/import")
+ .send({
+ source: "https://github.com/vercel-labs/agent-browser",
+ authToken: "ghp_private_token",
+ });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(201);
+ expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
+ "company-1",
+ "https://github.com/vercel-labs/agent-browser",
+ "ghp_private_token",
+ );
+ });
+
+ it("updates a skill auth token", async () => {
+ mockCompanySkillService.updateSkillAuth.mockResolvedValue({
+ id: "skill-1",
+ slug: "find-skills",
+ });
+
+ const res = await request(await createApp({
+ type: "board",
+ userId: "local-board",
+ companyIds: ["company-1"],
+ source: "local_implicit",
+ isInstanceAdmin: false,
+ }))
+ .patch("/api/companies/company-1/skills/skill-1/auth")
+ .send({ authToken: "ghp_private_token" });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
+ "company-1",
+ "skill-1",
+ "ghp_private_token",
+ );
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ companyId: "company-1",
+ action: "company.skill_auth_updated",
+ entityType: "company_skill",
+ entityId: "skill-1",
+ details: { slug: "find-skills" },
+ }),
+ );
+ });
+
+ it("clears a skill auth token", async () => {
+ mockCompanySkillService.updateSkillAuth.mockResolvedValue({
+ id: "skill-1",
+ slug: "find-skills",
+ });
+
+ const res = await request(await createApp({
+ type: "board",
+ userId: "local-board",
+ companyIds: ["company-1"],
+ source: "local_implicit",
+ isInstanceAdmin: false,
+ }))
+ .patch("/api/companies/company-1/skills/skill-1/auth")
+ .send({ authToken: null });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
+ "company-1",
+ "skill-1",
+ null,
+ );
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ companyId: "company-1",
+ action: "company.skill_auth_removed",
+ entityType: "company_skill",
+ entityId: "skill-1",
+ details: { slug: "find-skills" },
+ }),
+ );
+ });
+
+ it("allows agents with canCreateAgents to scan project workspaces", async () => {
+ mockAgentService.getById.mockResolvedValue({
+ id: "agent-1",
+ companyId: "company-1",
+ permissions: { canCreateAgents: true },
+ });
+
+ const res = await request(await createApp({
+ type: "agent",
+ agentId: "agent-1",
+ companyId: "company-1",
+ runId: "run-1",
+ }))
+ .post("/api/companies/company-1/skills/scan-projects")
+ .send({});
+
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {});
+ });
+
it("returns a blocking error when attempting to delete a skill still used by agents", async () => {
const { unprocessable } = await import("../errors.js");
mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => {
diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts
index 9e91bf26..402ffcbc 100644
--- a/server/src/routes/company-skills.ts
+++ b/server/src/routes/company-skills.ts
@@ -4,6 +4,7 @@ import {
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillImportSchema,
+ companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
@@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
- const result = await svc.importFromSource(companyId, source);
+ const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
+ const result = await svc.importFromSource(companyId, source, authToken || undefined);
const actor = getActorInfo(req);
await logActivity(db, {
@@ -318,5 +320,38 @@ export function companySkillRoutes(db: Db) {
res.json(result);
});
+ router.patch(
+ "/companies/:companyId/skills/:skillId/auth",
+ validate(companySkillUpdateAuthSchema),
+ async (req, res) => {
+ const companyId = req.params.companyId as string;
+ const skillId = req.params.skillId as string;
+ await assertCanMutateCompanySkills(req, companyId);
+ const authToken = req.body.authToken as string | null;
+ const result = await svc.updateSkillAuth(companyId, skillId, authToken);
+ if (!result) {
+ res.status(404).json({ error: "Skill not found" });
+ return;
+ }
+
+ const actor = getActorInfo(req);
+ await logActivity(db, {
+ companyId,
+ actorType: actor.actorType,
+ actorId: actor.actorId,
+ agentId: actor.agentId,
+ runId: actor.runId,
+ action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
+ entityType: "company_skill",
+ entityId: result.id,
+ details: {
+ slug: result.slug,
+ },
+ });
+
+ res.json(result);
+ },
+ );
+
return router;
}
diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts
index 88b66f6d..b7e186a2 100644
--- a/server/src/services/company-portability.ts
+++ b/server/src/services/company-portability.ts
@@ -26,9 +26,11 @@ import type {
CompanyPortabilityIssueManifestEntry,
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
+ CompanyPortabilitySecretEntry,
CompanySkill,
AgentEnvConfig,
RoutineVariable,
+ SecretProvider,
} from "@paperclipai/shared";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
@@ -50,7 +52,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
-import { forbidden, notFound, unprocessable } from "../errors.js";
+import { forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js";
@@ -399,7 +401,7 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
return parsed.success ? parsed.data : null;
}
-function extractPortableScopedEnvInputs(
+async function extractPortableScopedEnvInputs(
scope: {
label: string;
warningPrefix: string;
@@ -408,7 +410,11 @@ function extractPortableScopedEnvInputs(
},
envValue: unknown,
warnings: string[],
-): CompanyPortabilityEnvInput[] {
+ secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise },
+ secretEntries: CompanyPortabilitySecretEntry[],
+ includeSecrets: boolean,
+ companyId: string,
+): Promise {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record;
const inputs: CompanyPortabilityEnvInput[] = [];
@@ -420,6 +426,7 @@ function extractPortableScopedEnvInputs(
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
+ const secret = await secrets.getById(String(binding.secretId));
inputs.push({
key,
description: `Provide ${key} for ${scope.label}`,
@@ -429,7 +436,33 @@ function extractPortableScopedEnvInputs(
requirement: "optional",
defaultValue: "",
portability: "portable",
+ secretName: secret?.name ?? null,
+ secretProvider: secret?.provider ?? null,
});
+ if (includeSecrets && secret && binding.secretId) {
+ const alreadyExported = secretEntries.some((e) => e.name === secret.name);
+ if (!alreadyExported) {
+ try {
+ const resolvedValue = await secrets.resolveSecretValue(companyId, String(binding.secretId), "latest");
+ secretEntries.push({
+ name: secret.name,
+ provider: secret.provider as SecretProvider,
+ description: secret.description,
+ latestVersion: secret.latestVersion,
+ currentValue: resolvedValue,
+ });
+ } catch {
+ secretEntries.push({
+ name: secret.name,
+ provider: secret.provider as SecretProvider,
+ description: secret.description,
+ latestVersion: secret.latestVersion,
+ currentValue: ``,
+ });
+ warnings.push(`Secret "${secret.name}" could not be decrypted during export. Placeholder written.`);
+ }
+ }
+ }
continue;
}
@@ -439,9 +472,6 @@ function extractPortableScopedEnvInputs(
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
- if (portability === "system_dependent") {
- warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
- }
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -457,9 +487,6 @@ function extractPortableScopedEnvInputs(
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
- if (portability === "system_dependent") {
- warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
- }
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -567,11 +594,14 @@ type AgentLike = {
};
type EnvInputRecord = {
+ type?: "secret_ref" | "plain";
kind: "secret" | "plain";
requirement: "required" | "optional";
default?: string | null;
description?: string | null;
portability?: "portable" | "system_dependent";
+ secretName?: string | null;
+ secretProvider?: string | null;
};
const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record = {
@@ -1623,11 +1653,15 @@ function isAbsoluteCommand(value: string) {
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
}
-function extractPortableEnvInputs(
+async function extractPortableEnvInputs(
agentSlug: string,
envValue: unknown,
warnings: string[],
-): CompanyPortabilityEnvInput[] {
+ secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise },
+ secretEntries: CompanyPortabilitySecretEntry[],
+ includeSecrets: boolean,
+ companyId: string,
+): Promise {
return extractPortableScopedEnvInputs(
{
label: `agent ${agentSlug}`,
@@ -1637,14 +1671,22 @@ function extractPortableEnvInputs(
},
envValue,
warnings,
+ secrets,
+ secretEntries,
+ includeSecrets,
+ companyId,
);
}
-function extractPortableProjectEnvInputs(
+async function extractPortableProjectEnvInputs(
projectSlug: string,
envValue: unknown,
warnings: string[],
-): CompanyPortabilityEnvInput[] {
+ secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise },
+ secretEntries: CompanyPortabilitySecretEntry[],
+ includeSecrets: boolean,
+ companyId: string,
+): Promise {
return extractPortableScopedEnvInputs(
{
label: `project ${projectSlug}`,
@@ -1654,6 +1696,10 @@ function extractPortableProjectEnvInputs(
},
envValue,
warnings,
+ secrets,
+ secretEntries,
+ includeSecrets,
+ companyId,
);
}
@@ -2258,6 +2304,13 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
if (input.defaultValue !== null) entry.default = input.defaultValue;
if (input.description) entry.description = input.description;
if (input.portability === "system_dependent") entry.portability = "system_dependent";
+ if (input.secretName) {
+ entry.secretName = input.secretName;
+ entry.type = "secret_ref";
+ } else {
+ entry.type = "plain";
+ }
+ if (input.secretProvider) entry.secretProvider = input.secretProvider;
env[input.key] = entry;
}
return env;
@@ -2302,6 +2355,9 @@ function readAgentEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
+ secretName: record.secretName ?? null,
+ secretProvider: record.secretProvider ?? null,
+ type: record.type,
}];
});
}
@@ -2326,6 +2382,9 @@ function readProjectEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
+ secretName: record.secretName ?? null,
+ secretProvider: record.secretProvider ?? null,
+ type: record.type,
}];
});
}
@@ -2372,6 +2431,7 @@ function buildManifestFromPackageFiles(
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {};
+ const paperclipSecrets = Array.isArray(paperclipExtension.secrets) ? paperclipExtension.secrets : [];
const companyName =
asString(companyFrontmatter.name)
?? opts?.sourceLabel?.companyName
@@ -2455,6 +2515,7 @@ function buildManifestFromPackageFiles(
projects: [],
issues: [],
envInputs: [],
+ secrets: paperclipSecrets.length > 0 ? paperclipSecrets : undefined,
};
const warnings: string[] = [];
@@ -2969,7 +3030,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const files: Record = {};
const warnings: string[] = [];
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
+ const secretEntries: CompanyPortabilitySecretEntry[] = [];
const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder);
+ const includeSecrets = input.includeSecrets === true;
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
let companyLogoPath: string | null = null;
@@ -3249,10 +3312,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(...exportedInstructions.warnings);
const envInputsStart = envInputs.length;
- const exportedEnvInputs = extractPortableEnvInputs(
+ const exportedEnvInputs = await extractPortableEnvInputs(
slug,
(agent.adapterConfig as Record).env,
warnings,
+ secrets,
+ secretEntries,
+ includeSecrets,
+ companyId,
);
envInputs.push(...exportedEnvInputs);
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
@@ -3329,7 +3396,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const slug = projectSlugById.get(project.id)!;
const projectPath = `projects/${slug}/PROJECT.md`;
const envInputsStart = envInputs.length;
- const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
+ const exportedEnvInputs = await extractPortableProjectEnvInputs(slug, project.env, warnings, secrets, secretEntries, includeSecrets, companyId);
envInputs.push(...exportedEnvInputs);
const projectEnvInputs = dedupeEnvInputs(
envInputs
@@ -3534,8 +3601,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
skills: resolved.manifest.skills.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
+ if (includeSecrets) {
+ resolved.manifest.secrets = secretEntries.length > 0 ? secretEntries : undefined;
+ }
resolved.warnings.unshift(...warnings);
+ // Rebuild the YAML file to include secrets so files stay in sync with manifest
+ // Only include secrets - other fields should come from the original YAML structure
+ if (includeSecrets && resolved.manifest.secrets) {
+ // Parse existing YAML and add secrets to it
+ const existingYaml = parseYamlFile(readPortableTextFile(finalFiles, paperclipExtensionPath) ?? "") ?? {};
+ existingYaml.secrets = resolved.manifest.secrets;
+ finalFiles[paperclipExtensionPath] = buildYamlFile(existingYaml, { preserveEmptyStrings: true });
+ }
+
return {
rootPath,
manifest: resolved.manifest,
@@ -4093,6 +4172,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map();
+ const secretNameToId = new Map();
const existingSlugToAgentId = new Map();
const agentStatusById = new Map();
const existingAgents = await agents.list(targetCompany.id);
@@ -4124,6 +4204,35 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
+ // Create secrets in target company and build name->id map
+ for (const secretEntry of sourceManifest.secrets ?? []) {
+ if (secretEntry.currentValue.startsWith(" agent.slug === planAgent.slug);
@@ -4180,6 +4289,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
desiredSkills,
mode,
);
+
+ // Reconstruct adapterConfig.env from manifest.envInputs for this agent
+ const agentEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.agentSlug === manifestAgent.slug);
+ if (agentEnvInputs.length > 0) {
+ const env: Record = {};
+ for (const ei of agentEnvInputs) {
+ if (ei.kind === "secret" && ei.secretName) {
+ const newSecretId = secretNameToId.get(ei.secretName);
+ if (newSecretId) {
+ env[ei.key] = { type: "secret_ref", secretId: newSecretId };
+ } else {
+ warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
+ }
+ } else if (ei.kind === "secret" && !ei.secretName) {
+ warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
+ } else if (ei.kind === "plain" && ei.defaultValue !== null) {
+ env[ei.key] = { type: "plain", value: ei.defaultValue };
+ }
+ }
+ if (Object.keys(env).length > 0) {
+ normalizedAdapter.adapterConfig.env = await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, env as any, { strictMode: strictSecretsMode });
+ }
+ }
+
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@@ -4230,10 +4363,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
- const createdStatus = "idle";
let created = await agents.create(targetCompany.id, {
...patch,
- status: createdStatus,
+ status: "idle",
});
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
@@ -4253,7 +4385,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
- agentStatusById.set(created.id, created.status ?? createdStatus);
+ agentStatusById.set(created.id, created.status ?? "idle");
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
@@ -4302,6 +4434,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
?? null
: null;
const projectWorkspaceIdByKey = new Map();
+ // Build project env from manifest.envInputs filtered by this project
+ const projectEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.projectSlug === planProject.slug);
+ const reconstructedProjectEnv: Record = {};
+ for (const ei of projectEnvInputs) {
+ if (ei.kind === "secret" && ei.secretName) {
+ const newSecretId = secretNameToId.get(ei.secretName);
+ if (newSecretId) {
+ reconstructedProjectEnv[ei.key] = { type: "secret_ref", secretId: newSecretId };
+ } else {
+ warnings.push(`Env key "${ei.key}" for project ${planProject.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
+ }
+ } else if (ei.kind === "secret" && !ei.secretName) {
+ warnings.push(`Env key "${ei.key}" for project ${planProject.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
+ } else if (ei.kind === "plain" && ei.defaultValue !== null) {
+ reconstructedProjectEnv[ei.key] = { type: "plain", value: ei.defaultValue };
+ }
+ }
+ const projectEnvConfig = Object.keys(reconstructedProjectEnv).length > 0
+ ? await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, reconstructedProjectEnv as any, { strictMode: strictSecretsMode })
+ : null;
const projectPatch = {
name: planProject.plannedName,
description: manifestProject.description,
@@ -4311,7 +4463,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog",
- env: manifestProject.env,
+ env: projectEnvConfig ?? undefined,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
};
@@ -4390,6 +4542,91 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
+ // Remap secret_ref bindings in imported agent/project records to target company secret IDs
+ for (const envInput of sourceManifest.envInputs ?? []) {
+ if (envInput.kind !== "secret" || !envInput.secretName) continue;
+ const newSecretId = secretNameToId.get(envInput.secretName);
+ if (!newSecretId) {
+ // secret wasn't created (decryption failure or error) — it's already a placeholder in the env
+ continue;
+ }
+ if (envInput.agentSlug) {
+ const agentId = importedSlugToAgentId.get(envInput.agentSlug);
+ if (agentId) {
+ const agent = await agents.getById(agentId);
+ if (agent) {
+ const adapterConfig = agent.adapterConfig as Record;
+ const env = adapterConfig.env as Record | undefined;
+ let mutated = false;
+ if (env && typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
+ const binding = env[envInput.key] as Record;
+ if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
+ binding.secretId = newSecretId;
+ mutated = true;
+ }
+ }
+ if (mutated) await agents.update(agentId, { adapterConfig });
+ }
+ }
+ } else if (envInput.projectSlug) {
+ const projectId = importedSlugToProjectId.get(envInput.projectSlug);
+ if (projectId) {
+ const project = await projects.getById(projectId);
+ if (project && project.env && typeof project.env === "object") {
+ const env = project.env as Record;
+ let mutated = false;
+ if (typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
+ const binding = env[envInput.key] as Record;
+ if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
+ binding.secretId = newSecretId;
+ mutated = true;
+ }
+ }
+ if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
+ }
+ }
+ }
+ }
+
+ // Note: the legacy secret remapping below is kept as a safety net for
+ // agents/projects that were created/updated before this code existed.
+ // It can be removed once the inline reconstruction above is stable.
+ // Reconstruct plain env bindings and fill in missing env keys on imported agents/projects
+ for (const envInput of sourceManifest.envInputs ?? []) {
+ if (envInput.kind !== "plain" && !(envInput.kind === "secret" && !envInput.secretName)) continue;
+ if (!envInput.defaultValue && envInput.kind === "plain") continue;
+
+ if (envInput.agentSlug) {
+ const agentId = importedSlugToAgentId.get(envInput.agentSlug);
+ if (!agentId) continue;
+ const agent = await agents.getById(agentId);
+ if (!agent) continue;
+ const adapterConfig = agent.adapterConfig as Record;
+ const env = (adapterConfig.env as Record) ?? {};
+ let mutated = false;
+ if (!env[envInput.key] && envInput.kind === "plain") {
+ env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
+ mutated = true;
+ }
+ if (mutated) {
+ adapterConfig.env = env;
+ await agents.update(agentId, { adapterConfig });
+ }
+ } else if (envInput.projectSlug) {
+ const projectId = importedSlugToProjectId.get(envInput.projectSlug);
+ if (!projectId) continue;
+ const project = await projects.getById(projectId);
+ if (!project) continue;
+ const env = (project.env as Record) ?? {};
+ let mutated = false;
+ if (!env[envInput.key] && envInput.kind === "plain") {
+ env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
+ mutated = true;
+ }
+ if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
+ }
+ }
+
if (include.issues) {
const routines = routineService(db);
for (const manifestIssue of sourceManifest.issues) {
diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts
index 0f5af2db..790dd02d 100644
--- a/server/src/services/company-skills.ts
+++ b/server/src/services/company-skills.ts
@@ -32,6 +32,7 @@ import { notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
+import { secretService } from "./secrets.js";
type CompanySkillRow = typeof companySkills.$inferSelect;
type CompanySkillListDbRow = Pick<
@@ -540,20 +541,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record(url: string): Promise {
+async function fetchJson(url: string, authToken?: string): Promise {
const response = await ghFetch(url, {
headers: {
accept: "application/vnd.github+json",
},
- });
+ }, authToken);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
@@ -561,16 +562,18 @@ async function fetchJson(url: string): Promise {
}
-async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
+async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) {
const response = await fetchJson<{ default_branch?: string }>(
`${apiBase}/repos/${owner}/${repo}`,
+ authToken,
);
return asString(response.default_branch) ?? "main";
}
-async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
+async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) {
const response = await fetchJson<{ sha?: string }>(
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
+ authToken,
);
const sha = asString(response.sha);
if (!sha) {
@@ -607,7 +610,7 @@ function parseGitHubSourceUrl(rawUrl: string) {
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
}
-async function resolveGitHubPinnedRef(parsed: ReturnType) {
+async function resolveGitHubPinnedRef(parsed: ReturnType, authToken?: string) {
const apiBase = gitHubApiBase(parsed.hostname);
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
return {
@@ -618,8 +621,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType {
const url = sourceUrl.trim();
const warnings: string[] = [];
@@ -1064,10 +1068,11 @@ async function readUrlSkillImports(
if (looksLikeRepoUrl) {
const parsed = parseGitHubSourceUrl(url);
const apiBase = gitHubApiBase(parsed.hostname);
- const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
+ const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken);
let ref = pinnedRef;
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
+ authToken,
).catch(() => {
throw unprocessable(`Failed to read GitHub tree for ${url}`);
});
@@ -1094,7 +1099,7 @@ async function readUrlSkillImports(
const skills: ImportedSkill[] = [];
for (const relativeSkillPath of skillPaths) {
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
- const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
+ const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken);
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const skillDir = path.posix.dirname(relativeSkillPath);
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
@@ -1156,7 +1161,7 @@ async function readUrlSkillImports(
}
if (url.startsWith("http://") || url.startsWith("https://")) {
- const markdown = await fetchText(url);
+ const markdown = await fetchText(url, authToken);
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const urlObj = new URL(url);
const fileName = path.posix.basename(urlObj.pathname);
@@ -1548,6 +1553,22 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
export function companySkillService(db: Db) {
const agents = agentService(db);
const projects = projectService(db);
+ const secretsSvc = secretService(db);
+
+ async function resolveSkillAuthToken(
+ companyId: string,
+ skill: { metadata: Record | null },
+ ): Promise {
+ const meta = skill.metadata;
+ if (!meta) return undefined;
+ const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : "";
+ if (!secretId) return undefined;
+ try {
+ return await secretsSvc.resolveSecretValue(companyId, secretId, "latest");
+ } catch {
+ return undefined;
+ }
+ }
async function ensureBundledSkills(companyId: string) {
for (const skillsRoot of resolveBundledSkillsRoot()) {
@@ -1766,7 +1787,8 @@ export function companySkillService(db: Db) {
const hostname = asString(metadata.hostname) || "github.com";
const apiBase = gitHubApiBase(hostname);
- const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
+ const authToken = await resolveSkillAuthToken(companyId, skill);
+ const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken);
return {
supported: true,
reason: null,
@@ -1810,8 +1832,9 @@ export function companySkillService(db: Db) {
if (!owner || !repo) {
throw unprocessable("Skill source metadata is incomplete.");
}
+ const authToken = await resolveSkillAuthToken(companyId, skill);
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
- content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
+ content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken);
} else if (skill.sourceType === "url") {
if (normalizedPath !== "SKILL.md") {
throw notFound("This skill source only exposes SKILL.md");
@@ -1928,7 +1951,8 @@ export function companySkillService(db: Db) {
throw unprocessable("Skill source locator is missing.");
}
- const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
+ const authToken = await resolveSkillAuthToken(companyId, skill);
+ const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken);
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
if (!matching) {
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
@@ -2103,6 +2127,28 @@ export function companySkillService(db: Db) {
}
}
+ const sourceLocators = new Set();
+ for (const skill of acceptedSkills) {
+ if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") continue;
+ const locator = skill.sourceLocator ?? "";
+ if (locator) sourceLocators.add(locator);
+ }
+ for (const sourceLocator of sourceLocators) {
+ try {
+ const result = await readUrlSkillImports(companyId, sourceLocator, null);
+ for (const nextSkill of result.skills) {
+ if (acceptedSkills.some((s) => s.slug === nextSkill.slug)) continue;
+ const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
+ if (persisted) {
+ imported.push(persisted);
+ upsertAcceptedSkill(persisted);
+ }
+ }
+ } catch {
+ warnings.push(`Could not re-scan source ${sourceLocator} — skipping.`);
+ }
+ }
+
return {
scannedProjects: scannedProjectIds.size,
scannedWorkspaces: scanTargets.length,
@@ -2340,6 +2386,9 @@ export function companySkillService(db: Db) {
const metadata = {
...(skill.metadata ?? {}),
skillKey: skill.key,
+ ...(existing?.metadata && typeof (existing.metadata as Record).sourceAuthSecretId === "string"
+ ? { sourceAuthSecretId: (existing.metadata as Record).sourceAuthSecretId }
+ : {}),
};
const values = {
companyId,
@@ -2375,7 +2424,7 @@ export function companySkillService(db: Db) {
return out;
}
- async function importFromSource(companyId: string, source: string): Promise {
+ async function importFromSource(companyId: string, source: string, authToken?: string): Promise {
await ensureSkillInventoryCurrent(companyId);
const parsed = parseSkillImportSourceInput(source);
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
@@ -2385,7 +2434,7 @@ export function companySkillService(db: Db) {
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
warnings: parsed.warnings,
}
- : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
+ : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
.then((result) => ({
skills: result.skills,
warnings: [...parsed.warnings, ...result.warnings],
@@ -2412,6 +2461,33 @@ export function companySkillService(db: Db) {
}
}
const imported = await upsertImportedSkills(companyId, filteredSkills);
+
+ if (authToken && imported.length > 0) {
+ for (const skill of imported) {
+ const secretName = `skill-pat:${skill.id}`;
+ let secretId: string;
+ const existing = await secretsSvc.getByName(companyId, secretName);
+ if (existing) {
+ await secretsSvc.rotate(existing.id, { value: authToken });
+ secretId = existing.id;
+ } else {
+ const created = await secretsSvc.create(companyId, {
+ name: secretName,
+ provider: "local_encrypted",
+ value: authToken,
+ description: `GitHub PAT for skill ${skill.slug}`,
+ });
+ secretId = created.id;
+ }
+ const meta = (skill.metadata ?? {}) as Record;
+ meta.sourceAuthSecretId = secretId;
+ await db
+ .update(companySkills)
+ .set({ metadata: meta, updatedAt: new Date() })
+ .where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId)));
+ }
+ }
+
return { imported, warnings };
}
@@ -2451,9 +2527,68 @@ export function companySkillService(db: Db) {
// Clean up materialized runtime files
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
+ const meta = skill.metadata as Record | null;
+ const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
+ if (secretId) {
+ try {
+ await secretsSvc.remove(secretId);
+ } catch {
+ // Best-effort: don't fail the skill deletion if secret cleanup fails
+ }
+ }
+
return skill;
}
+ async function updateSkillAuth(
+ companyId: string,
+ skillId: string,
+ authToken: string | null,
+ ): Promise {
+ const skill = await getById(companyId, skillId);
+ if (!skill) return null;
+
+ const meta = (skill.metadata ?? {}) as Record;
+ const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
+
+ if (authToken) {
+ const secretName = `skill-pat:${skill.id}`;
+ let secretId: string;
+ const existingSecret = existingSecretId
+ ? await secretsSvc.getById(existingSecretId)
+ : await secretsSvc.getByName(companyId, secretName);
+ if (existingSecret) {
+ await secretsSvc.rotate(existingSecret.id, { value: authToken });
+ secretId = existingSecret.id;
+ } else {
+ const created = await secretsSvc.create(companyId, {
+ name: secretName,
+ provider: "local_encrypted",
+ value: authToken,
+ description: `GitHub PAT for skill ${skill.slug}`,
+ });
+ secretId = created.id;
+ }
+ meta.sourceAuthSecretId = secretId;
+ } else {
+ if (existingSecretId) {
+ try {
+ await secretsSvc.remove(existingSecretId);
+ } catch {
+ // Best-effort: don't fail the metadata update if secret deletion fails
+ }
+ }
+ delete meta.sourceAuthSecretId;
+ }
+
+ const [updated] = await db
+ .update(companySkills)
+ .set({ metadata: meta, updatedAt: new Date() })
+ .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
+ .returning();
+ return updated ? toCompanySkill(updated) : null;
+ }
+
return {
list,
listFull,
@@ -2470,6 +2605,7 @@ export function companySkillService(db: Db) {
createLocalSkill,
deleteSkill,
importFromSource,
+ updateSkillAuth,
scanProjectWorkspaces,
importPackageFiles,
installUpdate,
diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts
index 787ae0ef..c279ace5 100644
--- a/server/src/services/github-fetch.ts
+++ b/server/src/services/github-fetch.ts
@@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
}
-export async function ghFetch(url: string, init?: RequestInit): Promise {
+export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise {
+ const headers = new Headers(init?.headers);
+ if (authToken) {
+ headers.set("Authorization", `Bearer ${authToken}`);
+ }
try {
- return await fetch(url, init);
+ return await fetch(url, { ...init, headers, redirect: authToken ? "manual" : "follow" });
} catch {
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
}
diff --git a/skills/paperclip-dev/SKILL.md b/skills/paperclip-dev/SKILL.md
deleted file mode 100644
index d392d327..00000000
--- a/skills/paperclip-dev/SKILL.md
+++ /dev/null
@@ -1,267 +0,0 @@
----
-name: paperclip-dev
-required: false
-description: >
- Develop and operate a local Paperclip instance — start and stop servers,
- pull updates from master, run builds and tests, manage worktrees, back up
- databases, and diagnose problems. Use whenever you need to work on the
- Paperclip codebase itself or keep a running instance healthy.
----
-
-# Paperclip Dev
-
-This skill covers the day-to-day workflows for developing and operating a local Paperclip instance. It assumes you are working inside the Paperclip repo checkout with `origin` pointing to `git@github.com:paperclipai/paperclip.git`.
-
-> **OPEN SOURCE HYGIENE:** This repository is public-facing. Treat anything you push to `origin` as publishable. Never commit or push secrets, API keys, tokens, private logs, PII, customer data, or machine-local configuration that should stay private. Keep git history tidy as well: avoid pushing throwaway branches, noisy checkpoint commits, or speculative work that does not need to be shared upstream.
-
-> **MANDATORY:** Before running any CLI command, building, testing, or managing worktrees, you MUST read `doc/DEVELOPING.md` in the Paperclip repo. It is the canonical reference for all `paperclipai` CLI commands, their options, build/test workflows, database operations, worktree management, and diagnostics. Do NOT guess at flags or options — read the doc first.
-
-## Quick Command Reference
-
-These are the most common commands. For full option tables and details, see `doc/DEVELOPING.md`.
-
-| Task | Command |
-|------|---------|
-| Start server (first time or normal) | `npx paperclipai run` |
-| Dev mode with hot reload | `pnpm dev` |
-| Stop dev server | `pnpm dev:stop` |
-| Build | `pnpm build` |
-| Type-check | `pnpm typecheck` |
-| Run tests | `pnpm test` |
-| Run migrations | `pnpm db:migrate` |
-| Regenerate Drizzle client | `pnpm db:generate` |
-| Back up database | `npx paperclipai db:backup` |
-| Health check | `npx paperclipai doctor --repair` |
-| Print env vars | `npx paperclipai env` |
-| Trigger agent heartbeat | `npx paperclipai heartbeat run --agent-id ` |
-| Install agent skills locally | `npx paperclipai agent local-cli --company-id ` |
-
-## Pulling from Master
-
-```bash
-git fetch origin && git pull origin master
-pnpm install && pnpm build
-```
-
-If schema changes landed, also run `pnpm db:generate && pnpm db:migrate`.
-
-## Worktrees
-
-Paperclip worktrees combine git worktrees with isolated Paperclip instances — each gets its own database, server port, and environment seeded from the primary instance.
-
-> **MANDATORY:** Before creating or managing worktrees, you MUST read the "Worktree-local Instances" and "Worktree CLI Reference" sections in `doc/DEVELOPING.md`. That is the canonical reference for all worktree commands, their options, seed modes, and environment variables.
-
-### When to Use Worktrees
-
-- Starting a feature branch that needs its own Paperclip environment
-- Running parallel agent work without cross-contaminating the primary instance
-- Testing Paperclip changes in isolation before merging
-
-### Command Overview
-
-The CLI has two tiers (see `doc/DEVELOPING.md` for full option tables):
-
-| Command | Purpose |
-|---------|---------|
-| `worktree:make ` | Create worktree + isolated instance in one step |
-| `worktree:list` | List worktrees and their Paperclip status |
-| `worktree:merge-history` | Preview/import issue history between worktrees |
-| `worktree:cleanup ` | Remove worktree, branch, and instance data |
-| `worktree init` | Bootstrap instance inside existing worktree |
-| `worktree env` | Print shell exports for worktree instance |
-| `worktree reseed` | Refresh worktree DB from another instance |
-| `worktree repair` | Fix broken/missing worktree instance metadata |
-
-### Typical Workflow
-
-```bash
-# 1. Create a worktree for a feature
-npx paperclipai worktree:make my-feature --start-point origin/main
-
-# 2. Move into the worktree (path printed by worktree:make) and source the environment
-cd
-eval "$(npx paperclipai worktree env)"
-
-# 3. Start the isolated Paperclip server
-npx paperclipai run
-
-# 4. Do your work
-
-# 5. When done, merge history back if needed
-npx paperclipai worktree:merge-history --from paperclip-my-feature --to current --apply
-
-# 6. Clean up
-npx paperclipai worktree:cleanup my-feature
-```
-
-## Forks — Prefer Pushing to a User Fork
-
-If the user has a personal fork of `paperclipai/paperclip` configured as a git remote, push your feature branches to **that fork** instead of creating branches on the main repo. This keeps the upstream branch list clean and matches the standard open-source contribution flow.
-
-### Detect a fork remote
-
-Before pushing or creating a PR, list remotes and check for one that points at a non-`paperclipai` GitHub fork:
-
-```bash
-git remote -v
-```
-
-Treat any remote whose URL points to `github.com:/paperclip` (or `github.com//paperclip.git`) as the user's fork. Common names are `fork`, ``, or `myfork`. The remote named `origin` or `upstream` that points at `paperclipai/paperclip` is the canonical upstream — do not push feature branches there if a fork exists.
-
-### Pushing to the fork
-
-```bash
-# Push the current branch to the user's fork and set upstream
-git push -u HEAD
-```
-
-Then create the PR from the fork branch:
-
-```bash
-gh pr create --repo paperclipai/paperclip --head : ...
-```
-
-`gh pr create` usually figures out the head ref automatically when run from a branch tracking the fork; the explicit `--head :` form is the reliable fallback when it does not.
-
-### When no fork exists
-
-If `git remote -v` shows only `paperclipai/paperclip` remotes (no user fork), fall back to pushing branches to `origin` as before. Do NOT create a fork on the user's behalf — ask first.
-
-### Keeping the fork up to date
-
-The canonical remote that points at `paperclipai/paperclip` may be named `origin` **or** `upstream` depending on how the user set up the repo. Detect it the same way as in the "Detect a fork remote" step, then fetch and push from/with that remote so the sync works under either convention:
-
-```bash
-UPSTREAM_REMOTE=$(git remote -v | awk '/paperclipai\/paperclip.*\(fetch\)/{print $1; exit}')
-git fetch "$UPSTREAM_REMOTE"
-git push "${UPSTREAM_REMOTE}/master:master"
-```
-
-## Pull Requests
-
-> **MANDATORY PRE-FLIGHT:** Before creating ANY pull request, you MUST read the canonical source files listed below. Do NOT run `gh pr create` until you have read these files and verified your PR body matches every required section.
-
-### Step 1 — Read the canonical files
-
-You MUST read all three of these files before creating a PR:
-
-1. **`.github/PULL_REQUEST_TEMPLATE.md`** — the required PR body structure
-2. **`CONTRIBUTING.md`** — contribution conventions, PR requirements, and thinking-path examples
-3. **`.github/workflows/pr.yml`** — CI checks that gate merge
-
-### Step 2 — Validate your PR body against this checklist
-
-After reading the template, verify your `--body` includes every one of these sections (names must match exactly):
-
-- [ ] `## Thinking Path` — blockquote style, 5-8 reasoning steps
-- [ ] `## What Changed` — bullet list of concrete changes
-- [ ] `## Verification` — how a reviewer confirms this works
-- [ ] `## Risks` — what could go wrong
-- [ ] `## Model Used` — provider, model ID, version, capabilities
-- [ ] `## Checklist` — copied from the template, items checked off
-
-If any section is missing or empty, do NOT submit the PR. Go back and fill it in.
-
-### Step 3 — Create the PR
-
-Only after completing Steps 1 and 2, run `gh pr create`. Use the template contents as the structure for `--body` — do not write a freeform summary.
-
-## Hard Rules — Do NOT Bypass
-
-These rules exist because agents have caused real damage by improvising around CLI failures. Follow them exactly.
-
-1. **CLI is the only interface to worktrees and databases.** All worktree and database operations MUST go through `npx paperclipai` / `pnpm paperclipai` commands. You MUST NOT:
- - Run `pg_dump`, `pg_restore`, `psql`, `createdb`, `dropdb`, or any raw postgres commands
- - Manually set `DATABASE_URL` to point a worktree server at another instance's database
- - Run `rm -rf` on any `.paperclip/`, `.paperclip-worktrees/`, or `db/` directory
- - Directly manipulate embedded postgres data directories
- - Kill postgres processes by PID
-
-2. **If a CLI command fails, stop and report.** Do NOT attempt workarounds. If `worktree:make`, `worktree reseed`, `worktree init`, `worktree:cleanup`, or any other `paperclipai` command fails:
- - Report the exact error message in your task comment
- - Set the task to `blocked`
- - Suggest running `npx paperclipai doctor --repair` or recreating the worktree from scratch
- - Do NOT try to manually replicate what the CLI does
-
-3. **Never share databases between instances.** Each worktree instance gets its own isolated database. Never override `DATABASE_URL` to point one instance at another's database. This destroys isolation and can corrupt production data.
-
-4. **Starting a dev server in a worktree requires setup first.** The correct sequence is:
- ```bash
- # If the worktree already exists but has no running instance:
- cd
- eval "$(npx paperclipai worktree env)"
- pnpm install && pnpm build
- npx paperclipai run # or pnpm dev
-
- # If the worktree needs a fresh database:
- npx paperclipai worktree reseed --seed-mode full
-
- # If the worktree is broken beyond repair:
- npx paperclipai worktree:cleanup
- npx paperclipai worktree:make --seed-mode full
- ```
- If any step fails, follow rule 2 — stop and report.
-
-5. **Seeding is a CLI operation.** When asked to seed a worktree database from the main instance, use `worktree reseed` or recreate with `worktree:make --seed-mode full`. Read `doc/DEVELOPING.md` for the full option tables. Never attempt manual database copying.
-
-## Persistent Dev Servers (for Manual Testing)
-
-When an agent needs to start a dev server that outlives the current heartbeat — for example, so a human or QA agent can manually test against it — the server process **must** be launched in a detached session. A process started directly from a heartbeat shell is killed when the heartbeat exits.
-
-### Use `tmux` for persistent servers
-
-```bash
-# 1. cd into the worktree (or main repo) and source the environment
-cd
-eval "$(npx paperclipai worktree env)" # skip if using the primary instance
-
-# 2. Start the dev server in a named, detached tmux session
-tmux new-session -d -s 'pnpm dev'
-
-# Example with a descriptive name:
-tmux new-session -d -s auth-fix-3102 'pnpm dev'
-```
-
-### Managing the session
-
-| Task | Command |
-|------|---------|
-| Check if the session is alive | `tmux has-session -t 2>/dev/null && echo running` |
-| View server output | `tmux capture-pane -t -p` |
-| Kill the session | `tmux kill-session -t ` |
-| List all tmux sessions | `tmux list-sessions` |
-
-### Verifying the server is reachable
-
-After launching, confirm the port is listening before reporting success:
-
-```bash
-# Wait briefly for startup, then verify
-sleep 3
-curl -sf http://127.0.0.1:/api/health && echo "Server is up"
-lsof -nP -iTCP: -sTCP:LISTEN
-```
-
-### Key rules
-
-1. **Always use `tmux` (or equivalent)** when a dev server needs to stay running after the heartbeat ends. A server started directly from the agent shell will die when the heartbeat exits, even if it appeared healthy moments before.
-2. **Name the session descriptively** — include the worktree name and port (e.g., `auth-fix-3102`).
-3. **Verify the server is listening** before reporting the URL to anyone.
-4. **Do not use `nohup` or `&` alone** — these are unreliable for agent shells that may have their entire process group killed.
-5. **Clean up when done** — kill the tmux session when the testing is complete.
-
-## Common Mistakes
-
-| Mistake | Fix |
-|---------|-----|
-| Server won't start | Run `npx paperclipai doctor --repair` to diagnose and auto-fix |
-| Forgetting to source worktree env | Run `eval "$(npx paperclipai worktree env)"` after cd-ing into the worktree |
-| Stale dependencies after pull | Run `pnpm install && pnpm build` after pulling |
-| Schema out of date after pull | Run `pnpm db:generate && pnpm db:migrate` |
-| Reseeding while target DB is running | Stop the target server first, or use `--allow-live-target` |
-| Cleaning up with unmerged commits | Merge or push first, or use `--force` if intentionally discarding |
-| Running agents against wrong instance | Verify `PAPERCLIP_API_URL` points to the correct port |
-| CLI command fails | Do NOT work around it — report the error and block (see Hard Rules above) |
-| Agent tries manual postgres operations | NEVER do this — all DB ops go through the CLI (see Hard Rules above) |
-| Dev server dies between heartbeats | Launch in a detached `tmux` session — see "Persistent Dev Servers" above |
-| Pushed feature branch to `paperclipai/paperclip` when a fork exists | Push to the user's fork remote instead — see "Forks" above |
diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts
index 7377b2fa..1ce25503 100644
--- a/ui/src/api/companySkills.ts
+++ b/ui/src/api/companySkills.ts
@@ -36,10 +36,15 @@ export const companySkillsApi = {
`/companies/${encodeURIComponent(companyId)}/skills`,
payload,
),
- importFromSource: (companyId: string, source: string) =>
+ importFromSource: (companyId: string, source: string, authToken?: string) =>
api.post(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
- { source },
+ { source, ...(authToken ? { authToken } : {}) },
+ ),
+ updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
+ api.patch(
+ `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
+ { authToken },
),
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
api.post(
diff --git a/ui/src/components/ApprovalPayload.test.tsx b/ui/src/components/ApprovalPayload.test.tsx
index c11405e9..1b082e06 100644
--- a/ui/src/components/ApprovalPayload.test.tsx
+++ b/ui/src/components/ApprovalPayload.test.tsx
@@ -1,13 +1,33 @@
// @vitest-environment jsdom
import { act } from "react";
+import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ThemeProvider } from "../context/ThemeContext";
import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload";
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to }: { children: ReactNode; to: string }) => {children},
+}));
+
+vi.mock("../api/issues", () => ({
+ issuesApi: { get: vi.fn() },
+}));
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+function withProviders(children: ReactNode) {
+ return (
+
+ {children}
+
+ );
+}
+
describe("approvalLabel", () => {
it("uses payload titles for generic board approvals", () => {
expect(
@@ -35,17 +55,19 @@ describe("ApprovalPayloadRenderer", () => {
act(() => {
root.render(
- ,
+ withProviders(
+ ,
+ ),
);
});
@@ -67,14 +89,16 @@ describe("ApprovalPayloadRenderer", () => {
act(() => {
root.render(
- ,
+ withProviders(
+ ,
+ ),
);
});
@@ -86,3 +110,90 @@ describe("ApprovalPayloadRenderer", () => {
});
});
});
+
+describe("BoardApprovalPayloadContent markdown rendering", () => {
+ it("renders a ## header in summary as an h2 element", () => {
+ const html = renderToStaticMarkup(
+ withProviders(
+ ,
+ ),
+ );
+ expect(html).toContain(" {
+ const html = renderToStaticMarkup(
+ withProviders(
+ ,
+ ),
+ );
+ expect(html).toContain(" {
+ const html = renderToStaticMarkup(
+ withProviders(
+ ,
+ ),
+ );
+ expect(html).toContain(" {
+ const html = renderToStaticMarkup(
+ withProviders(
+ ,
+ ),
+ );
+ expect(html).toContain(" {
+ const html = renderToStaticMarkup(
+ withProviders(
+ ,
+ ),
+ );
+ expect(html).toContain("This is a simple one-line summary.");
+ expect(html).not.toContain(" {
+ const html = renderToStaticMarkup(
+ withProviders(
+ ,
+ ),
+ );
+ expect(html).toContain("Approve the deployment.");
+ expect(html).not.toContain(" = {
hire_agent: "Hire Agent",
@@ -185,7 +186,7 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record
Summary
- {summary}
+ {summary}
)}
{recommendedAction && (
@@ -193,13 +194,13 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record
Recommended action
- {recommendedAction}
+ {recommendedAction}
)}
{nextActionOnApproval && (
On approval
-
{nextActionOnApproval}
+
{nextActionOnApproval}
)}
{risks.length > 0 && (
diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx
index 0b910e7c..129088f0 100644
--- a/ui/src/pages/CompanyExport.tsx
+++ b/ui/src/pages/CompanyExport.tsx
@@ -17,6 +17,15 @@ import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { projectsApi } from "../api/projects";
import { Button } from "@/components/ui/button";
+import { ToggleSwitch } from "@/components/ui/toggle-switch";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { MarkdownBody } from "../components/MarkdownBody";
@@ -603,6 +612,8 @@ export function CompanyExport() {
const [expandedDirs, setExpandedDirs] = useState>(new Set());
const [checkedFiles, setCheckedFiles] = useState>(new Set());
const [treeSearch, setTreeSearch] = useState("");
+ const [includeSecrets, setIncludeSecrets] = useState(false);
+ const [secretsConfirmOpen, setSecretsConfirmOpen] = useState(false);
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const savedExpandedRef = useRef | null>(null);
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
@@ -731,6 +742,7 @@ export function CompanyExport() {
include: { company: true, agents: true, projects: true, issues: true },
selectedFiles: Array.from(checkedFiles).sort(),
sidebarOrder,
+ includeSecrets,
}),
onSuccess: (result) => {
const resultCheckedFiles = new Set(Object.keys(result.files));
@@ -945,6 +957,11 @@ export function CompanyExport() {
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
)}
+ {includeSecrets && (
+
+ Secrets included
+
+ )}