mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add project-level environment variables
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
97d4ce41b3
commit
8f23270f35
20 changed files with 13439 additions and 279 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
1
packages/db/src/migrations/0050_stiff_luckman.sql
Normal file
1
packages/db/src/migrations/0050_stiff_luckman.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "env" jsonb;
|
||||||
12772
packages/db/src/migrations/meta/0050_snapshot.json
Normal file
12772
packages/db/src/migrations/meta/0050_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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>>(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
65
server/src/__tests__/heartbeat-project-env.test.ts
Normal file
65
server/src/__tests__/heartbeat-project-env.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
188
server/src/__tests__/project-routes-env.test.ts
Normal file
188
server/src/__tests__/project-routes-env.test.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
252
ui/src/components/EnvVarEditor.tsx
Normal file
252
ui/src/components/EnvVarEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue