Add project-level environment variables

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-04-06 09:34:15 -05:00
parent 97d4ce41b3
commit 8f23270f35
20 changed files with 13439 additions and 279 deletions
+5
View File
@@ -184,6 +184,11 @@ Invariant: at least one root `company` level goal per company.
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
- `lead_agent_id` uuid fk `agents.id` null
- `target_date` date null
- `env` jsonb null (same secret-aware env binding format used by agent config)
Invariant:
- project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected
## 7.6 `issues` (core task entity)
@@ -0,0 +1 @@
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "env" jsonb;
File diff suppressed because it is too large Load Diff
@@ -351,6 +351,13 @@
"when": 1775349863293,
"tag": "0049_flawless_abomination",
"breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1775487782768,
"tag": "0050_stiff_luckman",
"breakpoints": true
}
]
}
+2
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import type { AgentEnvConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
import { agents } from "./agents.js";
@@ -15,6 +16,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
env: jsonb("env").$type<AgentEnvConfig>(),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
@@ -1,3 +1,6 @@
import type { AgentEnvConfig } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityInclude {
company: boolean;
agents: boolean;
@@ -52,13 +55,12 @@ export interface CompanyPortabilityProjectManifestEntry {
targetDate: string | null;
color: string | null;
status: string | null;
env: AgentEnvConfig | null;
executionWorkspacePolicy: Record<string, unknown> | null;
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
metadata: Record<string, unknown> | null;
}
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
key: string;
name: string;
+2
View File
@@ -1,4 +1,5 @@
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { AgentEnvConfig } from "./secrets.js";
import type {
ProjectExecutionWorkspacePolicy,
ProjectWorkspaceRuntimeConfig,
@@ -65,6 +66,7 @@ export interface Project {
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
env: AgentEnvConfig | null;
pauseReason: PauseReason | null;
pausedAt: Date | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
@@ -1,5 +1,6 @@
import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js";
import { envConfigSchema } from "./secret.js";
const executionWorkspaceStrategySchema = z
.object({
@@ -102,6 +103,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
env: envConfigSchema.optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
};
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("overlays project env on top of agent env and unions secret keys", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: {
env: {
SHARED_KEY: "agent",
AGENT_ONLY: "agent-only",
},
other: "value",
},
secretKeys: new Set(["AGENT_SECRET"]),
});
const resolveEnvBindings = vi.fn().mockResolvedValue({
env: {
SHARED_KEY: "project",
PROJECT_ONLY: "project-only",
},
secretKeys: new Set(["PROJECT_SECRET"]),
});
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
executionRunConfig: { env: { SHARED_KEY: "agent" } },
projectEnv: { SHARED_KEY: "project" },
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
} as any,
});
expect(result.resolvedConfig).toMatchObject({
other: "value",
env: {
SHARED_KEY: "project",
AGENT_ONLY: "agent-only",
PROJECT_ONLY: "project-only",
},
});
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
});
it("skips project env resolution when the project has no bindings", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: { env: { AGENT_ONLY: "agent-only" } },
secretKeys: new Set<string>(),
});
const resolveEnvBindings = vi.fn();
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
executionRunConfig: { env: { AGENT_ONLY: "agent-only" } },
projectEnv: null,
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
} as any,
});
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
});
@@ -22,6 +22,9 @@ const mockGoalService = vi.hoisted(() => ({
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
@@ -46,6 +49,7 @@ vi.mock("../services/index.js", () => ({
goalService: () => mockGoalService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
@@ -77,6 +81,7 @@ describe("project and goal telemetry routes", () => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.create.mockResolvedValue({
id: "project-1",
companyId: "company-1",
@@ -0,0 +1,188 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockProjectService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
listWorkspaces: vi.fn(),
updateWorkspace: vi.fn(),
removeWorkspace: vi.fn(),
remove: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@paperclipai/shared/telemetry",
);
return {
...actual,
trackProjectCreated: mockTrackProjectCreated,
};
});
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
async function createApp() {
const { projectRoutes } = await import("../routes/projects.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "board-user",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", projectRoutes({} as any));
app.use(errorHandler);
return app;
}
function buildProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "backlog",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project",
effectiveLocalFolder: "/tmp/project",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe("project env routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.createWorkspace.mockResolvedValue(null);
mockProjectService.listWorkspaces.mockResolvedValue([]);
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
});
it("normalizes env bindings on create and logs only env keys", async () => {
const normalizedEnv = {
API_KEY: {
type: "secret_ref",
secretId: "11111111-1111-4111-8111-111111111111",
version: "latest",
},
};
mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv);
mockProjectService.create.mockResolvedValue(buildProject({ env: normalizedEnv }));
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({
name: "Project",
env: normalizedEnv,
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
"company-1",
normalizedEnv,
expect.objectContaining({ fieldPath: "env" }),
);
expect(mockProjectService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ env: normalizedEnv }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
details: expect.objectContaining({
envKeys: ["API_KEY"],
}),
}),
);
});
it("normalizes env bindings on update and avoids logging raw values", async () => {
const normalizedEnv = {
PLAIN_KEY: { type: "plain", value: "top-secret" },
};
mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv);
mockProjectService.getById.mockResolvedValue(buildProject());
mockProjectService.update.mockResolvedValue(buildProject({ env: normalizedEnv }));
const app = await createApp();
const res = await request(app)
.patch("/api/projects/project-1")
.send({
env: normalizedEnv,
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockProjectService.update).toHaveBeenCalledWith(
"project-1",
expect.objectContaining({ env: normalizedEnv }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
details: {
changedKeys: ["env"],
envKeys: ["PLAIN_KEY"],
},
}),
);
});
});
+24 -2
View File
@@ -9,7 +9,7 @@ import {
} from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
import { conflict } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
@@ -18,7 +18,9 @@ import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId;
@@ -82,6 +84,13 @@ export function projectRoutes(db: Db) {
};
const { workspace, ...projectData } = req.body as CreateProjectPayload;
if (projectData.env !== undefined) {
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
companyId,
projectData.env,
{ strictMode: strictSecretsMode, fieldPath: "env" },
);
}
const project = await svc.create(companyId, projectData);
let createdWorkspaceId: string | null = null;
if (workspace) {
@@ -107,6 +116,7 @@ export function projectRoutes(db: Db) {
details: {
name: project.name,
workspaceId: createdWorkspaceId,
envKeys: project.env ? Object.keys(project.env).sort() : [],
},
});
const telemetryClient = getTelemetryClient();
@@ -128,6 +138,12 @@ export function projectRoutes(db: Db) {
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
if (body.env !== undefined) {
body.env = await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, body.env, {
strictMode: strictSecretsMode,
fieldPath: "env",
});
}
const project = await svc.update(id, body);
if (!project) {
res.status(404).json({ error: "Project not found" });
@@ -143,7 +159,13 @@ export function projectRoutes(db: Db) {
action: "project.updated",
entityType: "project",
entityId: project.id,
details: req.body,
details: {
changedKeys: Object.keys(req.body).sort(),
envKeys:
body.env && typeof body.env === "object" && !Array.isArray(body.env)
? Object.keys(body.env as Record<string, unknown>).sort()
: undefined,
},
});
res.json(project);
@@ -27,6 +27,7 @@ import type {
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
CompanySkill,
AgentEnvConfig,
RoutineVariable,
} from "@paperclipai/shared";
import {
@@ -39,6 +40,7 @@ import {
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey,
envConfigSchema,
normalizeAgentUrlKey,
} from "@paperclipai/shared";
import {
@@ -387,6 +389,11 @@ function isSensitiveEnvKey(key: string) {
);
}
function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
const parsed = envConfigSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
type ResolvedSource = {
manifest: CompanyPortabilityManifest;
files: Record<string, CompanyPortabilityFileEntry>;
@@ -419,6 +426,7 @@ type ProjectLike = {
targetDate: string | null;
color: string | null;
status: string;
env: Record<string, unknown> | null;
executionWorkspacePolicy: Record<string, unknown> | null;
workspaces?: Array<{
id: string;
@@ -2531,6 +2539,7 @@ function buildManifestFromPackageFiles(
targetDate: asString(extension.targetDate),
color: asString(extension.color),
status: asString(extension.status),
env: normalizePortableProjectEnv(extension.env),
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
? extension.executionWorkspacePolicy
: null,
@@ -3159,6 +3168,7 @@ 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,
@@ -4095,6 +4105,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,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
};
+45 -10
View File
@@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local",
]);
type RuntimeConfigSecretResolver = Pick<
ReturnType<typeof secretService>,
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
>;
export async function resolveExecutionRunAdapterConfig(input: {
companyId: string;
executionRunConfig: Record<string, unknown>;
projectEnv: unknown;
secretsSvc: RuntimeConfigSecretResolver;
}) {
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
input.companyId,
input.executionRunConfig,
);
const projectEnvResolution = input.projectEnv
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
: { env: {}, secretKeys: new Set<string>() };
if (Object.keys(projectEnvResolution.env).length > 0) {
resolvedConfig.env = {
...parseObject(resolvedConfig.env),
...projectEnvResolution.env,
};
for (const key of projectEnvResolution.secretKeys) {
secretKeys.add(key);
}
}
return { resolvedConfig, secretKeys };
}
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
@@ -2309,17 +2339,20 @@ export function heartbeatService(db: Db) {
: null;
const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueContext?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId
const projectContext = executionProjectId
? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
env: projects.env,
})
.from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) =>
gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
))
.then((rows) => rows[0] ?? null)
: null;
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
);
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
@@ -2416,10 +2449,12 @@ export function heartbeatService(db: Db) {
: persistedWorkspaceManagedConfig;
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
companyId: agent.companyId,
executionRunConfig,
);
projectEnv: projectContext?.env ?? null,
secretsSvc,
});
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
+13 -2
View File
@@ -39,6 +39,11 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
}
export function secretService(db: Db) {
type NormalizeEnvOptions = {
strictMode?: boolean;
fieldPath?: string;
};
async function getById(id: string) {
return db
.select()
@@ -94,10 +99,10 @@ export function secretService(db: Db) {
async function normalizeEnvConfig(
companyId: string,
envValue: unknown,
opts?: { strictMode?: boolean },
opts?: NormalizeEnvOptions,
): Promise<AgentEnvConfig> {
const record = asRecord(envValue);
if (!record) throw unprocessable("adapterConfig.env must be an object");
if (!record) throw unprocessable(`${opts?.fieldPath ?? "env"} must be an object`);
const normalized: AgentEnvConfig = {};
for (const [key, rawBinding] of Object.entries(record)) {
@@ -292,6 +297,12 @@ export function secretService(db: Db) {
opts?: { strictMode?: boolean },
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
normalizeEnvBindingsForPersistence: async (
companyId: string,
envValue: unknown,
opts?: NormalizeEnvOptions,
) => normalizeEnvConfig(companyId, envValue, opts),
normalizeHireApprovalPayloadForPersistence: async (
companyId: string,
payload: Record<string, unknown>,
+1 -263
View File
@@ -44,6 +44,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import { EnvVarEditor } from "./EnvVarEditor";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
@@ -1082,269 +1083,6 @@ function AdapterTypeDropdown({
);
}
function EnvVarEditor({
value,
secrets,
onCreateSecret,
onChange,
}: {
value: Record<string, EnvBinding>;
secrets: CompanySecret[];
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
onChange: (env: Record<string, EnvBinding> | undefined) => void;
}) {
type Row = {
key: string;
source: "plain" | "secret";
plainValue: string;
secretId: string;
};
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
if (!rec || typeof rec !== "object") {
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
}
const entries = Object.entries(rec).map(([k, binding]) => {
if (typeof binding === "string") {
return {
key: k,
source: "plain" as const,
plainValue: binding,
secretId: "",
};
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "secret_ref"
) {
const recBinding = binding as { secretId?: unknown };
return {
key: k,
source: "secret" as const,
plainValue: "",
secretId: typeof recBinding.secretId === "string" ? recBinding.secretId : "",
};
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "plain"
) {
const recBinding = binding as { value?: unknown };
return {
key: k,
source: "plain" as const,
plainValue: typeof recBinding.value === "string" ? recBinding.value : "",
secretId: "",
};
}
return {
key: k,
source: "plain" as const,
plainValue: "",
secretId: "",
};
});
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
}
const [rows, setRows] = useState<Row[]>(() => toRows(value));
const [sealError, setSealError] = useState<string | null>(null);
const valueRef = useRef(value);
const emittingRef = useRef(false);
// Sync when value identity changes (overlay reset after save).
// Skip re-sync when the change was triggered by our own emit() to avoid
// reverting local row state (e.g. a secret-transition dropdown choice).
useEffect(() => {
if (emittingRef.current) {
emittingRef.current = false;
valueRef.current = value;
return;
}
if (value !== valueRef.current) {
valueRef.current = value;
setRows(toRows(value));
}
}, [value]);
function emit(nextRows: Row[]) {
const rec: Record<string, EnvBinding> = {};
for (const row of nextRows) {
const k = row.key.trim();
if (!k) continue;
if (row.source === "secret") {
if (row.secretId) {
rec[k] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
} else {
// Row is transitioning to secret but user hasn't picked one yet.
// Preserve the plain value so it isn't silently dropped.
rec[k] = { type: "plain", value: row.plainValue };
}
} else {
rec[k] = { type: "plain", value: row.plainValue };
}
}
emittingRef.current = true;
onChange(Object.keys(rec).length > 0 ? rec : undefined);
}
function updateRow(i: number, patch: Partial<Row>) {
const withPatch = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
if (
withPatch[withPatch.length - 1].key ||
withPatch[withPatch.length - 1].plainValue ||
withPatch[withPatch.length - 1].secretId
) {
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(withPatch);
emit(withPatch);
}
function removeRow(i: number) {
const next = rows.filter((_, idx) => idx !== i);
if (
next.length === 0 ||
next[next.length - 1].key ||
next[next.length - 1].plainValue ||
next[next.length - 1].secretId
) {
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(next);
emit(next);
}
function defaultSecretName(key: string): string {
return key
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 64);
}
async function sealRow(i: number) {
const row = rows[i];
if (!row) return;
const key = row.key.trim();
const plain = row.plainValue;
if (!key || plain.length === 0) return;
const suggested = defaultSecretName(key) || "secret";
const name = window.prompt("Secret name", suggested)?.trim();
if (!name) return;
try {
setSealError(null);
const created = await onCreateSecret(name, plain);
updateRow(i, {
source: "secret",
secretId: created.id,
});
} catch (err) {
setSealError(err instanceof Error ? err.message : "Failed to create secret");
}
}
return (
<div className="space-y-1.5">
{rows.map((row, i) => {
const isTrailing =
i === rows.length - 1 &&
!row.key &&
!row.plainValue &&
!row.secretId;
return (
<div key={i} className="flex items-center gap-1.5">
<input
className={cn(inputClass, "flex-[2]")}
placeholder="KEY"
value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })}
/>
<select
className={cn(inputClass, "flex-[1] bg-background")}
value={row.source}
onChange={(e) =>
updateRow(i, {
source: e.target.value === "secret" ? "secret" : "plain",
...(e.target.value === "plain" ? { secretId: "" } : {}),
})
}
>
<option value="plain">Plain</option>
<option value="secret">Secret</option>
</select>
{row.source === "secret" ? (
<>
<select
className={cn(inputClass, "flex-[3] bg-background")}
value={row.secretId}
onChange={(e) => updateRow(i, { secretId: e.target.value })}
>
<option value="">Select secret...</option>
{secrets.map((secret) => (
<option key={secret.id} value={secret.id}>
{secret.name}
</option>
))}
</select>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(i)}
disabled={!row.key.trim() || !row.plainValue}
title="Create secret from current plain value"
>
New
</button>
</>
) : (
<>
<input
className={cn(inputClass, "flex-[3]")}
placeholder="value"
value={row.plainValue}
onChange={(e) => updateRow(i, { plainValue: e.target.value })}
/>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(i)}
disabled={!row.key.trim() || !row.plainValue}
title="Store value as secret and replace with reference"
>
Seal
</button>
</>
)}
{!isTrailing ? (
<button
type="button"
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
onClick={() => removeRow(i)}
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<div className="w-[26px] shrink-0" />
)}
</div>
);
})}
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
<p className="text-[11px] text-muted-foreground/60">
PAPERCLIP_* variables are injected automatically at runtime.
</p>
</div>
);
}
function ModelDropdown({
models,
value,
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from "react";
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
import { X } from "lucide-react";
import { cn } from "../lib/utils";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
type Row = {
key: string;
source: "plain" | "secret";
plainValue: string;
secretId: string;
};
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
if (!rec || typeof rec !== "object") {
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
}
const entries = Object.entries(rec).map(([key, binding]) => {
if (typeof binding === "string") {
return { key, source: "plain" as const, plainValue: binding, secretId: "" };
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "secret_ref"
) {
const record = binding as { secretId?: unknown };
return {
key,
source: "secret" as const,
plainValue: "",
secretId: typeof record.secretId === "string" ? record.secretId : "",
};
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "plain"
) {
const record = binding as { value?: unknown };
return {
key,
source: "plain" as const,
plainValue: typeof record.value === "string" ? record.value : "",
secretId: "",
};
}
return { key, source: "plain" as const, plainValue: "", secretId: "" };
});
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
}
export function EnvVarEditor({
value,
secrets,
onCreateSecret,
onChange,
}: {
value: Record<string, EnvBinding>;
secrets: CompanySecret[];
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
onChange: (env: Record<string, EnvBinding> | undefined) => void;
}) {
const [rows, setRows] = useState<Row[]>(() => toRows(value));
const [sealError, setSealError] = useState<string | null>(null);
const valueRef = useRef(value);
const emittingRef = useRef(false);
useEffect(() => {
if (emittingRef.current) {
emittingRef.current = false;
valueRef.current = value;
return;
}
if (value !== valueRef.current) {
valueRef.current = value;
setRows(toRows(value));
}
}, [value]);
function emit(nextRows: Row[]) {
const rec: Record<string, EnvBinding> = {};
for (const row of nextRows) {
const key = row.key.trim();
if (!key) continue;
if (row.source === "secret") {
if (row.secretId) {
rec[key] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
} else {
rec[key] = { type: "plain", value: row.plainValue };
}
} else {
rec[key] = { type: "plain", value: row.plainValue };
}
}
emittingRef.current = true;
onChange(Object.keys(rec).length > 0 ? rec : undefined);
}
function updateRow(index: number, patch: Partial<Row>) {
const withPatch = rows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
if (
withPatch[withPatch.length - 1].key ||
withPatch[withPatch.length - 1].plainValue ||
withPatch[withPatch.length - 1].secretId
) {
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(withPatch);
emit(withPatch);
}
function removeRow(index: number) {
const next = rows.filter((_, rowIndex) => rowIndex !== index);
if (
next.length === 0 ||
next[next.length - 1].key ||
next[next.length - 1].plainValue ||
next[next.length - 1].secretId
) {
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(next);
emit(next);
}
function defaultSecretName(key: string) {
return key
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 64);
}
async function sealRow(index: number) {
const row = rows[index];
if (!row) return;
const key = row.key.trim();
const plain = row.plainValue;
if (!key || plain.length === 0) return;
const suggested = defaultSecretName(key) || "secret";
const name = window.prompt("Secret name", suggested)?.trim();
if (!name) return;
try {
setSealError(null);
const created = await onCreateSecret(name, plain);
updateRow(index, { source: "secret", secretId: created.id });
} catch (error) {
setSealError(error instanceof Error ? error.message : "Failed to create secret");
}
}
return (
<div className="space-y-1.5">
{rows.map((row, index) => {
const isTrailing =
index === rows.length - 1 &&
!row.key &&
!row.plainValue &&
!row.secretId;
return (
<div key={index} className="flex items-center gap-1.5">
<input
className={cn(inputClass, "flex-[2]")}
placeholder="KEY"
value={row.key}
onChange={(event) => updateRow(index, { key: event.target.value })}
/>
<select
className={cn(inputClass, "flex-[1] bg-background")}
value={row.source}
onChange={(event) =>
updateRow(index, {
source: event.target.value === "secret" ? "secret" : "plain",
...(event.target.value === "plain" ? { secretId: "" } : {}),
})
}
>
<option value="plain">Plain</option>
<option value="secret">Secret</option>
</select>
{row.source === "secret" ? (
<>
<select
className={cn(inputClass, "flex-[3] bg-background")}
value={row.secretId}
onChange={(event) => updateRow(index, { secretId: event.target.value })}
>
<option value="">Select secret...</option>
{secrets.map((secret) => (
<option key={secret.id} value={secret.id}>
{secret.name}
</option>
))}
</select>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(index)}
disabled={!row.key.trim() || !row.plainValue}
title="Create secret from current plain value"
>
New
</button>
</>
) : (
<>
<input
className={cn(inputClass, "flex-[3]")}
placeholder="value"
value={row.plainValue}
onChange={(event) => updateRow(index, { plainValue: event.target.value })}
/>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(index)}
disabled={!row.key.trim() || !row.plainValue}
title="Store value as secret and replace with reference"
>
Seal
</button>
</>
)}
{!isTrailing ? (
<button
type="button"
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
onClick={() => removeRow(index)}
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<div className="w-[26px] shrink-0" />
)}
</div>
);
})}
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
<p className="text-[11px] text-muted-foreground/60">
PAPERCLIP_* variables are injected automatically at runtime.
</p>
</div>
);
}
+38
View File
@@ -7,6 +7,7 @@ import { cn, formatDate } from "../lib/utils";
import { goalsApi } from "../api/goals";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
@@ -19,6 +20,7 @@ import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvVarEditor } from "./EnvVarEditor";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
@@ -43,6 +45,7 @@ export type ProjectConfigFieldKey =
| "description"
| "status"
| "goals"
| "env"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_base_ref"
@@ -245,6 +248,21 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const linkedGoalIds = project.goalIds.length > 0
? project.goalIds
@@ -583,6 +601,26 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</Popover>
)}
</PropertyRow>
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
alignStart
valueClassName="space-y-2"
>
<div className="space-y-2">
<EnvVarEditor
value={project.env ?? {}}
secrets={availableSecrets}
onCreateSecret={async (name, value) => {
const created = await createSecret.mutateAsync({ name, value });
return created;
}}
onChange={(env) => commitField("env", { env: env ?? null })}
/>
<p className="text-[11px] text-muted-foreground">
Applied to all runs for issues in this project. Project values override agent env on key conflicts.
</p>
</div>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
@@ -58,6 +58,7 @@ function createProject(): Project {
leadAgentId: null,
targetDate: null,
color: "#22c55e",
env: null,
pauseReason: null,
pausedAt: null,
archivedAt: null,
@@ -45,6 +45,7 @@ function makeProject(id: string, name: string): Project {
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,