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:
Devin Foley 2026-04-23 19:15:22 -07:00 committed by GitHub
parent f98c348e2b
commit e4995bbb1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 10162 additions and 315 deletions

View file

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

View file

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

View file

@ -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([
{

View file

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

View file

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

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

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

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

File diff suppressed because it is too large Load diff

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

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

View file

@ -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";

View file

@ -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",

View file

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

View file

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