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

View file

@ -184,6 +184,11 @@ Invariant: at least one root `company` level goal per company.
- `status` enum: `backlog | planned | in_progress | completed | cancelled` - `status` enum: `backlog | planned | in_progress | completed | cancelled`
- `lead_agent_id` uuid fk `agents.id` null - `lead_agent_id` uuid fk `agents.id` null
- `target_date` date 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) ## 7.6 `issues` (core task entity)

View file

@ -0,0 +1 @@
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "env" jsonb;

File diff suppressed because it is too large Load diff

View file

@ -351,6 +351,13 @@
"when": 1775349863293, "when": 1775349863293,
"tag": "0049_flawless_abomination", "tag": "0049_flawless_abomination",
"breakpoints": true "breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1775487782768,
"tag": "0050_stiff_luckman",
"breakpoints": true
} }
] ]
} }

View file

@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core"; 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 { companies } from "./companies.js";
import { goals } from "./goals.js"; import { goals } from "./goals.js";
import { agents } from "./agents.js"; import { agents } from "./agents.js";
@ -15,6 +16,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id), leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"), targetDate: date("target_date"),
color: text("color"), color: text("color"),
env: jsonb("env").$type<AgentEnvConfig>(),
pauseReason: text("pause_reason"), pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }), pausedAt: timestamp("paused_at", { withTimezone: true }),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(), executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),

View file

@ -1,3 +1,6 @@
import type { AgentEnvConfig } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityInclude { export interface CompanyPortabilityInclude {
company: boolean; company: boolean;
agents: boolean; agents: boolean;
@ -52,13 +55,12 @@ export interface CompanyPortabilityProjectManifestEntry {
targetDate: string | null; targetDate: string | null;
color: string | null; color: string | null;
status: string | null; status: string | null;
env: AgentEnvConfig | null;
executionWorkspacePolicy: Record<string, unknown> | null; executionWorkspacePolicy: Record<string, unknown> | null;
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[]; workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
} }
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityProjectWorkspaceManifestEntry { export interface CompanyPortabilityProjectWorkspaceManifestEntry {
key: string; key: string;
name: string; name: string;

View file

@ -1,4 +1,5 @@
import type { PauseReason, ProjectStatus } from "../constants.js"; import type { PauseReason, ProjectStatus } from "../constants.js";
import type { AgentEnvConfig } from "./secrets.js";
import type { import type {
ProjectExecutionWorkspacePolicy, ProjectExecutionWorkspacePolicy,
ProjectWorkspaceRuntimeConfig, ProjectWorkspaceRuntimeConfig,
@ -65,6 +66,7 @@ export interface Project {
leadAgentId: string | null; leadAgentId: string | null;
targetDate: string | null; targetDate: string | null;
color: string | null; color: string | null;
env: AgentEnvConfig | null;
pauseReason: PauseReason | null; pauseReason: PauseReason | null;
pausedAt: Date | null; pausedAt: Date | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null; executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;

View file

@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js"; import { PROJECT_STATUSES } from "../constants.js";
import { envConfigSchema } from "./secret.js";
const executionWorkspaceStrategySchema = z const executionWorkspaceStrategySchema = z
.object({ .object({
@ -102,6 +103,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(), leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(), targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(), color: z.string().optional().nullable(),
env: envConfigSchema.optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(), executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(), archivedAt: z.string().datetime().optional().nullable(),
}; };

View file

@ -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();
});
});

View file

@ -22,6 +22,9 @@ const mockGoalService = vi.hoisted(() => ({
})); }));
const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn()); const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn()); const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockTrackGoalCreated = vi.hoisted(() => vi.fn()); const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
@ -46,6 +49,7 @@ vi.mock("../services/index.js", () => ({
goalService: () => mockGoalService, goalService: () => mockGoalService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => mockProjectService, projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService, workspaceOperationService: () => mockWorkspaceOperationService,
})); }));
@ -77,6 +81,7 @@ describe("project and goal telemetry routes", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.create.mockResolvedValue({ mockProjectService.create.mockResolvedValue({
id: "project-1", id: "project-1",
companyId: "company-1", companyId: "company-1",

View file

@ -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"],
},
}),
);
});
});

View file

