From e8579d5c66b3af82ff06bc5cb5598798e8cca54e Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 1 May 2026 08:18:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(import-export):=20complete=20company=20por?= =?UTF-8?q?tability=20=E2=80=94=20secrets=20export/import=20and=20env=20ro?= =?UTF-8?q?und-tripping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in secret export/import: secret values are resolved (and optionally decrypted) into the portability manifest, and re-created with conflict handling on import. Fixes env round-tripping so both secret_ref and plain bindings survive export/import cycles. --- packages/shared/src/index.ts | 1 + .../shared/src/types/company-portability.ts | 16 +- packages/shared/src/types/index.ts | 1 + .../src/validators/company-portability.ts | 11 + .../src/__tests__/company-portability.test.ts | 217 +++++++++++++- server/src/services/company-portability.ts | 275 ++++++++++++++++-- ui/src/pages/CompanyExport.tsx | 60 ++++ ui/src/pages/CompanyImport.tsx | 19 ++ 8 files changed, 579 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 72d8ca73..9a2420b0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -473,6 +473,7 @@ export type { CompanyPortabilityImportRequest, CompanyPortabilityImportResult, CompanyPortabilityExportRequest, + CompanyPortabilitySecretEntry, EnvBinding, AgentEnvConfig, CompanySecret, 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 a574f854..7328c561 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -304,6 +304,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/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 01794385..267d88c0 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/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/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 + + )} + + + + ); } diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 90fad9d4..3df8164b 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -866,6 +866,13 @@ export function CompanyImport() { title: "Import complete", body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`, }); + if (result.warnings.some((w) => w.includes("could not be decrypted") || w.toLowerCase().includes("failed to create secret"))) { + pushToast({ + tone: "warn", + title: "Secrets import warning", + body: "Some secrets could not be decrypted. Review warnings and recreate manually.", + }); + } // Force a fresh dashboard load so newly imported agents are immediately visible. window.location.assign(`/${importedCompany.issuePrefix}/dashboard`); }, @@ -1309,6 +1316,18 @@ export function CompanyImport() { )} + {/* Secrets info */} + {importPreview.manifest.secrets && importPreview.manifest.secrets.length > 0 && ( +
+
Secrets to import
+ {importPreview.manifest.secrets.map((s) => ( +
+ {s.name}{s.provider !== "local_encrypted" ? ` (${s.provider})` : ""} +
+ ))} +
+ )} + {/* Errors */} {importPreview.errors.length > 0 && (