mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add SSH environment support (#4358)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The environments subsystem already models execution environments, but before this branch there was no end-to-end SSH-backed runtime path for agents to actually run work against a remote box > - That meant agents could be configured around environment concepts without a reliable way to execute adapter sessions remotely, sync workspace state, and preserve run context across supported adapters > - We also need environment selection to participate in normal Paperclip control-plane behavior: agent defaults, project/issue selection, route validation, and environment probing > - Because this capability is still experimental, the UI surface should be easy to hide and easy to remove later without undoing the underlying implementation > - This pull request adds SSH environment execution support across the runtime, adapters, routes, schema, and tests, then puts the visible environment-management UI behind an experimental flag > - The benefit is that we can validate real SSH-backed agent execution now while keeping the user-facing controls safely gated until the feature is ready to come out of experimentation ## What Changed - Added SSH-backed execution target support in the shared adapter runtime, including remote workspace preparation, skill/runtime asset sync, remote session handling, and workspace restore behavior after runs. - Added SSH execution coverage for supported local adapters, plus remote execution tests across Claude, Codex, Cursor, Gemini, OpenCode, and Pi. - Added environment selection and environment-management backend support needed for SSH execution, including route/service work, validation, probing, and agent default environment persistence. - Added CLI support for SSH environment lab verification and updated related docs/tests. - Added the `enableEnvironments` experimental flag and gated the environment UI behind it on company settings, agent configuration, and project configuration surfaces. ## Verification - `pnpm exec vitest run packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/cursor-local/src/server/execute.remote.test.ts packages/adapters/gemini-local/src/server/execute.remote.test.ts packages/adapters/opencode-local/src/server/execute.remote.test.ts packages/adapters/pi-local/src/server/execute.remote.test.ts` - `pnpm exec vitest run server/src/__tests__/environment-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/instance-settings-routes.test.ts` - `pnpm exec vitest run ui/src/lib/new-agent-hire-payload.test.ts ui/src/lib/new-agent-runtime-config.test.ts` - `pnpm -r typecheck` - `pnpm build` - Manual verification on a branch-local dev server: - enabled the experimental flag - created an SSH environment - created a Linux Claude agent using that environment - confirmed a run executed on the Linux box and synced workspace changes back ## Risks - Medium: this touches runtime execution flow across multiple adapters, so regressions would likely show up in remote session setup, workspace sync, or environment selection precedence. - The UI flag reduces exposure, but the underlying runtime and route changes are still substantial and rely on migration correctness. - The change set is broad across adapters, control-plane services, migrations, and UI gating, so review should pay close attention to environment-selection precedence and remote workspace lifecycle behavior. ## Model Used - OpenAI Codex via Paperclip's local Codex adapter, GPT-5-class coding model with tool use and code execution in the local repo workspace. The local adapter does not surface a more specific public model version string in this branch workflow. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
f98c348e2b
commit
e4995bbb1c
95 changed files with 10162 additions and 315 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockActivityService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
|
|
@ -32,6 +33,8 @@ vi.mock("../services/index.js", () => ({
|
|||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
async function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
|
|
@ -53,10 +56,22 @@ async function createApp(
|
|||
});
|
||||
app.use("/api", activityRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
server = app.listen(0);
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("activity routes", () => {
|
||||
afterAll(async () => {
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server?.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
server = null;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ const mockSecretService = vi.hoisted(() => ({
|
|||
resolveAdapterConfigForRuntime: vi.fn(),
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
}));
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -40,6 +43,7 @@ vi.mock("../services/index.js", () => ({
|
|||
approvalService: () => ({}),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
environmentService: () => mockEnvironmentService,
|
||||
heartbeatService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => ({}),
|
||||
|
|
@ -112,6 +116,7 @@ function makeAgent() {
|
|||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: null,
|
||||
permissions: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
|
|
@ -19,6 +20,7 @@ const baseAgent = {
|
|||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: null,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
|
|
@ -59,6 +61,10 @@ const mockBudgetService = vi.hoisted(() => ({
|
|||
upsertPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
listTaskSessions: vi.fn(),
|
||||
resetRuntimeSession: vi.fn(),
|
||||
|
|
@ -91,6 +97,7 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
|||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
|
|
@ -101,6 +108,13 @@ function registerModuleMocks() {
|
|||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.js"));
|
||||
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
|
||||
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
|
||||
return {
|
||||
...actual,
|
||||
ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
|
|
@ -179,6 +193,7 @@ function registerModuleMocks() {
|
|||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +215,22 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } =
|
|||
};
|
||||
}
|
||||
|
||||
let sharedServer: Server | null = null;
|
||||
|
||||
async function closeSharedServer() {
|
||||
if (!sharedServer) return;
|
||||
const server = sharedServer;
|
||||
sharedServer = null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) {
|
||||
await closeSharedServer();
|
||||
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
|
||||
import("../middleware/index.js"),
|
||||
import("../routes/agents.js"),
|
||||
|
|
@ -213,10 +243,16 @@ async function createApp(actor: Record<string, unknown>, dbOptions: { requireBoa
|
|||
});
|
||||
app.use("/api", agentRoutes(createDbStub(dbOptions) as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
sharedServer = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => {
|
||||
sharedServer?.once("listening", resolve);
|
||||
});
|
||||
return sharedServer;
|
||||
}
|
||||
|
||||
describe("agent permission routes", () => {
|
||||
describe.sequential("agent permission routes", () => {
|
||||
afterEach(closeSharedServer);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
|
|
@ -239,6 +275,7 @@ describe("agent permission routes", () => {
|
|||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.doUnmock("@paperclipai/adapter-opencode-local/server");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockAgentService.getById.mockReset();
|
||||
|
|
@ -274,6 +311,7 @@ describe("agent permission routes", () => {
|
|||
mockGetTelemetryClient.mockReset();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
|
||||
mockInstanceSettingsService.getGeneral.mockReset();
|
||||
mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||
|
|
@ -305,6 +343,7 @@ describe("agent permission routes", () => {
|
|||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
|
||||
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
||||
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
||||
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
||||
bundle: null,
|
||||
|
|
@ -327,6 +366,9 @@ describe("agent permission routes", () => {
|
|||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||
censorUsernameInLogs: false,
|
||||
});
|
||||
mockEnsureOpenCodeModelConfiguredAndAvailable.mockResolvedValue([
|
||||
{ id: "opencode/gpt-5-nano", label: "opencode/gpt-5-nano" },
|
||||
]);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
|
|
@ -566,7 +608,7 @@ describe("agent permission routes", () => {
|
|||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect([200, 201]).toContain(res.status);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
|
|
@ -801,6 +843,524 @@ describe("agent permission routes", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("rejects creating an agent with an environment from another company", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "other-company",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain("Environment not found");
|
||||
});
|
||||
|
||||
it("rejects creating an agent with an unsupported non-local default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
||||
});
|
||||
|
||||
it("allows creating a codex agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.create.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "codex_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Codex Builder",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect([200, 201]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("allows creating a claude agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.create.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "claude_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Claude Builder",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("allows creating a gemini agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.create.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "gemini_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Gemini Builder",
|
||||
role: "engineer",
|
||||
adapterType: "gemini_local",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("allows creating an opencode agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.create.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "opencode_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "OpenCode Builder",
|
||||
role: "engineer",
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: {
|
||||
model: "opencode/gpt-5-nano",
|
||||
},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("allows creating a cursor agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.create.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "cursor",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Cursor Builder",
|
||||
role: "engineer",
|
||||
adapterType: "cursor",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("allows creating a pi agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.create.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "pi_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Pi Builder",
|
||||
role: "engineer",
|
||||
adapterType: "pi_local",
|
||||
adapterConfig: {
|
||||
model: "openai/gpt-5.4-mini",
|
||||
},
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect([200, 201]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("rejects updating an agent with an unsupported non-local default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
||||
});
|
||||
|
||||
it("allows updating a codex agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "codex_local",
|
||||
defaultEnvironmentId: null,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "codex_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows updating a claude agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "claude_local",
|
||||
defaultEnvironmentId: null,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "claude_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows updating a gemini agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "gemini_local",
|
||||
defaultEnvironmentId: null,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "gemini_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows updating an opencode agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "opencode_local",
|
||||
defaultEnvironmentId: null,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "opencode_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows updating a cursor agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "cursor",
|
||||
defaultEnvironmentId: null,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "cursor",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows updating a pi agent with an SSH default environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "pi_local",
|
||||
defaultEnvironmentId: null,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "pi_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects switching a codex agent away from SSH-capable runtime without clearing its SSH default", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "codex_local",
|
||||
defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
adapterType: "process",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
||||
});
|
||||
|
||||
it("exposes explicit task assignment access on agent detail", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ const mockApprovalService = vi.hoisted(() => ({
|
|||
create: vi.fn(),
|
||||
}));
|
||||
const mockBudgetService = vi.hoisted(() => ({}));
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
const mockHeartbeatService = vi.hoisted(() => ({}));
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
linkManyForApproval: vi.fn(),
|
||||
|
|
@ -74,6 +77,7 @@ vi.mock("../services/index.js", () => ({
|
|||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => ({}),
|
||||
|
|
@ -174,6 +178,7 @@ function makeAgent(adapterType: string) {
|
|||
adapterType,
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: null,
|
||||
permissions: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
|
|
@ -34,6 +35,20 @@ vi.mock("../services/index.js", () => ({
|
|||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
|
||||
let currentServer: Server | null = null;
|
||||
|
||||
async function closeCurrentServer() {
|
||||
if (!currentServer) return;
|
||||
const server = currentServer;
|
||||
currentServer = null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
|
|
@ -48,6 +63,7 @@ function registerModuleMocks() {
|
|||
}
|
||||
|
||||
async function createApp(actor: any, db: any = {} as any) {
|
||||
await closeCurrentServer();
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
|
|
@ -68,10 +84,13 @@ async function createApp(actor: any, db: any = {} as any) {
|
|||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
currentServer = app.listen(0);
|
||||
return currentServer;
|
||||
}
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
afterEach(closeCurrentServer);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
|
|
|
|||
118
server/src/__tests__/environment-config.test.ts
Normal file
118
server/src/__tests__/environment-config.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { normalizeEnvironmentConfig, parseEnvironmentDriverConfig } from "../services/environment-config.ts";
|
||||
|
||||
describe("environment config helpers", () => {
|
||||
it("normalizes SSH config into its canonical stored shape", () => {
|
||||
const config = normalizeEnvironmentConfig({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: "2222",
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKeySecretRef: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
knownHosts: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
host: "ssh.example.test",
|
||||
port: 2222,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects raw SSH private keys in the stored config shape", () => {
|
||||
expect(() =>
|
||||
normalizeEnvironmentConfig({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: "2222",
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
},
|
||||
}),
|
||||
).toThrow(HttpError);
|
||||
});
|
||||
|
||||
it("rejects SSH config without an absolute remote workspace path", () => {
|
||||
expect(() =>
|
||||
normalizeEnvironmentConfig({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "workspace",
|
||||
},
|
||||
}),
|
||||
).toThrow(HttpError);
|
||||
|
||||
expect(() =>
|
||||
normalizeEnvironmentConfig({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "workspace",
|
||||
},
|
||||
}),
|
||||
).toThrow("absolute");
|
||||
});
|
||||
|
||||
it("parses a persisted SSH environment into a typed driver config", () => {
|
||||
const parsed = parseEnvironmentDriverConfig({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported environment drivers", () => {
|
||||
expect(() =>
|
||||
normalizeEnvironmentConfig({
|
||||
driver: "sandbox" as any,
|
||||
config: {
|
||||
provider: "fake",
|
||||
},
|
||||
}),
|
||||
).toThrow(HttpError);
|
||||
});
|
||||
});
|
||||
183
server/src/__tests__/environment-live-ssh.test.ts
Normal file
183
server/src/__tests__/environment-live-ssh.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSshEnvLabFixtureConfig,
|
||||
ensureSshWorkspaceReady,
|
||||
readSshEnvLabFixtureStatus,
|
||||
runSshCommand,
|
||||
startSshEnvLabFixture,
|
||||
stopSshEnvLabFixture,
|
||||
type SshConnectionConfig,
|
||||
} from "@paperclipai/adapter-utils/ssh";
|
||||
|
||||
async function readOptionalSecret(
|
||||
value: string | undefined,
|
||||
filePath: string | undefined,
|
||||
): Promise<string | null> {
|
||||
if (value && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
if (filePath && filePath.trim().length > 0) {
|
||||
return await readFile(filePath, "utf8");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the env-lab state path for this instance. Falls back to a temp
|
||||
* directory scoped to the test run so parallel runs don't collide.
|
||||
*/
|
||||
function resolveEnvLabStatePath(): string {
|
||||
const instanceRoot =
|
||||
process.env.PAPERCLIP_INSTANCE_ROOT?.trim() ||
|
||||
path.join(process.env.HOME ?? "/tmp", ".paperclip-worktrees", "instances", "live-ssh-test");
|
||||
return path.join(instanceRoot, "env-lab", "ssh-fixture", "state.json");
|
||||
}
|
||||
|
||||
/** Attempt to build config from explicit PAPERCLIP_ENV_LIVE_SSH_* env vars. */
|
||||
function tryExplicitConfig(): {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
remoteWorkspacePath: string;
|
||||
} | null {
|
||||
const host = process.env.PAPERCLIP_ENV_LIVE_SSH_HOST?.trim() ?? "";
|
||||
const username = process.env.PAPERCLIP_ENV_LIVE_SSH_USERNAME?.trim() ?? "";
|
||||
const remoteWorkspacePath =
|
||||
process.env.PAPERCLIP_ENV_LIVE_SSH_REMOTE_WORKSPACE_PATH?.trim() ?? "";
|
||||
const port = Number.parseInt(process.env.PAPERCLIP_ENV_LIVE_SSH_PORT ?? "22", 10);
|
||||
|
||||
if (!host || !username || !remoteWorkspacePath || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
return { host, port, username, remoteWorkspacePath };
|
||||
}
|
||||
|
||||
/** Try to use an already-running env-lab fixture. */
|
||||
async function tryEnvLabFixture(): Promise<SshConnectionConfig | null> {
|
||||
const statePath = resolveEnvLabStatePath();
|
||||
const status = await readSshEnvLabFixtureStatus(statePath);
|
||||
if (status.running && status.state) {
|
||||
return buildSshEnvLabFixtureConfig(status.state);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a fresh env-lab SSH fixture for this test run. Returns the config
|
||||
* and a cleanup function to stop it afterwards.
|
||||
*/
|
||||
async function startEnvLabForTest(): Promise<{
|
||||
config: SshConnectionConfig;
|
||||
cleanup: () => Promise<void>;
|
||||
} | null> {
|
||||
const statePath = resolveEnvLabStatePath();
|
||||
try {
|
||||
const state = await startSshEnvLabFixture({ statePath });
|
||||
const config = await buildSshEnvLabFixtureConfig(state);
|
||||
return {
|
||||
config,
|
||||
cleanup: async () => {
|
||||
await stopSshEnvLabFixture(statePath);
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let envLabCleanup: (() => Promise<void>) | null = null;
|
||||
|
||||
/**
|
||||
* Resolve an SSH connection config from (in order):
|
||||
* 1. Explicit PAPERCLIP_ENV_LIVE_SSH_* env vars
|
||||
* 2. An already-running env-lab fixture
|
||||
* 3. Auto-starting an env-lab fixture
|
||||
*/
|
||||
async function resolveSshConfig(): Promise<SshConnectionConfig | null> {
|
||||
// 1. Explicit env vars
|
||||
const explicit = tryExplicitConfig();
|
||||
if (explicit) {
|
||||
return {
|
||||
...explicit,
|
||||
privateKey: await readOptionalSecret(
|
||||
process.env.PAPERCLIP_ENV_LIVE_SSH_PRIVATE_KEY,
|
||||
process.env.PAPERCLIP_ENV_LIVE_SSH_PRIVATE_KEY_PATH,
|
||||
),
|
||||
knownHosts: await readOptionalSecret(
|
||||
process.env.PAPERCLIP_ENV_LIVE_SSH_KNOWN_HOSTS,
|
||||
process.env.PAPERCLIP_ENV_LIVE_SSH_KNOWN_HOSTS_PATH,
|
||||
),
|
||||
strictHostKeyChecking:
|
||||
(process.env.PAPERCLIP_ENV_LIVE_SSH_STRICT_HOST_KEY_CHECKING ?? "true").toLowerCase() !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Already-running env-lab
|
||||
const running = await tryEnvLabFixture();
|
||||
if (running) return running;
|
||||
|
||||
// 3. Auto-start env-lab
|
||||
if (process.env.PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE !== "true") {
|
||||
const started = await startEnvLabForTest();
|
||||
if (started) {
|
||||
envLabCleanup = started.cleanup;
|
||||
return started.config;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let resolvedConfig: SshConnectionConfig | null | undefined;
|
||||
|
||||
const describeLiveSsh = (() => {
|
||||
// Eagerly check explicit env vars for sync skip decision.
|
||||
// If explicit vars are set, use them. Otherwise, we'll attempt env-lab in beforeAll.
|
||||
if (tryExplicitConfig()) return describe;
|
||||
// If NO_AUTO_FIXTURE is set and no explicit config, skip immediately
|
||||
if (process.env.PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE === "true") {
|
||||
console.warn(
|
||||
"Skipping live SSH smoke test. Set PAPERCLIP_ENV_LIVE_SSH_HOST, PAPERCLIP_ENV_LIVE_SSH_USERNAME, and PAPERCLIP_ENV_LIVE_SSH_REMOTE_WORKSPACE_PATH to enable it, or remove PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE to auto-start env-lab.",
|
||||
);
|
||||
return describe.skip;
|
||||
}
|
||||
// Will attempt env-lab — don't skip yet
|
||||
return describe;
|
||||
})();
|
||||
|
||||
describeLiveSsh("live SSH environment smoke", () => {
|
||||
afterAll(async () => {
|
||||
if (envLabCleanup) {
|
||||
await envLabCleanup();
|
||||
envLabCleanup = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("connects to the configured SSH environment and verifies basic runtime tools", async () => {
|
||||
if (resolvedConfig === undefined) {
|
||||
resolvedConfig = await resolveSshConfig();
|
||||
}
|
||||
|
||||
if (!resolvedConfig) {
|
||||
throw new Error(
|
||||
"Live SSH smoke test could not resolve SSH config from env vars or env-lab fixture. Set PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE=true to mark this suite skipped intentionally.",
|
||||
);
|
||||
}
|
||||
|
||||
const config = resolvedConfig;
|
||||
const ready = await ensureSshWorkspaceReady(config);
|
||||
const quotedRemoteWorkspacePath = JSON.stringify(config.remoteWorkspacePath);
|
||||
const result = await runSshCommand(
|
||||
config,
|
||||
`sh -lc "cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd"`,
|
||||
{ timeoutMs: 30000, maxBuffer: 256 * 1024 },
|
||||
);
|
||||
|
||||
expect(ready.remoteCwd).toBe(config.remoteWorkspacePath);
|
||||
expect(result.stdout).toContain(config.remoteWorkspacePath);
|
||||
expect(result.stdout).toContain("git");
|
||||
expect(result.stdout).toContain("tar");
|
||||
});
|
||||
});
|
||||
118
server/src/__tests__/environment-probe.test.ts
Normal file
118
server/src/__tests__/environment-probe.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({
|
||||
ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady,
|
||||
}));
|
||||
|
||||
import { probeEnvironment } from "../services/environment-probe.ts";
|
||||
|
||||
describe("probeEnvironment", () => {
|
||||
beforeEach(() => {
|
||||
mockEnsureSshWorkspaceReady.mockReset();
|
||||
});
|
||||
|
||||
it("reports local environments as immediately available", async () => {
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-1",
|
||||
companyId: "company-1",
|
||||
name: "Local",
|
||||
description: null,
|
||||
driver: "local",
|
||||
status: "active",
|
||||
config: {},
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.driver).toBe("local");
|
||||
expect(result.summary).toContain("Local environment");
|
||||
expect(mockEnsureSshWorkspaceReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs an SSH probe and returns the verified remote cwd", async () => {
|
||||
mockEnsureSshWorkspaceReady.mockResolvedValue({
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
});
|
||||
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-ssh",
|
||||
companyId: "company-1",
|
||||
name: "SSH Fixture",
|
||||
description: null,
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 2222,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
driver: "ssh",
|
||||
summary: "Connected to ssh-user@ssh.example.test and verified the remote workspace path.",
|
||||
details: {
|
||||
host: "ssh.example.test",
|
||||
port: 2222,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
},
|
||||
});
|
||||
expect(mockEnsureSshWorkspaceReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("captures SSH probe failures without throwing", async () => {
|
||||
mockEnsureSshWorkspaceReady.mockRejectedValue(
|
||||
Object.assign(new Error("Permission denied"), {
|
||||
code: 255,
|
||||
stdout: "",
|
||||
stderr: "Permission denied (publickey).",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-ssh",
|
||||
companyId: "company-1",
|
||||
name: "SSH Fixture",
|
||||
description: null,
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.summary).toContain("SSH probe failed");
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "Permission denied (publickey).",
|
||||
code: 255,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
1181
server/src/__tests__/environment-routes.test.ts
Normal file
1181
server/src/__tests__/environment-routes.test.ts
Normal file
File diff suppressed because it is too large
Load diff
492
server/src/__tests__/environment-selection-route-guards.test.ts
Normal file
492
server/src/__tests__/environment-selection-route-guards.test.ts
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
listWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
createChild: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockReferenceSummary = vi.hoisted(() => ({
|
||||
inbound: [],
|
||||
outbound: [],
|
||||
documentSources: [],
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
projectService: () => mockProjectService,
|
||||
issueService: () => mockIssueService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
secretService: () => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env),
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: unknown) => config),
|
||||
}),
|
||||
logActivity: mockLogActivity,
|
||||
workspaceOperationService: () => ({}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
goalService: () => ({
|
||||
getById: vi.fn(),
|
||||
getDefaultCompanyGoal: vi.fn(),
|
||||
}),
|
||||
heartbeatService: () => ({
|
||||
getRun: vi.fn(),
|
||||
getActiveRunForAgent: vi.fn(),
|
||||
}),
|
||||
issueApprovalService: () => ({
|
||||
listApprovalsForIssue: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(),
|
||||
listFeedbackTraces: vi.fn(),
|
||||
getFeedbackTraceById: vi.fn(),
|
||||
getFeedbackTraceBundle: vi.fn(),
|
||||
saveIssueVote: vi.fn(),
|
||||
}),
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({})),
|
||||
listCompanyIds: vi.fn(async () => []),
|
||||
}),
|
||||
issueReferenceService: () => ({
|
||||
emptySummary: vi.fn(() => mockReferenceSummary),
|
||||
syncIssue: vi.fn(),
|
||||
syncComment: vi.fn(),
|
||||
syncDocument: vi.fn(),
|
||||
deleteDocumentSource: vi.fn(),
|
||||
listIssueReferenceSummary: vi.fn(async () => mockReferenceSummary),
|
||||
diffIssueReferenceSummary: vi.fn(() => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
})),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
routineService: () => ({}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/issue-assignment-wakeup.js", () => ({
|
||||
queueIssueAssignmentWakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
function buildApp(routerFactory: (app: express.Express) => void) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
};
|
||||
next();
|
||||
});
|
||||
routerFactory(app);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
let projectServer: Server | null = null;
|
||||
let issueServer: Server | null = null;
|
||||
|
||||
function createProjectApp() {
|
||||
projectServer ??= buildApp((expressApp) => {
|
||||
expressApp.use("/api", projectRoutes({} as any));
|
||||
}).listen(0);
|
||||
return projectServer;
|
||||
}
|
||||
|
||||
function createIssueApp() {
|
||||
issueServer ??= buildApp((expressApp) => {
|
||||
expressApp.use("/api", issueRoutes({} as any, {} as any));
|
||||
}).listen(0);
|
||||
return issueServer;
|
||||
}
|
||||
|
||||
const sshEnvironmentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
async function closeServer(server: Server | null) {
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe.sequential("execution environment route guards", () => {
|
||||
afterAll(async () => {
|
||||
await closeServer(projectServer);
|
||||
await closeServer(issueServer);
|
||||
projectServer = null;
|
||||
issueServer = null;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockProjectService.create.mockReset();
|
||||
mockProjectService.getById.mockReset();
|
||||
mockProjectService.update.mockReset();
|
||||
mockProjectService.createWorkspace.mockReset();
|
||||
mockProjectService.remove.mockReset();
|
||||
mockProjectService.resolveByReference.mockReset();
|
||||
mockProjectService.listWorkspaces.mockReset();
|
||||
mockIssueService.create.mockReset();
|
||||
mockIssueService.createChild.mockReset();
|
||||
mockIssueService.getById.mockReset();
|
||||
mockIssueService.update.mockReset();
|
||||
mockIssueService.getByIdentifier.mockReset();
|
||||
mockIssueService.assertCheckoutOwner.mockReset();
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
it("accepts SSH environments on project create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
mockProjectService.create.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "SSH Project",
|
||||
status: "backlog",
|
||||
});
|
||||
const app = createProjectApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({
|
||||
name: "SSH Project",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(422);
|
||||
expect(mockProjectService.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts SSH environments on project update", async () => {
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "SSH Project",
|
||||
status: "backlog",
|
||||
archivedAt: null,
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
mockProjectService.update.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "SSH Project",
|
||||
status: "backlog",
|
||||
});
|
||||
const app = createProjectApp();
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/projects/project-1")
|
||||
.send({
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(422);
|
||||
expect(mockProjectService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-company environments on project create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-2",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
const app = createProjectApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({
|
||||
name: "Cross Company Project",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Environment not found.");
|
||||
expect(mockProjectService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsupported driver environments on project update", async () => {
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "SSH Project",
|
||||
status: "backlog",
|
||||
archivedAt: null,
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "unsupported_driver",
|
||||
config: {},
|
||||
});
|
||||
const app = createProjectApp();
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/projects/project-1")
|
||||
.send({
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "unsupported_driver" is not allowed here');
|
||||
expect(mockProjectService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects archived environments on project create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "ssh",
|
||||
status: "archived",
|
||||
config: {},
|
||||
});
|
||||
const app = createProjectApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({
|
||||
name: "Archived Project",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Environment is archived.");
|
||||
expect(mockProjectService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects archived environments on issue create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "ssh",
|
||||
status: "archived",
|
||||
config: {},
|
||||
});
|
||||
const app = createIssueApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues")
|
||||
.send({
|
||||
title: "Archived Issue",
|
||||
executionWorkspaceSettings: {
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Environment is archived.");
|
||||
expect(mockIssueService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts SSH environments on issue create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
mockIssueService.create.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
title: "SSH Issue",
|
||||
status: "todo",
|
||||
identifier: "PAPA-999",
|
||||
});
|
||||
const app = createIssueApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues")
|
||||
.send({
|
||||
title: "SSH Issue",
|
||||
executionWorkspaceSettings: {
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(422);
|
||||
expect(mockIssueService.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsupported driver environments on issue create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "unsupported_driver",
|
||||
config: {},
|
||||
});
|
||||
const app = createIssueApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues")
|
||||
.send({
|
||||
title: "Unsupported Driver Issue",
|
||||
executionWorkspaceSettings: {
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "unsupported_driver" is not allowed here');
|
||||
expect(mockIssueService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsupported driver environments on child issue create", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "parent-1",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByUserId: null,
|
||||
identifier: "PAPA-998",
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "unsupported_driver",
|
||||
config: {},
|
||||
});
|
||||
const app = createIssueApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/parent-1/children")
|
||||
.send({
|
||||
title: "Unsupported Child",
|
||||
executionWorkspaceSettings: {
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "unsupported_driver" is not allowed here');
|
||||
expect(mockIssueService.createChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-company environments on child issue create", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "parent-1",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByUserId: null,
|
||||
identifier: "PAPA-998",
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-2",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
const app = createIssueApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/parent-1/children")
|
||||
.send({
|
||||
title: "Cross Company Child",
|
||||
executionWorkspaceSettings: {
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Environment not found.");
|
||||
expect(mockIssueService.createChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts SSH environments on issue update", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByUserId: null,
|
||||
identifier: "PAPA-999",
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sshEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
mockIssueService.update.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
identifier: "PAPA-999",
|
||||
});
|
||||
const app = createIssueApp();
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/issues/issue-1")
|
||||
.send({
|
||||
executionWorkspaceSettings: {
|
||||
environmentId: sshEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(422);
|
||||
expect(mockIssueService.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -221,4 +221,31 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
|||
expect(rows[0]?.driver).toBe("local");
|
||||
expect(rows[0]?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("allows multiple SSH environments for the same company", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const first = await svc.create(companyId, {
|
||||
name: "Production SSH",
|
||||
driver: "ssh",
|
||||
config: { host: "prod.example.com", username: "deploy" },
|
||||
});
|
||||
const second = await svc.create(companyId, {
|
||||
name: "Staging SSH",
|
||||
driver: "ssh",
|
||||
config: { host: "staging.example.com", username: "deploy" },
|
||||
});
|
||||
|
||||
expect(first.id).not.toBe(second.id);
|
||||
|
||||
const rows = await db.select().from(environments).where(eq(environments.companyId, companyId));
|
||||
expect(rows.filter((row) => row.driver === "ssh")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceEnvironmentId,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
|
||||
|
|
@ -117,6 +118,7 @@ describe("execution workspace policy helpers", () => {
|
|||
parseProjectExecutionWorkspacePolicy({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
|
|
@ -127,6 +129,7 @@ describe("execution workspace policy helpers", () => {
|
|||
).toEqual({
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
|
|
@ -137,12 +140,83 @@ describe("execution workspace policy helpers", () => {
|
|||
expect(
|
||||
parseIssueExecutionWorkspaceSettings({
|
||||
mode: "project_primary",
|
||||
environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "shared_workspace",
|
||||
environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers persisted environment selection over issue and project defaults", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
issueSettings: { environmentId: "issue-env" },
|
||||
workspaceConfig: { environmentId: "workspace-env" },
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("workspace-env");
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
issueSettings: { environmentId: "issue-env" },
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("issue-env");
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
issueSettings: null,
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("project-env");
|
||||
});
|
||||
|
||||
it("falls back to the agent default environment before the company default", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: null,
|
||||
issueSettings: null,
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("agent-env");
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: null,
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: null,
|
||||
issueSettings: null,
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: null,
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
});
|
||||
|
||||
it("maps persisted execution workspace modes back to issue settings", () => {
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
|
|
@ -30,6 +31,7 @@ describe("execution workspace config helpers", () => {
|
|||
expect(readExecutionWorkspaceConfig({
|
||||
source: "project_primary",
|
||||
config: {
|
||||
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
|
|
@ -38,6 +40,7 @@ describe("execution workspace config helpers", () => {
|
|||
},
|
||||
},
|
||||
})).toEqual({
|
||||
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
|
|
@ -55,11 +58,13 @@ describe("execution workspace config helpers", () => {
|
|||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
},
|
||||
},
|
||||
{
|
||||
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
|
|
@ -69,6 +74,7 @@ describe("execution workspace config helpers", () => {
|
|||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
|
|
@ -81,6 +87,22 @@ describe("execution workspace config helpers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("clears a persisted environment selection when patching it to null", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
source: "project_primary",
|
||||
config: {
|
||||
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
|
||||
},
|
||||
},
|
||||
{
|
||||
environmentId: null,
|
||||
},
|
||||
)).toEqual({
|
||||
source: "project_primary",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the nested config block when requested", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
|
|
@ -223,6 +245,104 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
|||
]));
|
||||
});
|
||||
|
||||
it("clears matching environment selections transactionally without touching other workspaces", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const matchingWorkspaceId = randomUUID();
|
||||
const otherWorkspaceId = randomUUID();
|
||||
const untouchedWorkspaceId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace cleanup",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: matchingWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "directory",
|
||||
name: "Matching workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/workspace-a",
|
||||
metadata: {
|
||||
source: "manual",
|
||||
config: {
|
||||
environmentId,
|
||||
cleanupCommand: "echo clean",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: otherWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "directory",
|
||||
name: "Different environment",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/workspace-b",
|
||||
metadata: {
|
||||
source: "manual",
|
||||
config: {
|
||||
environmentId: randomUUID(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: untouchedWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "directory",
|
||||
name: "No environment",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/workspace-c",
|
||||
metadata: {
|
||||
source: "manual",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const cleared = await svc.clearEnvironmentSelection(companyId, environmentId);
|
||||
|
||||
expect(cleared).toBe(1);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: executionWorkspaces.id,
|
||||
metadata: executionWorkspaces.metadata,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.where(inArray(executionWorkspaces.id, [matchingWorkspaceId, otherWorkspaceId, untouchedWorkspaceId]));
|
||||
|
||||
const byId = new Map(rows.map((row) => [row.id, row.metadata as Record<string, unknown> | null]));
|
||||
expect(readExecutionWorkspaceConfig(byId.get(matchingWorkspaceId) ?? null)).toMatchObject({
|
||||
environmentId: null,
|
||||
cleanupCommand: "echo clean",
|
||||
});
|
||||
expect(readExecutionWorkspaceConfig(byId.get(otherWorkspaceId) ?? null)).toMatchObject({
|
||||
environmentId: expect.any(String),
|
||||
});
|
||||
expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull();
|
||||
});
|
||||
|
||||
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
tempDirs.add(repoRoot);
|
||||
|
|
|
|||
|
|
@ -752,7 +752,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
expect(blockedIssue?.executionRunId).toBeNull();
|
||||
expect(blockedIssue?.checkoutRunId).toBe(continuationRun?.id ?? null);
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
const comments = await waitForValue(async () => {
|
||||
const rows = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
return rows.length > 0 ? rows : null;
|
||||
});
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("retried continuation");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ describe("instance settings routes", () => {
|
|||
feedbackDataSharingPreference: "prompt",
|
||||
});
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
});
|
||||
|
|
@ -69,6 +70,7 @@ describe("instance settings routes", () => {
|
|||
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
|
||||
id: "instance-settings-1",
|
||||
experimental: {
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
},
|
||||
|
|
@ -87,6 +89,7 @@ describe("instance settings routes", () => {
|
|||
const getRes = await request(app).get("/api/instance/settings/experimental");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body).toEqual({
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
});
|
||||
|
|
@ -120,6 +123,24 @@ describe("instance settings routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("allows local board users to update environment controls", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableEnvironments: true })
|
||||
.expect(200);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableEnvironments: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local board users to read and update general settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
|
|||
|
|
@ -77,25 +77,8 @@ async function createApp(
|
|||
return app;
|
||||
}
|
||||
|
||||
describe("GET /invites/:token/test-resolution", () => {
|
||||
describe.sequential("GET /invites/:token/test-resolution", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("node:dns/promises");
|
||||
vi.doUnmock("node:http");
|
||||
vi.doUnmock("node:https");
|
||||
vi.doUnmock("node:net");
|
||||
vi.doUnmock("../board-claim.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../storage/index.js");
|
||||
vi.doUnmock("../middleware/logger.js");
|
||||
vi.doUnmock("../routes/access.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.doMock("node:dns/promises", async () => vi.importActual("node:dns/promises"));
|
||||
vi.doMock("node:http", async () => vi.importActual("node:http"));
|
||||
vi.doMock("node:https", async () => vi.importActual("node:https"));
|
||||
vi.doMock("node:net", async () => vi.importActual("node:net"));
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
currentAccessModule = null;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildPaperclipEnv } from "../adapters/utils.js";
|
||||
|
||||
const ORIGINAL_PAPERCLIP_RUNTIME_API_URL = process.env.PAPERCLIP_RUNTIME_API_URL;
|
||||
const ORIGINAL_PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL;
|
||||
const ORIGINAL_PAPERCLIP_LISTEN_HOST = process.env.PAPERCLIP_LISTEN_HOST;
|
||||
const ORIGINAL_PAPERCLIP_LISTEN_PORT = process.env.PAPERCLIP_LISTEN_PORT;
|
||||
|
|
@ -8,6 +9,9 @@ const ORIGINAL_HOST = process.env.HOST;
|
|||
const ORIGINAL_PORT = process.env.PORT;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_PAPERCLIP_RUNTIME_API_URL === undefined) delete process.env.PAPERCLIP_RUNTIME_API_URL;
|
||||
else process.env.PAPERCLIP_RUNTIME_API_URL = ORIGINAL_PAPERCLIP_RUNTIME_API_URL;
|
||||
|
||||
if (ORIGINAL_PAPERCLIP_API_URL === undefined) delete process.env.PAPERCLIP_API_URL;
|
||||
else process.env.PAPERCLIP_API_URL = ORIGINAL_PAPERCLIP_API_URL;
|
||||
|
||||
|
|
@ -25,7 +29,19 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("buildPaperclipEnv", () => {
|
||||
it("prefers an explicit PAPERCLIP_API_URL", () => {
|
||||
it("prefers an explicit PAPERCLIP_RUNTIME_API_URL", () => {
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL = "http://100.104.161.29:3102";
|
||||
process.env.PAPERCLIP_API_URL = "http://localhost:4100";
|
||||
process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1";
|
||||
process.env.PAPERCLIP_LISTEN_PORT = "3101";
|
||||
|
||||
const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" });
|
||||
|
||||
expect(env.PAPERCLIP_API_URL).toBe("http://100.104.161.29:3102");
|
||||
});
|
||||
|
||||
it("falls back to PAPERCLIP_API_URL when no runtime URL is configured", () => {
|
||||
delete process.env.PAPERCLIP_RUNTIME_API_URL;
|
||||
process.env.PAPERCLIP_API_URL = "http://localhost:4100";
|
||||
process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1";
|
||||
process.env.PAPERCLIP_LISTEN_PORT = "3101";
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
|||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||
}));
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockTelemetryTrack = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -31,6 +34,7 @@ vi.mock("../telemetry.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
goalService: () => mockGoalService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
|
|
@ -49,6 +53,7 @@ function registerModuleMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
goalService: () => mockGoalService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
|
|
@ -107,6 +112,7 @@ describe("project and goal telemetry routes", () => {
|
|||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
mockProjectService.create.mockResolvedValue({
|
||||
id: "project-1",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ const mockProjectService = vi.hoisted(() => ({
|
|||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||
}));
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -26,6 +29,7 @@ vi.mock("../telemetry.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
|
|
@ -43,6 +47,7 @@ function registerModuleMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
|
|
@ -127,6 +132,7 @@ describe("project env routes", () => {
|
|||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.createWorkspace.mockResolvedValue(null);
|
||||
mockProjectService.listWorkspaces.mockResolvedValue([]);
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
|||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||
}));
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -34,6 +37,7 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
|
|
@ -158,6 +162,7 @@ describe("workspace runtime service route authorization", () => {
|
|||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.create.mockResolvedValue(buildProject());
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { projectRoutes } from "./routes/projects.js";
|
|||
import { issueRoutes } from "./routes/issues.js";
|
||||
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
||||
import { routineRoutes } from "./routes/routines.js";
|
||||
import { environmentRoutes } from "./routes/environments.js";
|
||||
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
||||
import { goalRoutes } from "./routes/goals.js";
|
||||
import { approvalRoutes } from "./routes/approvals.js";
|
||||
|
|
@ -43,7 +44,7 @@ import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
|||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js";
|
||||
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
|
||||
import { createPluginWorkerManager, type PluginWorkerManager } from "./services/plugin-worker-manager.js";
|
||||
import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
||||
import { pluginJobStore } from "./services/plugin-job-store.js";
|
||||
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
||||
|
|
@ -129,6 +130,7 @@ export async function createApp(
|
|||
hostVersion?: string;
|
||||
localPluginDir?: string;
|
||||
pluginMigrationDb?: Db;
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
betterAuthHandler?: express.RequestHandler;
|
||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||
},
|
||||
|
|
@ -170,6 +172,9 @@ export async function createApp(
|
|||
}
|
||||
app.use(llmRoutes(db));
|
||||
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = opts.pluginWorkerManager ?? createPluginWorkerManager();
|
||||
|
||||
// Mount API routes
|
||||
const api = Router();
|
||||
api.use(boardMutationGuard());
|
||||
|
|
@ -192,6 +197,7 @@ export async function createApp(
|
|||
}));
|
||||
api.use(issueTreeControlRoutes(db));
|
||||
api.use(routineRoutes(db));
|
||||
api.use(environmentRoutes(db));
|
||||
api.use(executionWorkspaceRoutes(db));
|
||||
api.use(goalRoutes(db));
|
||||
api.use(approvalRoutes(db));
|
||||
|
|
@ -207,8 +213,6 @@ export async function createApp(
|
|||
if (opts.databaseBackupService) {
|
||||
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
|
||||
}
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const eventBus = createPluginEventBus();
|
||||
setPluginEventBus(eventBus);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
updateAgentInstructionsPathSchema,
|
||||
wakeAgentSchema,
|
||||
updateAgentSchema,
|
||||
supportedEnvironmentDriversForAdapter,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
approvalService,
|
||||
companySkillService,
|
||||
budgetService,
|
||||
environmentService,
|
||||
heartbeatService,
|
||||
ISSUE_LIST_DEFAULT_LIMIT,
|
||||
issueApprovalService,
|
||||
|
|
@ -76,6 +78,7 @@ import {
|
|||
resolveDefaultAgentInstructionsBundleRole,
|
||||
} from "../services/default-agent-instructions.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||
|
||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||
|
|
@ -139,6 +142,17 @@ export function agentRoutes(db: Db) {
|
|||
const instanceSettings = instanceSettingsService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
|
||||
async function assertAgentEnvironmentSelection(
|
||||
companyId: string,
|
||||
adapterType: string,
|
||||
environmentId: string | null | undefined,
|
||||
) {
|
||||
if (environmentId === undefined || environmentId === null) return;
|
||||
await assertEnvironmentSelectionForCompany(environmentService(db), companyId, environmentId, {
|
||||
allowedDrivers: allowedEnvironmentDriversForAgent(adapterType),
|
||||
});
|
||||
}
|
||||
|
||||
async function getCurrentUserRedactionOptions() {
|
||||
return {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
|
|
@ -407,6 +421,10 @@ export function agentRoutes(db: Db) {
|
|||
return Object.hasOwn(value, key);
|
||||
}
|
||||
|
||||
function allowedEnvironmentDriversForAgent(adapterType: string): string[] {
|
||||
return supportedEnvironmentDriversForAdapter(adapterType);
|
||||
}
|
||||
|
||||
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
|
||||
const companyIdQuery = req.query.companyId;
|
||||
const requestedCompanyId =
|
||||
|
|
@ -1609,6 +1627,7 @@ export function agentRoutes(db: Db) {
|
|||
createInput.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId);
|
||||
|
||||
const createdAgent = await svc.create(companyId, {
|
||||
...createInput,
|
||||
|
|
@ -2065,6 +2084,15 @@ export function agentRoutes(db: Db) {
|
|||
effectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {
|
||||
await assertAgentEnvironmentSelection(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")
|
||||
? (typeof patchData.defaultEnvironmentId === "string" ? patchData.defaultEnvironmentId : null)
|
||||
: existing.defaultEnvironmentId,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
|
|
|
|||
32
server/src/routes/environment-selection.ts
Normal file
32
server/src/routes/environment-selection.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { unprocessable } from "../errors.js";
|
||||
|
||||
export async function assertEnvironmentSelectionForCompany(
|
||||
environmentsSvc: {
|
||||
getById(environmentId: string): Promise<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
driver: string;
|
||||
status?: string | null;
|
||||
config: Record<string, unknown> | null;
|
||||
} | null>;
|
||||
},
|
||||
companyId: string,
|
||||
environmentId: string | null | undefined,
|
||||
options?: {
|
||||
allowedDrivers?: string[];
|
||||
},
|
||||
) {
|
||||
if (environmentId === undefined || environmentId === null) return;
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
if (!environment || environment.companyId !== companyId) {
|
||||
throw unprocessable("Environment not found.");
|
||||
}
|
||||
if (environment.status === "archived") {
|
||||
throw unprocessable("Environment is archived.");
|
||||
}
|
||||
if (options?.allowedDrivers && !options.allowedDrivers.includes(environment.driver)) {
|
||||
throw unprocessable(
|
||||
`Environment driver "${environment.driver}" is not allowed here. Allowed drivers: ${options.allowedDrivers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
423
server/src/routes/environments.ts
Normal file
423
server/src/routes/environments.ts
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
AGENT_ADAPTER_TYPES,
|
||||
createEnvironmentSchema,
|
||||
getEnvironmentCapabilities,
|
||||
probeEnvironmentConfigSchema,
|
||||
updateEnvironmentSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
environmentService,
|
||||
executionWorkspaceService,
|
||||
issueService,
|
||||
logActivity,
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
import {
|
||||
normalizeEnvironmentConfigForPersistence,
|
||||
normalizeEnvironmentConfigForProbe,
|
||||
parseEnvironmentDriverConfig,
|
||||
readSshEnvironmentPrivateKeySecretId,
|
||||
type ParsedEnvironmentConfig,
|
||||
} from "../services/environment-config.js";
|
||||
import { probeEnvironment } from "../services/environment-probe.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function environmentRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const agents = agentService(db);
|
||||
const access = accessService(db);
|
||||
const svc = environmentService(db);
|
||||
const executionWorkspaces = executionWorkspaceService(db);
|
||||
const issues = issueService(db);
|
||||
const projects = projectService(db);
|
||||
const secrets = secretService(db);
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function canCreateAgents(agent: { permissions: Record<string, unknown> | null | undefined }) {
|
||||
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
||||
}
|
||||
|
||||
async function assertCanMutateEnvironments(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
if (req.actor.type === "board") {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "environments:manage");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: environments:manage");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.actor.agentId) {
|
||||
throw forbidden("Agent authentication required");
|
||||
}
|
||||
|
||||
const actorAgent = await agents.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
||||
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage");
|
||||
if (allowedByGrant || canCreateAgents(actorAgent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw forbidden("Missing permission: environments:manage");
|
||||
}
|
||||
|
||||
async function actorCanReadEnvironmentConfigurations(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
if (req.actor.type === "board") {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
|
||||
return access.canUser(companyId, req.actor.userId, "environments:manage");
|
||||
}
|
||||
|
||||
if (!req.actor.agentId) return false;
|
||||
const actorAgent = await agents.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) return false;
|
||||
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage");
|
||||
return allowedByGrant || canCreateAgents(actorAgent);
|
||||
}
|
||||
|
||||
function redactEnvironmentForRestrictedView<T extends {
|
||||
config: Record<string, unknown>;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}>(environment: T): T & { configRedacted: true; metadataRedacted: true } {
|
||||
return {
|
||||
...environment,
|
||||
config: {},
|
||||
metadata: null,
|
||||
configRedacted: true,
|
||||
metadataRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeEnvironmentUpdate(
|
||||
patch: Record<string, unknown>,
|
||||
environment: {
|
||||
name: string;
|
||||
driver: string;
|
||||
status: string;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const details: Record<string, unknown> = {
|
||||
changedFields: Object.keys(patch).sort(),
|
||||
};
|
||||
|
||||
if (patch.name !== undefined) details.name = environment.name;
|
||||
if (patch.driver !== undefined) details.driver = environment.driver;
|
||||
if (patch.status !== undefined) details.status = environment.status;
|
||||
if (patch.description !== undefined) details.descriptionChanged = true;
|
||||
if (patch.config !== undefined) {
|
||||
details.configChanged = true;
|
||||
details.configTopLevelKeyCount =
|
||||
patch.config && typeof patch.config === "object" && !Array.isArray(patch.config)
|
||||
? Object.keys(patch.config as Record<string, unknown>).length
|
||||
: 0;
|
||||
}
|
||||
if (patch.metadata !== undefined) {
|
||||
details.metadataChanged = true;
|
||||
details.metadataTopLevelKeyCount =
|
||||
patch.metadata && typeof patch.metadata === "object" && !Array.isArray(patch.metadata)
|
||||
? Object.keys(patch.metadata as Record<string, unknown>).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/environments", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const rows = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
driver: req.query.driver as string | undefined,
|
||||
});
|
||||
const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, companyId);
|
||||
if (canReadConfigs) {
|
||||
res.json(rows);
|
||||
return;
|
||||
}
|
||||
res.json(rows.map((environment) => redactEnvironmentForRestrictedView(environment)));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/environments/capabilities", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(getEnvironmentCapabilities(AGENT_ADAPTER_TYPES));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateEnvironments(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const input = {
|
||||
...req.body,
|
||||
config: await normalizeEnvironmentConfigForPersistence({
|
||||
db,
|
||||
companyId,
|
||||
environmentName: req.body.name,
|
||||
driver: req.body.driver,
|
||||
config: req.body.config,
|
||||
actor: {
|
||||
agentId: actor.agentId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
}),
|
||||
};
|
||||
const environment = await svc.create(companyId, input);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "environment.created",
|
||||
entityType: "environment",
|
||||
entityId: environment.id,
|
||||
details: {
|
||||
name: environment.name,
|
||||
driver: environment.driver,
|
||||
status: environment.status,
|
||||
},
|
||||
});
|
||||
res.status(201).json(environment);
|
||||
});
|
||||
|
||||
router.get("/environments/:id", async (req, res) => {
|
||||
const environment = await svc.getById(req.params.id as string);
|
||||
if (!environment) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, environment.companyId);
|
||||
const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId);
|
||||
if (canReadConfigs) {
|
||||
res.json(environment);
|
||||
return;
|
||||
}
|
||||
res.json(redactEnvironmentForRestrictedView(environment));
|
||||
});
|
||||
|
||||
router.get("/environments/:id/leases", async (req, res) => {
|
||||
const environment = await svc.getById(req.params.id as string);
|
||||
if (!environment) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, environment.companyId);
|
||||
const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId);
|
||||
if (!canReadConfigs) {
|
||||
throw forbidden("Missing permission: environments:manage");
|
||||
}
|
||||
const leases = await svc.listLeases(environment.id, {
|
||||
status: req.query.status as string | undefined,
|
||||
});
|
||||
res.json(leases);
|
||||
});
|
||||
|
||||
router.get("/environment-leases/:leaseId", async (req, res) => {
|
||||
const lease = await svc.getLeaseById(req.params.leaseId as string);
|
||||
if (!lease) {
|
||||
res.status(404).json({ error: "Environment lease not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, lease.companyId);
|
||||
const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, lease.companyId);
|
||||
if (!canReadConfigs) {
|
||||
throw forbidden("Missing permission: environments:manage");
|
||||
}
|
||||
res.json(lease);
|
||||
});
|
||||
|
||||
router.patch("/environments/:id", validate(updateEnvironmentSchema), async (req, res) => {
|
||||
const existing = await svc.getById(req.params.id as string);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanMutateEnvironments(req, existing.companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const nextDriver = req.body.driver ?? existing.driver;
|
||||
const nextName = req.body.name ?? existing.name;
|
||||
const configSource =
|
||||
req.body.config !== undefined
|
||||
? req.body.driver !== undefined && req.body.driver !== existing.driver
|
||||
? req.body.config
|
||||
: {
|
||||
...parseObject(existing.config),
|
||||
...parseObject(req.body.config),
|
||||
}
|
||||
: req.body.driver !== undefined && req.body.driver !== existing.driver
|
||||
? {}
|
||||
: existing.config;
|
||||
const patch = {
|
||||
...req.body,
|
||||
...(req.body.config !== undefined || req.body.driver !== undefined
|
||||
? {
|
||||
config: await normalizeEnvironmentConfigForPersistence({
|
||||
db,
|
||||
companyId: existing.companyId,
|
||||
environmentName: nextName,
|
||||
driver: nextDriver,
|
||||
config: configSource,
|
||||
actor: {
|
||||
agentId: actor.agentId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const environment = await svc.update(existing.id, patch);
|
||||
if (!environment) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: environment.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "environment.updated",
|
||||
entityType: "environment",
|
||||
entityId: environment.id,
|
||||
details: summarizeEnvironmentUpdate(patch as Record<string, unknown>, environment),
|
||||
});
|
||||
res.json(environment);
|
||||
});
|
||||
|
||||
router.delete("/environments/:id", async (req, res) => {
|
||||
const existing = await svc.getById(req.params.id as string);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanMutateEnvironments(req, existing.companyId);
|
||||
await Promise.all([
|
||||
executionWorkspaces.clearEnvironmentSelection(existing.companyId, existing.id),
|
||||
issues.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id),
|
||||
projects.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id),
|
||||
]);
|
||||
const removed = await svc.remove(existing.id);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
const secretId = readSshEnvironmentPrivateKeySecretId(existing);
|
||||
if (secretId) {
|
||||
await secrets.remove(secretId);
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "environment.deleted",
|
||||
entityType: "environment",
|
||||
entityId: removed.id,
|
||||
details: {
|
||||
name: removed.name,
|
||||
driver: removed.driver,
|
||||
status: removed.status,
|
||||
},
|
||||
});
|
||||
res.json(removed);
|
||||
});
|
||||
|
||||
router.post("/environments/:id/probe", async (req, res) => {
|
||||
const environment = await svc.getById(req.params.id as string);
|
||||
if (!environment) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanMutateEnvironments(req, environment.companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const probe = await probeEnvironment(db, environment);
|
||||
await logActivity(db, {
|
||||
companyId: environment.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "environment.probed",
|
||||
entityType: "environment",
|
||||
entityId: environment.id,
|
||||
details: {
|
||||
driver: environment.driver,
|
||||
ok: probe.ok,
|
||||
summary: probe.summary,
|
||||
},
|
||||
});
|
||||
res.json(probe);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/environments/probe-config",
|
||||
validate(probeEnvironmentConfigSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateEnvironments(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const normalizedConfig = normalizeEnvironmentConfigForProbe({
|
||||
driver: req.body.driver,
|
||||
config: req.body.config,
|
||||
});
|
||||
const environment = {
|
||||
id: "unsaved",
|
||||
companyId,
|
||||
name: req.body.name?.trim() || "Unsaved environment",
|
||||
description: req.body.description ?? null,
|
||||
driver: req.body.driver,
|
||||
status: "active" as const,
|
||||
config: normalizedConfig,
|
||||
metadata: req.body.metadata ?? null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const probe = await probeEnvironment(db, environment, {
|
||||
resolvedConfig: {
|
||||
driver: req.body.driver,
|
||||
config: normalizedConfig,
|
||||
} as ParsedEnvironmentConfig,
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "environment.probed_unsaved",
|
||||
entityType: "environment",
|
||||
entityId: "unsaved",
|
||||
details: {
|
||||
driver: environment.driver,
|
||||
ok: probe.ok,
|
||||
summary: probe.summary,
|
||||
configTopLevelKeyCount: Object.keys(environment.config).length,
|
||||
},
|
||||
});
|
||||
res.json(probe);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ import {
|
|||
projectService,
|
||||
routineService,
|
||||
workProductService,
|
||||
environmentService,
|
||||
} from "../services/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
|
||||
|
|
@ -71,6 +72,7 @@ import {
|
|||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||
import {
|
||||
applyIssueExecutionPolicyTransition,
|
||||
normalizeIssueExecutionPolicy,
|
||||
|
|
@ -415,6 +417,19 @@ export function issueRoutes(
|
|||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
async function assertIssueEnvironmentSelection(
|
||||
companyId: string,
|
||||
environmentId: string | null | undefined,
|
||||
) {
|
||||
if (environmentId === undefined || environmentId === null) return;
|
||||
await assertEnvironmentSelectionForCompany(
|
||||
environmentService(db),
|
||||
companyId,
|
||||
environmentId,
|
||||
{ allowedDrivers: ["local", "ssh"] },
|
||||
);
|
||||
}
|
||||
|
||||
async function logExpiredRequestConfirmations(input: {
|
||||
issue: { id: string; companyId: string; identifier?: string | null };
|
||||
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
|
||||
|
|
@ -1635,6 +1650,7 @@ export function issueRoutes(
|
|||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, companyId);
|
||||
}
|
||||
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
|
|
@ -1701,6 +1717,7 @@ export function issueRoutes(
|
|||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, parent.companyId);
|
||||
}
|
||||
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
|
|
@ -1775,6 +1792,7 @@ export function issueRoutes(
|
|||
hiddenAt: hiddenAtRaw,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId);
|
||||
const requestedAssigneeAgentId =
|
||||
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
||||
const effectiveMoveToTodoRequested =
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
|
||||
import { environmentService, projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { appendWithCap } from "../adapters/utils.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||
|
||||
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
||||
|
||||
|
|
@ -40,6 +41,22 @@ export function projectRoutes(db: Db) {
|
|||
const secretsSvc = secretService(db);
|
||||
const workspaceOperations = workspaceOperationService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const environmentsSvc = environmentService(db);
|
||||
|
||||
async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) {
|
||||
if (environmentId === undefined || environmentId === null) return;
|
||||
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
||||
allowedDrivers: ["local", "ssh"],
|
||||
});
|
||||
}
|
||||
|
||||
function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined {
|
||||
if (!policy || typeof policy !== "object" || !("environmentId" in policy)) {
|
||||
return undefined;
|
||||
}
|
||||
const environmentId = (policy as { environmentId?: unknown }).environmentId;
|
||||
return typeof environmentId === "string" || environmentId === null ? environmentId : undefined;
|
||||
}
|
||||
|
||||
async function resolveCompanyIdForProjectReference(req: Request) {
|
||||
const companyIdQuery = req.query.companyId;
|
||||
|
|
@ -103,6 +120,10 @@ export function projectRoutes(db: Db) {
|
|||
};
|
||||
|
||||
const { workspace, ...projectData } = req.body as CreateProjectPayload;
|
||||
await assertProjectEnvironmentSelection(
|
||||
companyId,
|
||||
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
|
||||
);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
[
|
||||
|
|
@ -165,6 +186,10 @@ export function projectRoutes(db: Db) {
|
|||
req,
|
||||
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
||||
);
|
||||
await assertProjectEnvironmentSelection(
|
||||
existing.companyId,
|
||||
readProjectPolicyEnvironmentId(body.executionWorkspacePolicy),
|
||||
);
|
||||
if (typeof body.archivedAt === "string") {
|
||||
body.archivedAt = new Date(body.archivedAt);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const CONFIG_REVISION_FIELDS = [
|
|||
"adapterType",
|
||||
"adapterConfig",
|
||||
"runtimeConfig",
|
||||
"defaultEnvironmentId",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
|
|
@ -98,6 +99,7 @@ function buildConfigSnapshot(
|
|||
adapterType: row.adapterType,
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
defaultEnvironmentId: row.defaultEnvironmentId,
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
metadata,
|
||||
};
|
||||
|
|
@ -169,6 +171,10 @@ function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$infe
|
|||
adapterType: snapshot.adapterType,
|
||||
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
||||
runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
||||
defaultEnvironmentId:
|
||||
typeof snapshot.defaultEnvironmentId === "string" || snapshot.defaultEnvironmentId === null
|
||||
? snapshot.defaultEnvironmentId
|
||||
: null,
|
||||
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
||||
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
||||
};
|
||||
|
|
|
|||
237
server/src/services/environment-config.ts
Normal file
237
server/src/services/environment-config.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type {
|
||||
Environment,
|
||||
EnvironmentDriver,
|
||||
LocalEnvironmentConfig,
|
||||
SshEnvironmentConfig,
|
||||
} from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
|
||||
const secretRefSchema = z.object({
|
||||
type: z.literal("secret_ref"),
|
||||
secretId: z.string().uuid(),
|
||||
version: z.union([z.literal("latest"), z.number().int().positive()]).optional().default("latest"),
|
||||
}).strict();
|
||||
|
||||
const sshEnvironmentConfigSchema = z.object({
|
||||
host: z.string({ required_error: "SSH environments require a host." }).trim().min(1, "SSH environments require a host."),
|
||||
port: z.coerce.number().int().min(1).max(65535).default(22),
|
||||
username: z.string({ required_error: "SSH environments require a username." }).trim().min(1, "SSH environments require a username."),
|
||||
remoteWorkspacePath: z
|
||||
.string({ required_error: "SSH environments require a remote workspace path." })
|
||||
.trim()
|
||||
.min(1, "SSH environments require a remote workspace path.")
|
||||
.refine((value) => value.startsWith("/"), "SSH remote workspace path must be absolute."),
|
||||
privateKey: z.null().optional().default(null),
|
||||
privateKeySecretRef: secretRefSchema.optional().nullable().default(null),
|
||||
knownHosts: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => (value && value.length > 0 ? value : null)),
|
||||
strictHostKeyChecking: z.boolean().optional().default(true),
|
||||
}).strict();
|
||||
|
||||
const sshEnvironmentConfigProbeSchema = sshEnvironmentConfigSchema.extend({
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => (value && value.length > 0 ? value : null)),
|
||||
}).strict();
|
||||
|
||||
const sshEnvironmentConfigPersistenceSchema = sshEnvironmentConfigProbeSchema;
|
||||
|
||||
export type ParsedEnvironmentConfig =
|
||||
| { driver: "local"; config: LocalEnvironmentConfig }
|
||||
| { driver: "ssh"; config: SshEnvironmentConfig };
|
||||
|
||||
function toErrorMessage(error: z.ZodError) {
|
||||
const first = error.issues[0];
|
||||
if (!first) return "Invalid environment config.";
|
||||
return first.message;
|
||||
}
|
||||
|
||||
function secretName(input: {
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
field: string;
|
||||
}) {
|
||||
const slug = input.environmentName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "environment";
|
||||
return `environment-${input.driver}-${slug}-${input.field}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
async function createEnvironmentSecret(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
field: string;
|
||||
value: string;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
}) {
|
||||
const created = await secretService(input.db).create(
|
||||
input.companyId,
|
||||
{
|
||||
name: secretName(input),
|
||||
provider: "local_encrypted",
|
||||
value: input.value,
|
||||
description: `Secret for ${input.environmentName} ${input.field}.`,
|
||||
},
|
||||
input.actor,
|
||||
);
|
||||
return {
|
||||
type: "secret_ref" as const,
|
||||
secretId: created.id,
|
||||
version: "latest" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEnvironmentConfig(input: {
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown> | null | undefined;
|
||||
}): Record<string, unknown> {
|
||||
if (input.driver === "local") {
|
||||
return { ...parseObject(input.config) };
|
||||
}
|
||||
|
||||
if (input.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(input.config));
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(toErrorMessage(parsed.error), {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data satisfies SshEnvironmentConfig;
|
||||
}
|
||||
|
||||
throw unprocessable(`Unsupported environment driver "${input.driver}".`);
|
||||
}
|
||||
|
||||
export function normalizeEnvironmentConfigForProbe(input: {
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown> | null | undefined;
|
||||
}): Record<string, unknown> {
|
||||
if (input.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigProbeSchema.safeParse(parseObject(input.config));
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(toErrorMessage(parsed.error), {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data satisfies SshEnvironmentConfig;
|
||||
}
|
||||
|
||||
return normalizeEnvironmentConfig(input);
|
||||
}
|
||||
|
||||
export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown> | null | undefined;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
}): Promise<Record<string, unknown>> {
|
||||
if (input.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigPersistenceSchema.safeParse(parseObject(input.config));
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(toErrorMessage(parsed.error), {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
const secrets = secretService(input.db);
|
||||
const { privateKey, ...stored } = parsed.data;
|
||||
let nextPrivateKeySecretRef = stored.privateKeySecretRef;
|
||||
if (privateKey) {
|
||||
nextPrivateKeySecretRef = await createEnvironmentSecret({
|
||||
db: input.db,
|
||||
companyId: input.companyId,
|
||||
environmentName: input.environmentName,
|
||||
driver: input.driver,
|
||||
field: "private-key",
|
||||
value: privateKey,
|
||||
actor: input.actor,
|
||||
});
|
||||
if (
|
||||
stored.privateKeySecretRef &&
|
||||
stored.privateKeySecretRef.secretId !== nextPrivateKeySecretRef.secretId
|
||||
) {
|
||||
await secrets.remove(stored.privateKeySecretRef.secretId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...stored,
|
||||
privateKey: null,
|
||||
privateKeySecretRef: nextPrivateKeySecretRef,
|
||||
} satisfies SshEnvironmentConfig;
|
||||
}
|
||||
|
||||
return normalizeEnvironmentConfig({
|
||||
driver: input.driver,
|
||||
config: input.config,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
): Promise<ParsedEnvironmentConfig> {
|
||||
const parsed = parseEnvironmentDriverConfig(environment);
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||
return {
|
||||
driver: "ssh",
|
||||
config: {
|
||||
...parsed.config,
|
||||
privateKey: await secretService(db).resolveSecretValue(
|
||||
companyId,
|
||||
parsed.config.privateKeySecretRef.secretId,
|
||||
parsed.config.privateKeySecretRef.version ?? "latest",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function readSshEnvironmentPrivateKeySecretId(
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
): string | null {
|
||||
if (environment.driver !== "ssh") return null;
|
||||
const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(environment.config));
|
||||
if (!parsed.success) return null;
|
||||
return parsed.data.privateKeySecretRef?.secretId ?? null;
|
||||
}
|
||||
|
||||
export function parseEnvironmentDriverConfig(
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
): ParsedEnvironmentConfig {
|
||||
if (environment.driver === "local") {
|
||||
return {
|
||||
driver: "local",
|
||||
config: { ...parseObject(environment.config) },
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigSchema.parse(parseObject(environment.config));
|
||||
return {
|
||||
driver: "ssh",
|
||||
config: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported environment driver "${environment.driver}".`);
|
||||
}
|
||||
77
server/src/services/environment-probe.ts
Normal file
77
server/src/services/environment-probe.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { Environment, EnvironmentProbeResult } from "@paperclipai/shared";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
||||
import {
|
||||
resolveEnvironmentDriverConfigForRuntime,
|
||||
type ParsedEnvironmentConfig,
|
||||
} from "./environment-config.js";
|
||||
import os from "node:os";
|
||||
|
||||
export async function probeEnvironment(
|
||||
db: Db,
|
||||
environment: Environment,
|
||||
options: { resolvedConfig?: ParsedEnvironmentConfig } = {},
|
||||
): Promise<EnvironmentProbeResult> {
|
||||
const parsed = options.resolvedConfig ?? await resolveEnvironmentDriverConfigForRuntime(db, environment.companyId, environment);
|
||||
|
||||
if (parsed.driver === "local") {
|
||||
return {
|
||||
ok: true,
|
||||
driver: "local",
|
||||
summary: "Local environment is available on this Paperclip host.",
|
||||
details: {
|
||||
hostname: os.hostname(),
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
driver: "ssh",
|
||||
summary: `Connected to ${parsed.config.username}@${parsed.config.host} and verified the remote workspace path.`,
|
||||
details: {
|
||||
host: parsed.config.host,
|
||||
port: parsed.config.port,
|
||||
username: parsed.config.username,
|
||||
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
||||
remoteCwd,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const stderr =
|
||||
error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string"
|
||||
? error.stderr.trim()
|
||||
: "";
|
||||
const stdout =
|
||||
error && typeof error === "object" && "stdout" in error && typeof error.stdout === "string"
|
||||
? error.stdout.trim()
|
||||
: "";
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
const message =
|
||||
stderr ||
|
||||
stdout ||
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
"SSH probe failed.";
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
driver: "ssh",
|
||||
summary: `SSH probe failed for ${parsed.config.username}@${parsed.config.host}.`,
|
||||
details: {
|
||||
host: parsed.config.host,
|
||||
port: parsed.config.port,
|
||||
username: parsed.config.username,
|
||||
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
||||
error: message,
|
||||
code,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { environmentLeases, environments } from "@paperclipai/db";
|
||||
import {
|
||||
|
|
@ -130,6 +130,7 @@ export function environmentService(db: Db) {
|
|||
})
|
||||
.onConflictDoNothing({
|
||||
target: [environments.companyId, environments.driver],
|
||||
where: sql`${environments.driver} = 'local'`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
|
@ -189,6 +190,15 @@ export function environmentService(db: Db) {
|
|||
return row ? toEnvironment(row) : null;
|
||||
},
|
||||
|
||||
remove: async (id: string): Promise<Environment | null> => {
|
||||
const row = await db
|
||||
.delete(environments)
|
||||
.where(eq(environments.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toEnvironment(row) : null;
|
||||
},
|
||||
|
||||
listLeases: async (
|
||||
environmentId: string,
|
||||
filters: {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
|||
const defaultMode = asString(parsed.defaultMode, "");
|
||||
const defaultProjectWorkspaceId =
|
||||
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
|
||||
const environmentId = typeof parsed.environmentId === "string" ? parsed.environmentId : undefined;
|
||||
const allowIssueOverride =
|
||||
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
||||
const normalizedDefaultMode = (() => {
|
||||
|
|
@ -58,6 +59,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
|||
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
|
||||
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
||||
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
|
||||
...(environmentId !== undefined ? { environmentId } : {}),
|
||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
|
|
@ -109,6 +111,7 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
|||
...(normalizedMode
|
||||
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
|
||||
: {}),
|
||||
...(typeof parsed.environmentId === "string" ? { environmentId: parsed.environmentId } : {}),
|
||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
|
|
@ -116,6 +119,28 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
workspaceConfig: { environmentId?: string | null } | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
}) {
|
||||
if (input.workspaceConfig?.environmentId !== undefined) {
|
||||
return input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.issueSettings?.environmentId !== undefined) {
|
||||
return input.issueSettings.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.projectPolicy?.environmentId !== undefined) {
|
||||
return input.projectPolicy.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.agentDefaultEnvironmentId !== null) {
|
||||
return input.agentDefaultEnvironmentId;
|
||||
}
|
||||
return input.defaultEnvironmentId;
|
||||
}
|
||||
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||
): IssueExecutionWorkspaceSettings | null {
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
|
|||
if (!raw) return null;
|
||||
|
||||
const config: ExecutionWorkspaceConfig = {
|
||||
environmentId: readNullableString(raw.environmentId),
|
||||
provisionCommand: readNullableString(raw.provisionCommand),
|
||||
teardownCommand: readNullableString(raw.teardownCommand),
|
||||
cleanupCommand: readNullableString(raw.cleanupCommand),
|
||||
|
|
@ -226,6 +227,7 @@ export function mergeExecutionWorkspaceConfig(
|
|||
): Record<string, unknown> | null {
|
||||
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
||||
const current = readExecutionWorkspaceConfig(metadata) ?? {
|
||||
environmentId: null,
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
|
|
@ -240,6 +242,7 @@ export function mergeExecutionWorkspaceConfig(
|
|||
}
|
||||
|
||||
const nextConfig: ExecutionWorkspaceConfig = {
|
||||
environmentId: patch.environmentId !== undefined ? readNullableString(patch.environmentId) : current.environmentId,
|
||||
provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand,
|
||||
teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand,
|
||||
cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand,
|
||||
|
|
@ -260,6 +263,7 @@ export function mergeExecutionWorkspaceConfig(
|
|||
|
||||
if (hasConfig) {
|
||||
nextMetadata.config = {
|
||||
environmentId: nextConfig.environmentId,
|
||||
provisionCommand: nextConfig.provisionCommand,
|
||||
teardownCommand: nextConfig.teardownCommand,
|
||||
cleanupCommand: nextConfig.cleanupCommand,
|
||||
|
|
@ -739,6 +743,37 @@ export function executionWorkspaceService(db: Db) {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
|
||||
clearEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: executionWorkspaces.id,
|
||||
metadata: executionWorkspaces.metadata,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.companyId, companyId));
|
||||
|
||||
let cleared = 0;
|
||||
const updatedAt = new Date();
|
||||
for (const row of rows) {
|
||||
const metadata = (row.metadata as Record<string, unknown> | null) ?? null;
|
||||
const config = readExecutionWorkspaceConfig(metadata);
|
||||
if (config?.environmentId !== environmentId) continue;
|
||||
|
||||
await tx
|
||||
.update(executionWorkspaces)
|
||||
.set({
|
||||
metadata: mergeExecutionWorkspaceConfig(metadata, { environmentId: null }),
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(executionWorkspaces.id, row.id));
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,19 @@ import type { Db } from "@paperclipai/db";
|
|||
import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
isEnvironmentDriverSupportedForAdapter,
|
||||
type BillingType,
|
||||
type EnvironmentLeaseStatus,
|
||||
type ExecutionWorkspace,
|
||||
type ExecutionWorkspaceConfig,
|
||||
type RunLivenessState,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
ensureSshWorkspaceReady,
|
||||
findReachablePaperclipApiUrlOverSsh,
|
||||
type SshRemoteExecutionSpec,
|
||||
} from "@paperclipai/adapter-utils/ssh";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
|
|
@ -98,8 +105,10 @@ import {
|
|||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceEnvironmentId,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import {
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
|
|
@ -322,6 +331,27 @@ function leaseReleaseStatusForRunStatus(
|
|||
return status === "failed" || status === "timed_out" ? "failed" : "released";
|
||||
}
|
||||
|
||||
function runtimeApiUrlCandidates() {
|
||||
const candidates = [
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL,
|
||||
process.env.PAPERCLIP_API_URL,
|
||||
process.env.PUBLIC_BASE_URL,
|
||||
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
const encoded = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
if (!encoded) return candidates;
|
||||
try {
|
||||
const parsed = JSON.parse(encoded);
|
||||
if (Array.isArray(parsed)) {
|
||||
candidates.push(
|
||||
...parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.warn("Ignoring invalid PAPERCLIP_RUNTIME_API_CANDIDATES_JSON");
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function applyPersistedExecutionWorkspaceConfig(input: {
|
||||
config: Record<string, unknown>;
|
||||
workspaceConfig: ExecutionWorkspaceConfig | null;
|
||||
|
|
@ -391,9 +421,19 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
|
|||
};
|
||||
}
|
||||
|
||||
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
|
||||
function buildExecutionWorkspaceConfigSnapshot(
|
||||
config: Record<string, unknown>,
|
||||
environmentId?: string | null,
|
||||
): Partial<ExecutionWorkspaceConfig> | null {
|
||||
const strategy = parseObject(config.workspaceStrategy);
|
||||
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
|
||||
// Persist the resolved environment onto the workspace so reused sessions stay on the
|
||||
// environment they were created against until the workspace itself is recreated/reset.
|
||||
const hasExplicitEnvironmentSelection = environmentId !== undefined;
|
||||
|
||||
if (hasExplicitEnvironmentSelection) {
|
||||
snapshot.environmentId = environmentId ?? null;
|
||||
}
|
||||
|
||||
if ("workspaceStrategy" in config) {
|
||||
snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null;
|
||||
|
|
@ -426,7 +466,7 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>):
|
|||
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||
return true;
|
||||
});
|
||||
return hasSnapshot ? snapshot : null;
|
||||
return hasSnapshot || hasExplicitEnvironmentSelection ? snapshot : null;
|
||||
}
|
||||
|
||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||
|
|
@ -5061,7 +5101,15 @@ export function heartbeatService(db: Db) {
|
|||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||
: persistedWorkspaceManagedConfig;
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
|
||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
workspaceConfig: existingExecutionWorkspace?.config ?? null,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
|
|
@ -5294,26 +5342,105 @@ export function heartbeatService(db: Db) {
|
|||
})(),
|
||||
};
|
||||
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
|
||||
const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const selectedEnvironment =
|
||||
selectedEnvironmentId === defaultEnvironment.id
|
||||
? defaultEnvironment
|
||||
: await environmentsSvc.getById(selectedEnvironmentId);
|
||||
if (!selectedEnvironment || selectedEnvironment.companyId !== agent.companyId) {
|
||||
throw notFound(`Environment "${selectedEnvironmentId}" not found.`);
|
||||
}
|
||||
if (selectedEnvironment.status !== "active") {
|
||||
throw conflict(`Environment "${selectedEnvironment.name}" is not active.`);
|
||||
}
|
||||
if (!isEnvironmentDriverSupportedForAdapter(agent.adapterType, selectedEnvironment.driver)) {
|
||||
throw conflict(
|
||||
`Adapter "${agent.adapterType}" does not support "${selectedEnvironment.driver}" environments.`,
|
||||
);
|
||||
}
|
||||
|
||||
const selectedEnvironmentRuntimeConfig = await resolveEnvironmentDriverConfigForRuntime(
|
||||
db,
|
||||
agent.companyId,
|
||||
selectedEnvironment,
|
||||
);
|
||||
let environmentProvider = selectedEnvironment.driver;
|
||||
let environmentProviderLeaseId: string | null = null;
|
||||
let environmentLeaseMetadata: Record<string, unknown> = {
|
||||
driver: selectedEnvironment.driver,
|
||||
executionWorkspaceMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode,
|
||||
cwd: executionWorkspace.cwd,
|
||||
};
|
||||
let executionTarget: AdapterExecutionTarget | null = null;
|
||||
let remoteExecution: SshRemoteExecutionSpec | null = null;
|
||||
|
||||
if (selectedEnvironmentRuntimeConfig.driver === "ssh") {
|
||||
const { remoteCwd } = await ensureSshWorkspaceReady(selectedEnvironmentRuntimeConfig.config);
|
||||
const paperclipApiUrl = await findReachablePaperclipApiUrlOverSsh({
|
||||
config: selectedEnvironmentRuntimeConfig.config,
|
||||
candidates: runtimeApiUrlCandidates(),
|
||||
});
|
||||
remoteExecution = {
|
||||
...selectedEnvironmentRuntimeConfig.config,
|
||||
remoteCwd,
|
||||
paperclipApiUrl,
|
||||
};
|
||||
environmentProvider = "ssh";
|
||||
environmentProviderLeaseId = `ssh://${selectedEnvironmentRuntimeConfig.config.username}@${selectedEnvironmentRuntimeConfig.config.host}:${selectedEnvironmentRuntimeConfig.config.port}${remoteCwd}`;
|
||||
environmentLeaseMetadata = {
|
||||
...environmentLeaseMetadata,
|
||||
host: selectedEnvironmentRuntimeConfig.config.host,
|
||||
port: selectedEnvironmentRuntimeConfig.config.port,
|
||||
username: selectedEnvironmentRuntimeConfig.config.username,
|
||||
remoteWorkspacePath: selectedEnvironmentRuntimeConfig.config.remoteWorkspacePath,
|
||||
remoteCwd,
|
||||
paperclipApiUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const environmentLease = await environmentsSvc.acquireLease({
|
||||
companyId: agent.companyId,
|
||||
environmentId: localEnvironment.id,
|
||||
environmentId: selectedEnvironment.id,
|
||||
executionWorkspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||
issueId: issueId ?? null,
|
||||
heartbeatRunId: run.id,
|
||||
leasePolicy: "ephemeral",
|
||||
provider: "local",
|
||||
metadata: {
|
||||
driver: "local",
|
||||
executionWorkspaceMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode,
|
||||
cwd: executionWorkspace.cwd,
|
||||
},
|
||||
provider: environmentProvider,
|
||||
providerLeaseId: environmentProviderLeaseId,
|
||||
metadata: environmentLeaseMetadata,
|
||||
});
|
||||
if (remoteExecution) {
|
||||
executionTarget = {
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
environmentId: selectedEnvironment.id,
|
||||
leaseId: environmentLease.id,
|
||||
remoteCwd: remoteExecution.remoteCwd,
|
||||
paperclipApiUrl: remoteExecution.paperclipApiUrl,
|
||||
spec: remoteExecution,
|
||||
};
|
||||
}
|
||||
context.paperclipEnvironment = {
|
||||
id: localEnvironment.id,
|
||||
name: localEnvironment.name,
|
||||
driver: localEnvironment.driver,
|
||||
id: selectedEnvironment.id,
|
||||
name: selectedEnvironment.name,
|
||||
driver: selectedEnvironment.driver,
|
||||
leaseId: environmentLease.id,
|
||||
...(typeof environmentLease.metadata?.remoteCwd === "string"
|
||||
? {
|
||||
remoteCwd: environmentLease.metadata.remoteCwd,
|
||||
host:
|
||||
typeof environmentLease.metadata?.host === "string"
|
||||
? environmentLease.metadata.host
|
||||
: undefined,
|
||||
port:
|
||||
typeof environmentLease.metadata?.port === "number"
|
||||
? environmentLease.metadata.port
|
||||
: undefined,
|
||||
username:
|
||||
typeof environmentLease.metadata?.username === "string"
|
||||
? environmentLease.metadata.username
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
|
|
@ -5325,8 +5452,8 @@ export function heartbeatService(db: Db) {
|
|||
entityType: "environment_lease",
|
||||
entityId: environmentLease.id,
|
||||
details: {
|
||||
environmentId: localEnvironment.id,
|
||||
driver: localEnvironment.driver,
|
||||
environmentId: selectedEnvironment.id,
|
||||
driver: selectedEnvironment.driver,
|
||||
leasePolicy: environmentLease.leasePolicy,
|
||||
provider: environmentLease.provider,
|
||||
executionWorkspaceId: environmentLease.executionWorkspaceId,
|
||||
|
|
@ -5592,6 +5719,10 @@ export function heartbeatService(db: Db) {
|
|||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
|
|
|
|||
|
|
@ -38,11 +38,13 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
|
|||
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
|
@ -2191,6 +2192,36 @@ export function issueService(db: Db) {
|
|||
return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx);
|
||||
},
|
||||
|
||||
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId));
|
||||
|
||||
let cleared = 0;
|
||||
for (const row of rows) {
|
||||
const settings = parseIssueExecutionWorkspaceSettings(row.executionWorkspaceSettings);
|
||||
if (settings?.environmentId !== environmentId) continue;
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionWorkspaceSettings: {
|
||||
...settings,
|
||||
environmentId: null,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, row.id));
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
const attachmentAssetIds = await tx
|
||||
|
|
|
|||
|
|
@ -523,6 +523,36 @@ export function projectService(db: Db) {
|
|||
return enriched ?? null;
|
||||
},
|
||||
|
||||
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
|
||||
let cleared = 0;
|
||||
for (const row of rows) {
|
||||
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
|
||||
if (policy?.environmentId !== environmentId) continue;
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
executionWorkspacePolicy: {
|
||||
...policy,
|
||||
environmentId: null,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(projects.id, row.id));
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
.delete(projects)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue