forked from farhoodlabs/paperclip
Add project-level environment variables
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user