@ -9,7 +9,7 @@ import {
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js"; 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 { conflict } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
@ -18,7 +18,9 @@ import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) { export function projectRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = projectService(db); const svc = projectService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db); const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function resolveCompanyIdForProjectReference(req: Request) { async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId; const companyIdQuery = req.query.companyId;
@ -82,6 +84,13 @@ export function projectRoutes(db: Db) {
}; };
const { workspace, ...projectData } = req.body as CreateProjectPayload; 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); const project = await svc.create(companyId, projectData);
let createdWorkspaceId: string | null = null; let createdWorkspaceId: string | null = null;
if (workspace) { if (workspace) {
@ -107,6 +116,7 @@ export function projectRoutes(db: Db) {
details: { details: {
name: project.name, name: project.name,
workspaceId: createdWorkspaceId, workspaceId: createdWorkspaceId,
envKeys: project.env ? Object.keys(project.env).sort() : [],
}, },
}); });
const telemetryClient = getTelemetryClient(); const telemetryClient = getTelemetryClient();
@ -128,6 +138,12 @@ export function projectRoutes(db: Db) {
if (typeof body.archivedAt === "string") { if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt); 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); const project = await svc.update(id, body);
if (!project) { if (!project) {
res.status(404).json({ error: "Project not found" }); res.status(404).json({ error: "Project not found" });
@ -143,7 +159,13 @@ export function projectRoutes(db: Db) {
action: "project.updated", action: "project.updated",
entityType: "project", entityType: "project",
entityId: project.id, 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); res.json(project);

View file

@ -27,6 +27,7 @@ import type {
CompanyPortabilitySidebarOrder, CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry, CompanyPortabilitySkillManifestEntry,
CompanySkill, CompanySkill,
AgentEnvConfig,
RoutineVariable, RoutineVariable,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { import {
@ -39,6 +40,7 @@ import {
ROUTINE_TRIGGER_KINDS, ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES, ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey, deriveProjectUrlKey,
envConfigSchema,
normalizeAgentUrlKey, normalizeAgentUrlKey,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { 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 = { type ResolvedSource = {
manifest: CompanyPortabilityManifest; manifest: CompanyPortabilityManifest;
files: Record<string, CompanyPortabilityFileEntry>; files: Record<string, CompanyPortabilityFileEntry>;
@ -419,6 +426,7 @@ type ProjectLike = {
targetDate: string | null; targetDate: string | null;
color: string | null; color: string | null;
status: string; status: string;
env: Record<string, unknown> | null;
executionWorkspacePolicy: Record<string, unknown> | null; executionWorkspacePolicy: Record<string, unknown> | null;
workspaces?: Array<{ workspaces?: Array<{
id: string; id: string;
@ -2531,6 +2539,7 @@ function buildManifestFromPackageFiles(
targetDate: asString(extension.targetDate), targetDate: asString(extension.targetDate),
color: asString(extension.color), color: asString(extension.color),
status: asString(extension.status), status: asString(extension.status),
env: normalizePortableProjectEnv(extension.env),
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy) executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
? extension.executionWorkspacePolicy ? extension.executionWorkspacePolicy
: null, : null,
@ -3159,6 +3168,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
targetDate: project.targetDate ?? null, targetDate: project.targetDate ?? null,
color: project.color ?? null, color: project.color ?? null,
status: project.status, status: project.status,
env: normalizePortableProjectEnv(project.env) ?? undefined,
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy( executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
slug, slug,
project.executionWorkspacePolicy, project.executionWorkspacePolicy,
@ -4095,6 +4105,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any) status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number] ? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog", : "backlog",
env: manifestProject.env,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy), executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
}; };

View file

@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local", "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: { export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>; config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null; workspaceConfig: ExecutionWorkspaceConfig | null;
@ -2309,17 +2339,20 @@ export function heartbeatService(db: Db) {
: null; : null;
const contextProjectId = readNonEmptyString(context.projectId); const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueContext?.projectId ?? contextProjectId; const executionProjectId = issueContext?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId const projectContext = executionProjectId
? await db ? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
env: projects.env,
})
.from(projects) .from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId))) .where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) => .then((rows) => rows[0] ?? null)
gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
))
: null; : null;
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
);
const taskSession = taskKey const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey) ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null; : null;
@ -2416,10 +2449,12 @@ export function heartbeatService(db: Db) {
: persistedWorkspaceManagedConfig; : persistedWorkspaceManagedConfig;
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
agent.companyId, companyId: agent.companyId,
executionRunConfig, executionRunConfig,
); projectEnv: projectContext?.env ?? null,
secretsSvc,
});
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = { const runtimeConfig = {
...resolvedConfig, ...resolvedConfig,

View file

@ -39,6 +39,11 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
} }
export function secretService(db: Db) { export function secretService(db: Db) {
type NormalizeEnvOptions = {
strictMode?: boolean;
fieldPath?: string;
};
async function getById(id: string) { async function getById(id: string) {
return db return db
.select() .select()
@ -94,10 +99,10 @@ export function secretService(db: Db) {
async function normalizeEnvConfig( async function normalizeEnvConfig(
companyId: string, companyId: string,
envValue: unknown, envValue: unknown,
opts?: { strictMode?: boolean }, opts?: NormalizeEnvOptions,
): Promise<AgentEnvConfig> { ): Promise<AgentEnvConfig> {
const record = asRecord(envValue); 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 = {}; const normalized: AgentEnvConfig = {};
for (const [key, rawBinding] of Object.entries(record)) { for (const [key, rawBinding] of Object.entries(record)) {
@ -292,6 +297,12 @@ export function secretService(db: Db) {
opts?: { strictMode?: boolean }, opts?: { strictMode?: boolean },
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts), ) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
normalizeEnvBindingsForPersistence: async (
companyId: string,
envValue: unknown,
opts?: NormalizeEnvOptions,
) => normalizeEnvConfig(companyId, envValue, opts),
normalizeHireApprovalPayloadForPersistence: async ( normalizeHireApprovalPayloadForPersistence: async (
companyId: string, companyId: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,

View file

@ -44,6 +44,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal"; import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker"; import { ReportsToPicker } from "./ReportsToPicker";
import { EnvVarEditor } from "./EnvVarEditor";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
import { getAdapterLabel } from "../adapters/adapter-display-registry"; 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({ function ModelDropdown({
models, models,
value, value,

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>
);
}

View file

@ -7,6 +7,7 @@ import { cn, formatDate } from "../lib/utils";
import { goalsApi } from "../api/goals"; import { goalsApi } from "../api/goals";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { statusBadge, statusBadgeDefault } from "../lib/status-colors"; import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
@ -19,6 +20,7 @@ import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives"; import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor"; import { InlineEditor } from "./InlineEditor";
import { EnvVarEditor } from "./EnvVarEditor";
const PROJECT_STATUSES = [ const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" }, { value: "backlog", label: "Backlog" },
@ -43,6 +45,7 @@ export type ProjectConfigFieldKey =
| "description" | "description"
| "status" | "status"
| "goals" | "goals"
| "env"
| "execution_workspace_enabled" | "execution_workspace_enabled"
| "execution_workspace_default_mode" | "execution_workspace_default_mode"
| "execution_workspace_base_ref" | "execution_workspace_base_ref"
@ -245,6 +248,21 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
queryFn: () => instanceSettingsApi.getExperimental(), queryFn: () => instanceSettingsApi.getExperimental(),
retry: false, 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 const linkedGoalIds = project.goalIds.length > 0
? project.goalIds ? project.goalIds
@ -583,6 +601,26 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</Popover> </Popover>
)} )}
</PropertyRow> </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" />}> <PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span> <span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow> </PropertyRow>

View file

@ -58,6 +58,7 @@ function createProject(): Project {
leadAgentId: null, leadAgentId: null,
targetDate: null, targetDate: null,
color: "#22c55e", color: "#22c55e",
env: null,
pauseReason: null, pauseReason: null,
pausedAt: null, pausedAt: null,
archivedAt: null, archivedAt: null,

View file

@ -45,6 +45,7 @@ function makeProject(id: string, name: string): Project {
leadAgentId: null, leadAgentId: null,
targetDate: null, targetDate: null,
color: null, color: null,
env: null,
pauseReason: null, pauseReason: null,
pausedAt: null, pausedAt: null,
executionWorkspacePolicy: null, executionWorkspacePolicy: null,