forked from farhoodlabs/paperclip
fix(export): strip project env values from company packages
This commit is contained in:
@@ -13,6 +13,7 @@ export interface CompanyPortabilityEnvInput {
|
||||
key: string;
|
||||
description: string | null;
|
||||
agentSlug: string | null;
|
||||
projectSlug: string | null;
|
||||
kind: "secret" | "plain";
|
||||
requirement: "required" | "optional";
|
||||
defaultValue: string | null;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const portabilityEnvInputSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
agentSlug: z.string().min(1).nullable(),
|
||||
projectSlug: z.string().min(1).nullable(),
|
||||
kind: z.enum(["secret", "plain"]),
|
||||
requirement: z.enum(["required", "optional"]),
|
||||
defaultValue: z.string().nullable(),
|
||||
|
||||
@@ -1149,6 +1149,7 @@ describe("company portability", () => {
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
|
||||
agentSlug: "claudecoder",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
@@ -1158,6 +1159,7 @@ describe("company portability", () => {
|
||||
key: "GH_TOKEN",
|
||||
description: "Provide GH_TOKEN for agent claudecoder",
|
||||
agentSlug: "claudecoder",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
@@ -1166,6 +1168,128 @@ describe("company portability", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports project env as portable inputs without concrete values", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
description: "Ship it",
|
||||
leadAgentId: "agent-1",
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "plain",
|
||||
value: "sk-project-secret",
|
||||
},
|
||||
DOCS_MODE: {
|
||||
type: "plain",
|
||||
value: "strict",
|
||||
},
|
||||
GITHUB_TOKEN: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
},
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
metadata: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("OPENAI_API_KEY:");
|
||||
expect(extension).toContain("DOCS_MODE:");
|
||||
expect(extension).toContain("GITHUB_TOKEN:");
|
||||
expect(extension).not.toContain("sk-project-secret");
|
||||
expect(extension).not.toContain('type: "secret_ref"');
|
||||
expect(extension).not.toContain("11111111-1111-1111-1111-111111111111");
|
||||
expect(extension).toContain('default: "strict"');
|
||||
expect(extension).toContain('kind: "secret"');
|
||||
expect(extension).toContain('kind: "plain"');
|
||||
});
|
||||
|
||||
it("reads project env inputs back from .paperclip.yaml during preview import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
description: "Ship it",
|
||||
leadAgentId: "agent-1",
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "plain",
|
||||
value: "sk-project-secret",
|
||||
},
|
||||
},
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
metadata: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const preview = await portability.previewImport({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: exported.rootPath,
|
||||
files: exported.files,
|
||||
},
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "new_company",
|
||||
newCompanyName: "Imported Paperclip",
|
||||
},
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(preview.errors).toEqual([]);
|
||||
expect(preview.envInputs).toContainEqual({
|
||||
key: "OPENAI_API_KEY",
|
||||
description: "Optional default for OPENAI_API_KEY on project launch",
|
||||
agentSlug: null,
|
||||
projectSlug: "launch",
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
||||
@@ -394,6 +394,83 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function extractPortableScopedEnvInputs(
|
||||
scope: {
|
||||
label: string;
|
||||
warningPrefix: string;
|
||||
agentSlug: string | null;
|
||||
projectSlug: string | null;
|
||||
},
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
if (!isPlainRecord(envValue)) return [];
|
||||
const env = envValue as Record<string, unknown>;
|
||||
const inputs: CompanyPortabilityEnvInput[] = [];
|
||||
|
||||
for (const [key, binding] of Object.entries(env)) {
|
||||
if (key.toUpperCase() === "PATH") {
|
||||
warnings.push(`${scope.warningPrefix} PATH override was omitted from export because it is system-dependent.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Provide ${key} for ${scope.label}`,
|
||||
agentSlug: scope.agentSlug,
|
||||
projectSlug: scope.projectSlug,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "plain") {
|
||||
const defaultValue = asString(binding.value);
|
||||
const isSensitive = isSensitiveEnvKey(key);
|
||||
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}`,
|
||||
agentSlug: scope.agentSlug,
|
||||
projectSlug: scope.projectSlug,
|
||||
kind: isSensitive ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
||||
portability,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
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}`,
|
||||
agentSlug: scope.agentSlug,
|
||||
projectSlug: scope.projectSlug,
|
||||
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: isSensitiveEnvKey(key) ? "" : binding,
|
||||
portability,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
type ResolvedSource = {
|
||||
manifest: CompanyPortabilityManifest;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
@@ -1536,68 +1613,33 @@ function extractPortableEnvInputs(
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
if (!isPlainRecord(envValue)) return [];
|
||||
const env = envValue as Record<string, unknown>;
|
||||
const inputs: CompanyPortabilityEnvInput[] = [];
|
||||
return extractPortableScopedEnvInputs(
|
||||
{
|
||||
label: `agent ${agentSlug}`,
|
||||
warningPrefix: `Agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
projectSlug: null,
|
||||
},
|
||||
envValue,
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [key, binding] of Object.entries(env)) {
|
||||
if (key.toUpperCase() === "PATH") {
|
||||
warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Provide ${key} for agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "plain") {
|
||||
const defaultValue = asString(binding.value);
|
||||
const isSensitive = isSensitiveEnvKey(key);
|
||||
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
||||
? "system_dependent"
|
||||
: "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
kind: isSensitive ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
||||
portability,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof binding === "string") {
|
||||
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: binding,
|
||||
portability,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
function extractPortableProjectEnvInputs(
|
||||
projectSlug: string,
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
return extractPortableScopedEnvInputs(
|
||||
{
|
||||
label: `project ${projectSlug}`,
|
||||
warningPrefix: `Project ${projectSlug}`,
|
||||
agentSlug: null,
|
||||
projectSlug,
|
||||
},
|
||||
envValue,
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
|
||||
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
@@ -2183,7 +2225,7 @@ function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
|
||||
const seen = new Set<string>();
|
||||
const out: CompanyPortabilityManifest["envInputs"] = [];
|
||||
for (const value of values) {
|
||||
const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`;
|
||||
const key = `${value.agentSlug ?? ""}:${value.projectSlug ?? ""}:${value.key.toUpperCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
@@ -2240,6 +2282,31 @@ function readAgentEnvInputs(
|
||||
key,
|
||||
description: asString(record.description) ?? null,
|
||||
agentSlug,
|
||||
projectSlug: null,
|
||||
kind: record.kind === "plain" ? "plain" : "secret",
|
||||
requirement: record.requirement === "required" ? "required" : "optional",
|
||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function readProjectEnvInputs(
|
||||
extension: Record<string, unknown>,
|
||||
projectSlug: string,
|
||||
): CompanyPortabilityManifest["envInputs"] {
|
||||
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
|
||||
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
|
||||
if (!env) return [];
|
||||
|
||||
return Object.entries(env).flatMap(([key, value]) => {
|
||||
if (!isPlainRecord(value)) return [];
|
||||
const record = value as EnvInputRecord;
|
||||
return [{
|
||||
key,
|
||||
description: asString(record.description) ?? null,
|
||||
agentSlug: null,
|
||||
projectSlug,
|
||||
kind: record.kind === "plain" ? "plain" : "secret",
|
||||
requirement: record.requirement === "required" ? "required" : "optional",
|
||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||
@@ -2546,6 +2613,7 @@ function buildManifestFromPackageFiles(
|
||||
workspaces,
|
||||
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
||||
});
|
||||
manifest.envInputs.push(...readProjectEnvInputs(extension, slug));
|
||||
if (frontmatter.kind && frontmatter.kind !== "project") {
|
||||
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
|
||||
}
|
||||
@@ -3153,6 +3221,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
for (const project of selectedProjectRows) {
|
||||
const slug = projectSlugById.get(project.id)!;
|
||||
const projectPath = `projects/${slug}/PROJECT.md`;
|
||||
const envInputsStart = envInputs.length;
|
||||
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
|
||||
envInputs.push(...exportedEnvInputs);
|
||||
const projectEnvInputs = dedupeEnvInputs(
|
||||
envInputs
|
||||
.slice(envInputsStart)
|
||||
.filter((inputValue) => inputValue.projectSlug === slug),
|
||||
);
|
||||
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
|
||||
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
|
||||
files[projectPath] = buildMarkdown(
|
||||
@@ -3168,7 +3244,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
targetDate: project.targetDate ?? null,
|
||||
color: project.color ?? null,
|
||||
status: project.status,
|
||||
env: normalizePortableProjectEnv(project.env) ?? undefined,
|
||||
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
|
||||
slug,
|
||||
project.executionWorkspacePolicy,
|
||||
@@ -3177,6 +3252,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
) ?? undefined,
|
||||
workspaces: portableWorkspaces.extension,
|
||||
});
|
||||
if (isPlainRecord(extension) && projectEnvInputs.length > 0) {
|
||||
extension.inputs = {
|
||||
env: buildEnvInputMap(projectEnvInputs),
|
||||
};
|
||||
}
|
||||
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
||||
}
|
||||
|
||||
@@ -3516,7 +3596,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
|
||||
for (const envInput of manifest.envInputs) {
|
||||
if (envInput.portability === "system_dependent") {
|
||||
warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`);
|
||||
const scope = envInput.agentSlug
|
||||
? ` for agent ${envInput.agentSlug}`
|
||||
: envInput.projectSlug
|
||||
? ` for project ${envInput.projectSlug}`
|
||||
: "";
|
||||
warnings.push(`Environment input ${envInput.key}${scope} is system-dependent and may need manual adjustment after import.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user