mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:04:34 +01:00
Accept team catalog secret values
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user