Accept team catalog secret values

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 03:00:04 +00:00
1 parent 3da1c6f3d7
commit 30dc2bbc60
12 files changed
+678 -38

No files matched your search

@@ -63,6 +63,7 @@ const assetSvc = {
};
const secretSvc = {
create: vi.fn(async () => ({ id: "secret-created" })),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: Record<string, unknown>) => env),
syncEnvBindingsForTarget: vi.fn(async () => []),
@@ -131,6 +132,7 @@ describe("company portability", () => {
beforeEach(() => {
vi.clearAllMocks();
secretSvc.create.mockResolvedValue({ id: "secret-created" });
secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
secretSvc.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
secretSvc.syncEnvBindingsForTarget.mockResolvedValue([]);
@@ -1392,6 +1394,113 @@ describe("company portability", () => {
]);
});
it("materializes required agent env inputs from import secretValues as company secrets", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.list.mockResolvedValue([]);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-imported",
name: input.name,
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
status: input.status,
}));
await portability.importBundle({
source: {
type: "inline",
files: {
"COMPANY.md": [
"---",
"name: Import",
"includes:",
" - agents/coder/AGENTS.md",
"---",
"",
].join("\n"),
"agents/coder/AGENTS.md": [
"---",
"name: Coder",
"slug: coder",
"kind: agent",
"---",
"",
"# Coder",
"",
].join("\n"),
".paperclip.yaml": [
"schema: paperclip/v1",
"agents:",
" coder:",
" adapter:",
" type: codex_local",
" config: {}",
" inputs:",
" env:",
" OPENAI_API_KEY:",
" kind: secret",
" requirement: required",
"",
].join("\n"),
},
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
collisionStrategy: "rename",
secretValues: {
"agent:coder:OPENAI_API_KEY": "sk-imported",
},
}, "user-1");
expect(secretSvc.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "local_encrypted",
value: "sk-imported",
description: expect.stringContaining("OPENAI_API_KEY"),
}),
{ userId: "user-1", agentId: null },
);
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
env: {
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-created",
version: "latest",
},
},
}),
{ strictMode: false },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterConfig: expect.objectContaining({
env: {
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-created",
version: "latest",
},
},
}),
}));
expect(secretSvc.syncEnvBindingsForTarget).toHaveBeenCalledWith(
"company-1",
{ targetType: "agent", targetId: "agent-imported" },
expect.objectContaining({
OPENAI_API_KEY: expect.objectContaining({ secretId: "secret-created" }),
}),
);
});
it("exports project env as portable inputs without concrete values", async () => {
const portability = companyPortabilityService({} as any);
@@ -204,7 +204,10 @@ describe("teams catalog routes", () => {
const res = await request(app)
.post(`/api/companies/${companyId}/teams/catalog/product-engineering/install`)
.send({ collisionStrategy: "rename" });
.send({
collisionStrategy: "rename",
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockTeamsCatalogService.installCatalogTeam).toHaveBeenCalledWith(
@@ -212,6 +215,7 @@ describe("teams catalog routes", () => {
"product-engineering",
expect.objectContaining({
collisionStrategy: "rename",
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
actor: expect.objectContaining({
actorType: "agent",
actorId: "agent-1",
@@ -112,6 +112,22 @@ describe("teamsCatalogService", () => {
);
});
it("passes install secretValues through to company portability import", async () => {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "core-exec-team", {
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
});
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(
expect.objectContaining({
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
}),
null,
{ mode: "agent_safe", sourceCompanyId: "company-1" },
);
});
it("classifies unresolved and unsafe external skill requirements as blocked", () => {
const fakeTeam: CatalogTeam = {
id: "paperclipai:optional:test:unsafe",
+147 -1
View File
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { execFile } from "node:child_process";
import path from "node:path";
@@ -70,6 +70,7 @@ import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
import { routineService } from "./routines.js";
import { secretService } from "./secrets.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import {
PORTABLE_CATALOG_PROVENANCE_STRING_KEYS,
readCatalogStringList,
@@ -2412,6 +2413,63 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
return env;
}
function envInputScopedKey(input: CompanyPortabilityEnvInput) {
if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`;
if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`;
return input.key;
}
function envInputValue(input: CompanyPortabilityEnvInput, values: Record<string, string> | null | undefined) {
if (!values) return null;
const scopedKey = envInputScopedKey(input);
if (Object.prototype.hasOwnProperty.call(values, scopedKey)) return values[scopedKey];
if (Object.prototype.hasOwnProperty.call(values, input.key)) return values[input.key];
return null;
}
function importSecretLabel(input: CompanyPortabilityEnvInput) {
const scope = input.agentSlug
? `agent ${input.agentSlug}`
: input.projectSlug
? `project ${input.projectSlug}`
: "company import";
return `${scope} ${input.key}`;
}
function importSecretKey(input: CompanyPortabilityEnvInput, suffix: string) {
const scope = input.agentSlug
? `agent-${input.agentSlug}`
: input.projectSlug
? `project-${input.projectSlug}`
: "company";
return `import-${scope}-${input.key}-${suffix}`;
}
function writeManifestEnvBinding(
manifest: CompanyPortabilityManifest,
input: CompanyPortabilityEnvInput,
binding: AgentEnvConfig[string],
) {
if (input.agentSlug) {
const agent = manifest.agents.find((entry) => entry.slug === input.agentSlug);
if (!agent) return;
const adapterConfig = isPlainRecord(agent.adapterConfig) ? agent.adapterConfig : {};
const env = isPlainRecord(adapterConfig.env) ? { ...adapterConfig.env } : {};
env[input.key] = binding;
agent.adapterConfig = { ...adapterConfig, env };
return;
}
if (input.projectSlug) {
const project = manifest.projects.find((entry) => entry.slug === input.projectSlug);
if (!project) return;
project.env = {
...(project.env ?? {}),
[input.key]: binding,
};
}
}
function readCompanyApprovalDefault(_frontmatter: Record<string, unknown>) {
return false;
}
@@ -2925,6 +2983,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const companySkills = companySkillService(db);
const secrets = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
const defaultSecretProvider = getConfiguredSecretProvider();
function assertKnownImportAdapterType(type: string | null | undefined): string {
const adapterType = typeof type === "string" ? type.trim() : "";
@@ -2983,6 +3042,56 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
};
}
async function materializeImportEnvInputValues(
companyId: string,
manifest: CompanyPortabilityManifest,
envInputs: CompanyPortabilityEnvInput[],
secretValues: Record<string, string> | null | undefined,
actorUserId: string | null | undefined,
) {
if (envInputs.length === 0) return;
const missingRequired = envInputs.filter((input) => {
if (input.requirement !== "required") return false;
const value = envInputValue(input, secretValues);
return value === null || value.trim().length === 0;
});
if (missingRequired.length > 0) {
throw unprocessable(`Required environment values are missing: ${missingRequired.map(envInputScopedKey).join(", ")}`);
}
for (const input of envInputs) {
const value = envInputValue(input, secretValues);
if (value === null || value.trim().length === 0) continue;
if (input.kind === "plain") {
writeManifestEnvBinding(manifest, input, {
type: "plain",
value,
});
continue;
}
const suffix = randomUUID().slice(0, 8);
const label = importSecretLabel(input);
const secret = await secrets.create(
companyId,
{
name: `Imported ${label} ${suffix}`,
key: importSecretKey(input, suffix),
provider: defaultSecretProvider,
value,
description: input.description ?? `Imported ${input.key} for ${label}.`,
},
{ userId: actorUserId ?? null, agentId: null },
);
writeManifestEnvBinding(manifest, input, {
type: "secret_ref",
secretId: secret.id,
version: "latest",
});
}
}
function resolveImportedAssigneeAgentId(
assigneeSlug: string | null | undefined,
importedSlugToAgentId: Map<string, string>,
@@ -4235,6 +4344,33 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (!targetCompany) throw notFound("Target company not found");
const importedAgentEnvSlugs = new Set(
plan.preview.plan.agentPlans
.filter((entry) => entry.action !== "skip")
.map((entry) => entry.slug),
);
const importedProjectEnvSlugs = new Set(
plan.preview.plan.projectPlans
.filter((entry) => entry.action !== "skip")
.map((entry) => entry.slug),
);
const importEnvInputs = (sourceManifest.envInputs ?? []).filter((inputValue) => {
if (inputValue.agentSlug) {
return include.agents && importedAgentEnvSlugs.has(inputValue.agentSlug);
}
if (inputValue.projectSlug) {
return include.projects && importedProjectEnvSlugs.has(inputValue.projectSlug);
}
return true;
});
await materializeImportEnvInputValues(
targetCompany.id,
sourceManifest,
importEnvInputs,
input.secretValues,
actorUserId,
);
if (include.company) {
const logoPath = sourceManifest.company?.logoPath ?? null;
if (!logoPath) {
@@ -4412,6 +4548,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
await secrets.syncEnvBindingsForTarget?.(
targetCompany.id,
{ targetType: "agent", targetId: updated.id },
isPlainRecord(updated.adapterConfig) ? updated.adapterConfig.env : undefined,
);
importedSlugToAgentId.set(planAgent.slug, updated.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
resultAgents.push({
@@ -4448,6 +4589,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? createdStatus);
await secrets.syncEnvBindingsForTarget?.(
targetCompany.id,
{ targetType: "agent", targetId: created.id },
isPlainRecord(created.adapterConfig) ? created.adapterConfig.env : undefined,
);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
+2
View File
@@ -61,6 +61,7 @@ export interface CatalogTeamImportOptions {
nameOverrides?: Record<string, string>;
selectedFiles?: string[];
adapterOverrides?: CompanyPortabilityImport["adapterOverrides"];
secretValues?: CompanyPortabilityImport["secretValues"];
sourcePolicy?: CatalogTeamSourcePolicy;
actor?: CatalogTeamActorContext | null;
}
@@ -780,6 +781,7 @@ export function teamsCatalogService(db: Db) {
const importInput: CompanyPortabilityImport = {
...buildPortabilityInput(companyId, prepared.source, options),
adapterOverrides: options.adapterOverrides,
secretValues: options.secretValues,
};
const result = await portability.importBundle(
importInput,