Add sandbox environment support (#4415)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The environment/runtime layer decides where agent work executes and
how the control plane reaches those runtimes.
> - Today Paperclip can run locally and over SSH, but sandboxed
execution needs a first-class environment model instead of one-off
adapter behavior.
> - We also want sandbox providers to be pluggable so the core does not
hardcode every provider implementation.
> - This branch adds the Sandbox environment path, the provider
contract, and a deterministic fake provider plugin.
> - That required synchronized changes across shared contracts, plugin
SDK surfaces, server runtime orchestration, and the UI
environment/workspace flows.
> - The result is that sandbox execution becomes a core control-plane
capability while keeping provider implementations extensible and
testable.

## What Changed

- Added sandbox runtime support to the environment execution path,
including runtime URL discovery, sandbox execution targeting,
orchestration, and heartbeat integration.
- Added plugin-provider support for sandbox environments so providers
can be supplied via plugins instead of hardcoded server logic.
- Added the fake sandbox provider plugin with deterministic behavior
suitable for local and automated testing.
- Updated shared types, validators, plugin protocol definitions, and SDK
helpers to carry sandbox provider and workspace-runtime contracts across
package boundaries.
- Updated server routes and services so companies can create sandbox
environments, select them for work, and execute work through the sandbox
runtime path.
- Updated the UI environment and workspace surfaces to expose sandbox
environment configuration and selection.
- Added test coverage for sandbox runtime behavior, provider seams,
environment route guards, orchestration, and the fake provider plugin.

## Verification

- Ran locally before the final fixture-only scrub:
  - `pnpm -r typecheck`
  - `pnpm test:run`
  - `pnpm build`
- Ran locally after the final scrub amend:
  - `pnpm vitest run server/src/__tests__/runtime-api.test.ts`
- Reviewer spot checks:
  - create a sandbox environment backed by the fake provider plugin
  - run work through that environment
- confirm sandbox provider execution does not inherit host secrets
implicitly

## Risks

- This touches shared contracts, plugin SDK plumbing, server runtime
orchestration, and UI environment/workspace flows, so regressions would
likely show up as cross-layer mismatches rather than isolated type
errors.
- Runtime URL discovery and sandbox callback selection are sensitive to
host/bind configuration; if that logic is wrong, sandbox-backed
callbacks may fail even when execution succeeds.
- The fake provider plugin is intentionally deterministic and
test-oriented; future providers may expose capability gaps that this
branch does not yet cover.

## Model Used

- OpenAI Codex coding agent on a GPT-5-class backend in the
Paperclip/Codex harness. Exact backend model ID is not exposed
in-session. Tool-assisted workflow with shell execution, file editing,
git history inspection, and local test execution.

## 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-24 12:15:53 -07:00 committed by GitHub
parent 641eb44949
commit 70679a3321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 10469 additions and 1498 deletions

View file

@ -161,6 +161,10 @@ function registerModuleMocks() {
secretService: () => mockSecretService,
}));
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.doMock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => mockAgentInstructionsService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
@ -270,6 +274,7 @@ describe.sequential("agent permission routes", () => {
vi.doUnmock("../services/issue-approvals.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/secrets.js");
vi.doUnmock("../services/environments.js");
vi.doUnmock("../services/workspace-operations.js");
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../routes/agents.js");

View file

@ -105,14 +105,103 @@ describe("environment config helpers", () => {
});
});
it("rejects unsupported environment drivers", () => {
expect(() =>
normalizeEnvironmentConfig({
driver: "sandbox" as any,
config: {
provider: "fake",
it("normalizes sandbox config into its canonical stored shape", () => {
const config = normalizeEnvironmentConfig({
driver: "sandbox",
config: {
provider: "fake",
image: " ubuntu:24.04 ",
},
});
expect(config).toEqual({
provider: "fake",
image: "ubuntu:24.04",
reuseLease: false,
});
});
it("parses a persisted sandbox environment into a typed driver config", () => {
const parsed = parseEnvironmentDriverConfig({
driver: "sandbox",
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
});
expect(parsed).toEqual({
driver: "sandbox",
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
});
});
it("normalizes plugin-backed sandbox provider config without server provider changes", () => {
const config = normalizeEnvironmentConfig({
driver: "sandbox",
config: {
provider: "fake-plugin",
image: " fake:test ",
timeoutMs: "120000",
reuseLease: true,
customFlag: "kept",
},
});
expect(config).toEqual({
provider: "fake-plugin",
image: " fake:test ",
timeoutMs: 120000,
reuseLease: true,
customFlag: "kept",
});
});
it("parses a persisted plugin-backed sandbox environment into a typed driver config", () => {
const parsed = parseEnvironmentDriverConfig({
driver: "sandbox",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 300000,
reuseLease: true,
},
});
expect(parsed).toEqual({
driver: "sandbox",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 300000,
reuseLease: true,
},
});
});
it("normalizes plugin environment config into its canonical stored shape", () => {
const config = normalizeEnvironmentConfig({
driver: "plugin",
config: {
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: {
template: "base",
},
}),
).toThrow(HttpError);
},
});
expect(config).toEqual({
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: {
template: "base",
},
});
});
});

View file

@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveEnvironmentDriverConfigForRuntime } = vi.hoisted(() => ({
mockResolveEnvironmentDriverConfigForRuntime: vi.fn(),
}));
vi.mock("../services/environment-config.js", () => ({
resolveEnvironmentDriverConfigForRuntime: mockResolveEnvironmentDriverConfigForRuntime,
}));
import {
DEFAULT_SANDBOX_REMOTE_CWD,
resolveEnvironmentExecutionTarget,
} from "../services/environment-execution-target.js";
describe("resolveEnvironmentExecutionTarget", () => {
beforeEach(() => {
mockResolveEnvironmentDriverConfigForRuntime.mockReset();
});
it("uses a bounded default cwd for sandbox targets when lease metadata omits remoteCwd", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: {
provider: "fake-plugin",
reuseLease: false,
timeoutMs: 30_000,
},
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: {
id: "env-1",
driver: "sandbox",
config: {
provider: "fake-plugin",
},
},
leaseId: "lease-1",
leaseMetadata: {},
lease: null,
environmentRuntime: null,
});
expect(target).toMatchObject({
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
leaseId: "lease-1",
environmentId: "env-1",
timeoutMs: 30_000,
});
});
});

View file

@ -1,16 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn());
const mockProbePluginEnvironmentDriver = vi.hoisted(() => vi.fn());
const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/adapter-utils/ssh", () => ({
ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady,
}));
vi.mock("../services/plugin-environment-driver.js", () => ({
probePluginEnvironmentDriver: mockProbePluginEnvironmentDriver,
probePluginSandboxProviderDriver: mockProbePluginSandboxProviderDriver,
}));
import { probeEnvironment } from "../services/environment-probe.ts";
describe("probeEnvironment", () => {
beforeEach(() => {
mockEnsureSshWorkspaceReady.mockReset();
mockProbePluginEnvironmentDriver.mockReset();
mockProbePluginSandboxProviderDriver.mockReset();
});
it("reports local environments as immediately available", async () => {
@ -75,6 +84,123 @@ describe("probeEnvironment", () => {
expect(mockEnsureSshWorkspaceReady).toHaveBeenCalledTimes(1);
});
it("reports fake sandbox environments as ready without external calls", async () => {
const result = await probeEnvironment({} as any, {
id: "env-sandbox",
companyId: "company-1",
name: "Fake Sandbox",
description: null,
driver: "sandbox",
status: "active",
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result).toEqual({
ok: true,
driver: "sandbox",
summary: "Fake sandbox provider is ready for image ubuntu:24.04.",
details: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
});
expect(mockEnsureSshWorkspaceReady).not.toHaveBeenCalled();
});
it("routes plugin-backed sandbox provider probes through plugin workers", async () => {
mockProbePluginSandboxProviderDriver.mockResolvedValue({
ok: true,
driver: "sandbox",
summary: "Fake plugin probe passed.",
details: {
provider: "fake-plugin",
metadata: { ready: true },
},
});
const workerManager = {} as any;
const result = await probeEnvironment({} as any, {
id: "env-sandbox-plugin",
companyId: "company-1",
name: "Fake Plugin Sandbox",
description: null,
driver: "sandbox",
status: "active",
config: {
provider: "fake-plugin",
image: "fake:test",
reuseLease: false,
},
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
}, { pluginWorkerManager: workerManager });
expect(result.ok).toBe(true);
expect(mockProbePluginSandboxProviderDriver).toHaveBeenCalledWith({
db: expect.anything(),
workerManager,
companyId: "company-1",
environmentId: "env-sandbox-plugin",
provider: "fake-plugin",
config: {
provider: "fake-plugin",
image: "fake:test",
reuseLease: false,
},
});
});
it("routes plugin environment probes through the plugin worker host", async () => {
mockProbePluginEnvironmentDriver.mockResolvedValue({
ok: true,
driver: "plugin",
summary: "Plugin probe passed.",
details: {
metadata: { ready: true },
},
});
const workerManager = {} as any;
const result = await probeEnvironment({} as any, {
id: "env-plugin",
companyId: "company-1",
name: "Plugin Sandbox",
description: null,
driver: "plugin",
status: "active",
config: {
pluginKey: "acme.environments",
driverKey: "sandbox",
driverConfig: { template: "base" },
},
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
}, { pluginWorkerManager: workerManager });
expect(result.ok).toBe(true);
expect(mockProbePluginEnvironmentDriver).toHaveBeenCalledWith({
db: expect.anything(),
workerManager,
companyId: "company-1",
environmentId: "env-plugin",
config: {
pluginKey: "acme.environments",
driverKey: "sandbox",
driverConfig: { template: "base" },
},
});
});
it("captures SSH probe failures without throwing", async () => {
mockEnsureSshWorkspaceReady.mockRejectedValue(
Object.assign(new Error("Permission denied"), {

View file

@ -14,39 +14,38 @@ const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockEnvironmentService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
listLeases: vi.fn(),
getLeaseById: vi.fn(),
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
clearEnvironmentSelection: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
clearExecutionWorkspaceEnvironmentSelection: vi.fn(),
}));
const mockProjectService = vi.hoisted(() => ({
clearExecutionWorkspaceEnvironmentSelection: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockProbeEnvironment = vi.hoisted(() => vi.fn());
const mockSecretService = vi.hoisted(() => ({
create: vi.fn(),
remove: vi.fn(),
resolveSecretValue: vi.fn(),
}));
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
environmentService: () => mockEnvironmentService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
issueService: () => mockIssueService,
environmentService: () => mockEnvironmentService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
}));
@ -59,6 +58,19 @@ vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
vi.mock("../services/plugin-environment-driver.js", () => ({
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
}));
function createEnvironment() {
const now = new Date("2026-04-16T05:00:00.000Z");
return {
@ -81,8 +93,14 @@ let currentActor: Record<string, unknown> = {
userId: "user-1",
source: "local_implicit",
};
function createApp(actor: Record<string, unknown>) {
const routeOptions: Record<string, unknown> = {};
function createApp(actor: Record<string, unknown>, options: Record<string, unknown> = {}) {
currentActor = actor;
for (const key of Object.keys(routeOptions)) {
delete routeOptions[key];
}
Object.assign(routeOptions, options);
if (server) return server;
const app = express();
@ -91,7 +109,7 @@ function createApp(actor: Record<string, unknown>) {
(req as any).actor = currentActor;
next();
});
app.use("/api", environmentRoutes({} as any));
app.use("/api", environmentRoutes({} as any, routeOptions as any));
app.use(errorHandler);
server = app.listen(0);
return server;
@ -113,24 +131,25 @@ describe("environment routes", () => {
mockAccessService.canUser.mockReset();
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
mockIssueService.getById.mockReset();
mockProjectService.getById.mockReset();
mockEnvironmentService.list.mockReset();
mockEnvironmentService.getById.mockReset();
mockEnvironmentService.create.mockReset();
mockEnvironmentService.update.mockReset();
mockEnvironmentService.remove.mockReset();
mockEnvironmentService.listLeases.mockReset();
mockEnvironmentService.getLeaseById.mockReset();
mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset();
mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
mockLogActivity.mockReset();
mockProbeEnvironment.mockReset();
mockSecretService.create.mockReset();
mockSecretService.remove.mockReset();
mockSecretService.resolveSecretValue.mockReset();
mockSecretService.create.mockResolvedValue({
id: "11111111-1111-1111-1111-111111111111",
});
mockValidatePluginEnvironmentDriverConfig.mockReset();
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
mockListReadyPluginEnvironmentDrivers.mockReset();
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]);
});
it("lists company-scoped environments", async () => {
@ -151,7 +170,7 @@ describe("environment routes", () => {
});
});
it("returns environment capabilities for the company", async () => {
it("returns provider capabilities for the company", async () => {
const app = createApp({
type: "board",
userId: "user-1",
@ -162,8 +181,8 @@ describe("environment routes", () => {
expect(res.status).toBe(200);
expect(res.body.drivers.ssh).toBe("supported");
expect(res.body.drivers.local).toBe("supported");
expect(res.body.sandboxProviders).toBeUndefined();
expect(res.body.sandboxProviders.fake.supportsRunExecution).toBe(false);
expect(res.body.sandboxProviders).not.toHaveProperty("fake-plugin");
});
it("redacts config and metadata for unprivileged agent list reads", async () => {
@ -197,31 +216,6 @@ describe("environment routes", () => {
]);
});
it("redacts config and metadata for board members without environments:manage", async () => {
mockEnvironmentService.list.mockResolvedValue([createEnvironment()]);
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app).get("/api/companies/company-1/environments");
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({
id: "env-1",
config: {},
metadata: null,
configRedacted: true,
metadataRedacted: true,
}),
]);
});
it("returns full config for privileged environment readers", async () => {
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
mockAgentService.getById.mockResolvedValue({
@ -278,31 +272,6 @@ describe("environment routes", () => {
);
});
it("redacts config and metadata for board detail reads without environments:manage", async () => {
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app).get("/api/environments/env-1");
expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.objectContaining({
id: "env-1",
config: {},
metadata: null,
configRedacted: true,
metadataRedacted: true,
}),
);
});
it("creates an environment and logs activity", async () => {
const environment = createEnvironment();
mockAgentService.getById.mockResolvedValue({
@ -525,6 +494,131 @@ describe("environment routes", () => {
);
});
it("rejects persisted fake sandbox environments", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "Fake Sandbox",
driver: "sandbox",
config: {
provider: "fake",
image: " ubuntu:24.04 ",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toContain("reserved for internal probes");
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
});
it("creates a sandbox environment with normalized Fake plugin config", async () => {
const environment = {
...createEnvironment(),
id: "env-sandbox-fake-plugin",
name: "Fake plugin Sandbox",
driver: "sandbox" as const,
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 450000,
reuseLease: true,
},
};
mockEnvironmentService.create.mockResolvedValue(environment);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "Fake plugin Sandbox",
driver: "sandbox",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: "450000",
reuseLease: true,
},
});
expect(res.status).toBe(201);
expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", {
name: "Fake plugin Sandbox",
driver: "sandbox",
status: "active",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 450000,
reuseLease: true,
},
});
expect(mockSecretService.create).not.toHaveBeenCalled();
});
it("validates plugin environment config through the plugin driver host", async () => {
const environment = {
...createEnvironment(),
id: "env-plugin",
name: "Plugin Sandbox",
driver: "plugin" as const,
config: {
pluginKey: "acme.environments",
driverKey: "sandbox",
driverConfig: {
template: "normalized",
},
},
};
mockValidatePluginEnvironmentDriverConfig.mockResolvedValue(environment.config);
mockEnvironmentService.create.mockResolvedValue(environment);
const pluginWorkerManager = {};
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
}, { pluginWorkerManager });
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "Plugin Sandbox",
driver: "plugin",
config: {
pluginKey: "acme.environments",
driverKey: "sandbox",
driverConfig: {
template: "base",
},
},
});
expect(res.status).toBe(201);
expect(mockValidatePluginEnvironmentDriverConfig).toHaveBeenCalledWith({
db: expect.anything(),
workerManager: pluginWorkerManager,
config: {
pluginKey: "acme.environments",
driverKey: "sandbox",
driverConfig: {
template: "base",
},
},
});
expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
config: environment.config,
}));
});
it("rejects unprivileged agent mutations for shared environments", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
@ -570,7 +664,7 @@ describe("environment routes", () => {
lastUsedAt: new Date("2026-04-16T05:05:00.000Z"),
expiresAt: null,
releasedAt: null,
metadata: { provider: "local" },
metadata: { provider: "fake" },
createdAt: new Date("2026-04-16T05:00:00.000Z"),
updatedAt: new Date("2026-04-16T05:05:00.000Z"),
},
@ -589,24 +683,6 @@ describe("environment routes", () => {
});
});
it("rejects environment lease listing for board users without environments:manage", async () => {
const environment = createEnvironment();
mockEnvironmentService.getById.mockResolvedValue(environment);
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp({
type: "board",
userId: "user-1",
source: "dashboard_session",
companyIds: ["company-1"],
});
const res = await request(app).get(`/api/environments/${environment.id}/leases`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("environments:manage");
expect(mockEnvironmentService.listLeases).not.toHaveBeenCalled();
});
it("returns a single lease after company access is confirmed", async () => {
mockEnvironmentService.getLeaseById.mockResolvedValue({
id: "lease-1",
@ -642,42 +718,6 @@ describe("environment routes", () => {
expect(mockEnvironmentService.getLeaseById).toHaveBeenCalledWith("lease-1");
});
it("rejects single-lease reads for board users without environments:manage", async () => {
mockEnvironmentService.getLeaseById.mockResolvedValue({
id: "lease-1",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: "workspace-1",
issueId: null,
heartbeatRunId: "run-1",
status: "active",
leasePolicy: "ephemeral",
provider: "ssh",
providerLeaseId: "ssh://ssh-user@example.test:22/workspace",
acquiredAt: new Date("2026-04-16T05:00:00.000Z"),
lastUsedAt: new Date("2026-04-16T05:05:00.000Z"),
expiresAt: null,
releasedAt: null,
failureReason: null,
cleanupStatus: null,
metadata: { remoteCwd: "/workspace" },
createdAt: new Date("2026-04-16T05:00:00.000Z"),
updatedAt: new Date("2026-04-16T05:05:00.000Z"),
});
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp({
type: "board",
userId: "user-1",
source: "dashboard_session",
companyIds: ["company-1"],
});
const res = await request(app).get("/api/environment-leases/lease-1");
expect(res.status).toBe(403);
expect(res.body.error).toContain("environments:manage");
});
it("rejects cross-company agent access", async () => {
mockEnvironmentService.list.mockResolvedValue([]);
const app = createApp({
@ -730,7 +770,7 @@ describe("environment routes", () => {
changedFields: ["config", "metadata", "status"],
status: "archived",
configChanged: true,
configTopLevelKeyCount: 3,
configTopLevelKeyCount: expect.any(Number),
metadataChanged: true,
metadataTopLevelKeyCount: 1,
},
@ -740,134 +780,6 @@ describe("environment routes", () => {
expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("do-not-log");
});
it("preserves the stored SSH private key secret ref on partial config updates", async () => {
const environment = {
...createEnvironment(),
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "ssh.example.test",
port: 22,
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,
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.update.mockResolvedValue({
...environment,
config: {
...environment.config,
port: 2222,
},
});
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch(`/api/environments/${environment.id}`)
.send({
config: {
port: 2222,
},
});
expect(res.status).toBe(200);
expect(mockEnvironmentService.update).toHaveBeenCalledWith(
environment.id,
expect.objectContaining({
config: expect.objectContaining({
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",
},
}),
}),
);
expect(mockSecretService.create).not.toHaveBeenCalled();
expect(mockSecretService.remove).not.toHaveBeenCalled();
});
it("replaces the stored SSH private key secret when a new private key is provided", async () => {
const environment = {
...createEnvironment(),
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: "22222222-2222-2222-2222-222222222222",
version: "latest",
},
knownHosts: null,
strictHostKeyChecking: true,
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.update.mockResolvedValue(environment);
mockSecretService.create.mockResolvedValue({
id: "33333333-3333-3333-3333-333333333333",
});
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch(`/api/environments/${environment.id}`)
.send({
config: {
privateKey: " replacement-private-key ",
},
});
expect(res.status).toBe(200);
expect(mockEnvironmentService.update).toHaveBeenCalledWith(
environment.id,
expect.objectContaining({
config: expect.objectContaining({
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: "33333333-3333-3333-3333-333333333333",
version: "latest",
},
}),
}),
);
expect(mockSecretService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "local_encrypted",
value: "replacement-private-key",
}),
expect.any(Object),
);
expect(mockSecretService.remove).toHaveBeenCalledWith("22222222-2222-2222-2222-222222222222");
});
it("resets config instead of inheriting SSH secrets when switching to local without an explicit config", async () => {
const environment = {
...createEnvironment(),
@ -929,6 +841,29 @@ describe("environment routes", () => {
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("rejects switching an environment to the built-in fake sandbox provider", async () => {
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch("/api/environments/env-1")
.send({
driver: "sandbox",
config: {
provider: "fake",
image: "ubuntu:24.04",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toContain("reserved for internal probes");
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("returns 404 when patching a missing environment", async () => {
mockEnvironmentService.getById.mockResolvedValue(null);
const app = createApp({
@ -946,137 +881,6 @@ describe("environment routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("deletes an environment and logs the removal", async () => {
const environment = createEnvironment();
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.remove.mockResolvedValue(environment);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app).delete(`/api/environments/${environment.id}`);
expect(res.status).toBe(200);
expect(mockEnvironmentService.remove).toHaveBeenCalledWith(environment.id);
expect(mockExecutionWorkspaceService.clearEnvironmentSelection).toHaveBeenCalledWith(
environment.companyId,
environment.id,
);
expect(mockIssueService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith(
environment.companyId,
environment.id,
);
expect(mockProjectService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith(
environment.companyId,
environment.id,
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "environment.deleted",
entityId: environment.id,
details: {
name: environment.name,
driver: environment.driver,
status: environment.status,
},
}),
);
});
it("deletes the stored SSH private-key secret after removing the environment", async () => {
const environment = {
...createEnvironment(),
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: "11111111-1111-4111-8111-111111111111",
version: "latest",
},
knownHosts: null,
strictHostKeyChecking: true,
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.remove.mockResolvedValue(environment);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app).delete(`/api/environments/${environment.id}`);
expect(res.status).toBe(200);
expect(mockEnvironmentService.remove).toHaveBeenCalledWith(environment.id);
expect(mockSecretService.remove).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111");
expect(mockEnvironmentService.remove.mock.invocationCallOrder[0]).toBeLessThan(
mockSecretService.remove.mock.invocationCallOrder[0],
);
expect(mockExecutionWorkspaceService.clearEnvironmentSelection).toHaveBeenCalledWith(
environment.companyId,
environment.id,
);
expect(mockIssueService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith(
environment.companyId,
environment.id,
);
expect(mockProjectService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith(
environment.companyId,
environment.id,
);
});
it("skips SSH secret cleanup gracefully when stored SSH config no longer parses", async () => {
const environment = {
...createEnvironment(),
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "",
username: "ssh-user",
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.remove.mockResolvedValue(environment);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app).delete(`/api/environments/${environment.id}`);
expect(res.status).toBe(200);
expect(mockEnvironmentService.remove).toHaveBeenCalledWith(environment.id);
expect(mockSecretService.remove).not.toHaveBeenCalled();
});
it("returns 404 when deleting a missing environment", async () => {
mockEnvironmentService.getById.mockResolvedValue(null);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app).delete("/api/environments/missing-env");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Environment not found");
expect(mockEnvironmentService.remove).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("probes an SSH environment and logs the result", async () => {
const environment = {
...createEnvironment(),
@ -1114,7 +918,9 @@ describe("environment routes", () => {
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment);
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
pluginWorkerManager: undefined,
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@ -1130,12 +936,66 @@ describe("environment routes", () => {
);
});
it("probes unsaved SSH config without persisting secrets", async () => {
it("probes a sandbox environment and logs the result", async () => {
const environment = {
...createEnvironment(),
id: "env-sandbox",
name: "Fake Sandbox",
driver: "sandbox" as const,
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockProbeEnvironment.mockResolvedValue({
ok: true,
driver: "ssh",
summary: "Connected to ssh-user@ssh.example.test and verified the remote workspace path.",
details: { remoteCwd: "/srv/paperclip/workspace" },
driver: "sandbox",
summary: "Fake sandbox provider is ready for image ubuntu:24.04.",
details: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
});
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
runId: "run-1",
});
const res = await request(app)
.post(`/api/environments/${environment.id}/probe`)
.send({});
expect(res.status).toBe(200);
expect(res.body.driver).toBe("sandbox");
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
pluginWorkerManager: undefined,
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "environment.probed",
entityType: "environment",
entityId: environment.id,
details: expect.objectContaining({
driver: "sandbox",
ok: true,
}),
}),
);
});
it("probes unsaved provider config without persisting secrets", async () => {
mockProbeEnvironment.mockResolvedValue({
ok: true,
driver: "sandbox",
summary: "Fake plugin sandbox provider is ready.",
details: { provider: "fake-plugin" },
});
const app = createApp({
type: "board",
@ -1147,14 +1007,14 @@ describe("environment routes", () => {
const res = await request(app)
.post("/api/companies/company-1/environments/probe-config")
.send({
name: "Draft SSH",
description: "Probe this SSH target before saving it.",
driver: "ssh",
name: "Draft Fake plugin",
driver: "sandbox",
config: {
host: "ssh.example.test",
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: "unsaved-test-key",
provider: "fake-plugin",
template: "base",
apiKey: "unsaved-test-key",
timeoutMs: 300000,
reuseLease: true,
},
});
@ -1165,14 +1025,15 @@ describe("environment routes", () => {
expect.anything(),
expect.objectContaining({
id: "unsaved",
driver: "ssh",
driver: "sandbox",
config: expect.objectContaining({
privateKey: "unsaved-test-key",
apiKey: "unsaved-test-key",
}),
}),
expect.objectContaining({
pluginWorkerManager: undefined,
resolvedConfig: expect.objectContaining({
driver: "ssh",
driver: "sandbox",
}),
}),
);

View file

@ -0,0 +1,350 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Hoisted mocks — must be declared before any imports that reference them
// ---------------------------------------------------------------------------
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
const mockAdapterExecutionTargetToRemoteSpec = vi.hoisted(() => vi.fn());
const mockBuildWorkspaceRealizationRequest = vi.hoisted(() => vi.fn());
const mockUpdateLeaseMetadata = vi.hoisted(() => vi.fn());
const mockUpdateExecutionWorkspace = vi.hoisted(() => vi.fn());
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/environment-execution-target.js", () => ({
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
resolveEnvironmentExecutionTransport: vi.fn().mockResolvedValue(null),
}));
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
adapterExecutionTargetToRemoteSpec: mockAdapterExecutionTargetToRemoteSpec,
}));
vi.mock("../services/workspace-realization.js", () => ({
buildWorkspaceRealizationRequest: mockBuildWorkspaceRealizationRequest,
}));
vi.mock("../services/environments.js", () => ({
environmentService: vi.fn(() => ({
ensureLocalEnvironment: vi.fn(),
getById: vi.fn(),
acquireLease: vi.fn(),
releaseLease: vi.fn(),
updateLeaseMetadata: mockUpdateLeaseMetadata,
})),
}));
vi.mock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: vi.fn(() => ({
update: mockUpdateExecutionWorkspace,
})),
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
// ---------------------------------------------------------------------------
// Imports after mocks
// ---------------------------------------------------------------------------
import {
environmentRunOrchestrator,
EnvironmentRunError,
} from "../services/environment-run-orchestrator.ts";
import type { Environment, EnvironmentLease, ExecutionWorkspace } from "@paperclipai/shared";
import type { RealizedExecutionWorkspace } from "../services/workspace-runtime.ts";
import type { EnvironmentRuntimeService } from "../services/environment-runtime.ts";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeEnvironment(driver: string = "local"): Environment {
return {
id: "env-1",
companyId: "company-1",
name: "Test Environment",
description: null,
driver: driver as Environment["driver"],
status: "active",
config: {},
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function makeLease(overrides: Partial<EnvironmentLease> = {}): EnvironmentLease {
return {
id: "lease-1",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
status: "active",
leasePolicy: "ephemeral",
provider: "local",
providerLeaseId: null,
acquiredAt: new Date(),
lastUsedAt: new Date(),
expiresAt: null,
releasedAt: null,
failureReason: null,
cleanupStatus: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeExecutionWorkspace(cwd: string = "/workspace/project"): RealizedExecutionWorkspace {
return {
baseCwd: "/workspace",
source: "project_primary",
projectId: "project-1",
workspaceId: "ws-1",
repoUrl: null,
repoRef: null,
strategy: "project_primary",
cwd,
branchName: null,
worktreePath: null,
warnings: [],
created: false,
};
}
function makePersistedExecutionWorkspace(
overrides: Partial<ExecutionWorkspace> = {},
): ExecutionWorkspace {
return {
id: "ew-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: null,
mode: "standard",
strategyType: "project_primary",
name: "workspace",
status: "open",
cwd: "/workspace/project",
repoUrl: null,
baseRef: null,
branchName: null,
providerType: "local",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date(),
openedAt: new Date(),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeRealizeInput(overrides: {
environment?: Environment;
lease?: EnvironmentLease;
persistedExecutionWorkspace?: ExecutionWorkspace | null;
} = {}): Parameters<ReturnType<typeof environmentRunOrchestrator>["realizeForRun"]>[0] {
return {
environment: overrides.environment ?? makeEnvironment("local"),
lease: overrides.lease ?? makeLease(),
adapterType: "claude_local",
companyId: "company-1",
issueId: null,
heartbeatRunId: "run-1",
executionWorkspace: makeExecutionWorkspace(),
effectiveExecutionWorkspaceMode: null,
persistedExecutionWorkspace: overrides.persistedExecutionWorkspace !== undefined
? overrides.persistedExecutionWorkspace
: null,
};
}
function makeMockRuntime(overrides: Partial<EnvironmentRuntimeService> = {}): EnvironmentRuntimeService {
return {
acquireRunLease: vi.fn(),
releaseRunLeases: vi.fn(),
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/workspace/project",
metadata: {
workspaceRealization: {
version: 1,
driver: "local",
cwd: "/workspace/project",
},
},
}),
...overrides,
} as unknown as EnvironmentRuntimeService;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("environmentRunOrchestrator — realizeForRun", () => {
const mockDb = {} as any;
beforeEach(() => {
vi.clearAllMocks();
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/workspace/project",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: null,
},
});
mockAdapterExecutionTargetToRemoteSpec.mockReturnValue({
kind: "local",
environmentId: "env-1",
leaseId: "lease-1",
});
mockUpdateLeaseMetadata.mockResolvedValue(null);
mockUpdateExecutionWorkspace.mockResolvedValue(null);
mockLogActivity.mockResolvedValue(undefined);
});
it("happy path: returns lease, executionTarget, and remoteExecution on successful realization", async () => {
const executionTarget = { kind: "local", environmentId: "env-1", leaseId: "lease-1" };
const remoteExecution = { kind: "local", environmentId: "env-1", leaseId: "lease-1" };
mockResolveEnvironmentExecutionTarget.mockResolvedValue(executionTarget);
mockAdapterExecutionTargetToRemoteSpec.mockReturnValue(remoteExecution);
const runtime = makeMockRuntime();
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
const result = await orchestrator.realizeForRun(makeRealizeInput());
expect(result.lease).toBeDefined();
expect(result.executionTarget).toEqual(executionTarget);
expect(result.remoteExecution).toEqual(remoteExecution);
expect(result.workspaceRealization).toEqual(
expect.objectContaining({ version: 1, driver: "local" }),
);
expect(runtime.realizeWorkspace).toHaveBeenCalledOnce();
expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce();
});
it("realization failure: runtime.realizeWorkspace throws → EnvironmentRunError with code workspace_realization_failed", async () => {
const runtime = makeMockRuntime({
realizeWorkspace: vi.fn().mockRejectedValue(new Error("sandbox unreachable")),
});
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await expect(orchestrator.realizeForRun(makeRealizeInput())).rejects.toSatisfy(
(err: unknown) =>
err instanceof EnvironmentRunError &&
err.code === "workspace_realization_failed" &&
err.environmentId === "env-1" &&
err.driver === "local",
);
expect(mockResolveEnvironmentExecutionTarget).not.toHaveBeenCalled();
});
it("target resolution failure: resolveEnvironmentExecutionTarget throws → EnvironmentRunError with code transport_resolution_failed", async () => {
mockResolveEnvironmentExecutionTarget.mockRejectedValue(new Error("network error"));
const runtime = makeMockRuntime();
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await expect(orchestrator.realizeForRun(makeRealizeInput())).rejects.toSatisfy(
(err: unknown) =>
err instanceof EnvironmentRunError &&
err.code === "transport_resolution_failed" &&
err.environmentId === "env-1",
);
});
it("non-sandbox driver skips workspace realization and goes straight to target resolution", async () => {
const environment = makeEnvironment("plugin" as Environment["driver"]);
const executionTarget = null;
mockResolveEnvironmentExecutionTarget.mockResolvedValue(executionTarget);
const runtime = makeMockRuntime();
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
const result = await orchestrator.realizeForRun(
makeRealizeInput({ environment }),
);
expect(runtime.realizeWorkspace).not.toHaveBeenCalled();
expect(result.workspaceRealization).toEqual({});
expect(result.executionTarget).toBeNull();
});
it("persisted metadata is updated on lease and execution workspace after realization", async () => {
const persistedExecutionWorkspace = makePersistedExecutionWorkspace();
const updatedLease = makeLease({
metadata: { workspaceRealization: { version: 1, driver: "local", cwd: "/workspace/project" } },
});
const updatedEw = { ...persistedExecutionWorkspace, metadata: { workspaceRealizationRequest: {}, workspaceRealization: {} } };
mockUpdateLeaseMetadata.mockResolvedValue(updatedLease);
mockUpdateExecutionWorkspace.mockResolvedValue(updatedEw);
mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "local", environmentId: "env-1", leaseId: "lease-1" });
const runtime = makeMockRuntime();
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
const result = await orchestrator.realizeForRun(
makeRealizeInput({ persistedExecutionWorkspace }),
);
// Lease metadata should have been updated with workspaceRealization
expect(mockUpdateLeaseMetadata).toHaveBeenCalledOnce();
expect(mockUpdateLeaseMetadata).toHaveBeenCalledWith(
"lease-1",
expect.objectContaining({ workspaceRealization: expect.any(Object) }),
);
// Execution workspace metadata should have been updated
expect(mockUpdateExecutionWorkspace).toHaveBeenCalledOnce();
expect(mockUpdateExecutionWorkspace).toHaveBeenCalledWith(
"ew-1",
expect.objectContaining({
metadata: expect.objectContaining({
workspaceRealizationRequest: expect.any(Object),
workspaceRealization: expect.any(Object),
}),
}),
);
// The returned lease should reflect the updated value
expect(result.lease).toEqual(updatedLease);
expect(result.persistedExecutionWorkspace).toEqual(updatedEw);
});
});

View file

@ -0,0 +1,319 @@
import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import {
buildSshEnvLabFixtureConfig,
getSshEnvLabSupport,
startSshEnvLabFixture,
stopSshEnvLabFixture,
type SshEnvironmentConfig,
} from "@paperclipai/adapter-utils/ssh";
import {
agents,
companies,
companySecretVersions,
companySecrets,
createDb,
environmentLeases,
environments,
heartbeatRuns,
} from "@paperclipai/db";
import type { Environment } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { secretService } from "../services/secrets.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const sshFixtureSupport = await getSshEnvLabSupport();
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping environment runtime driver contract tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
interface RuntimeContractCase {
name: string;
driver: string;
config: Record<string, unknown>;
setup?: () => Promise<() => Promise<void>>;
expectLease: (lease: {
providerLeaseId: string | null;
metadata: Record<string, unknown> | null;
}, environment: Environment) => void;
}
describeEmbeddedPostgres("environment runtime driver contract", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
const fixtureRoots: string[] = [];
const servers: Server[] = [];
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract");
stopDb = started.stop;
db = createDb(started.connectionString);
});
afterEach(async () => {
for (const server of servers.splice(0)) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
while (fixtureRoots.length > 0) {
const root = fixtureRoots.pop();
if (!root) continue;
await stopSshEnvLabFixture(path.join(root, "state.json")).catch(() => undefined);
await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
await db.delete(environmentLeases);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(environments);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
});
async function seedEnvironment(input: {
driver: string;
config: Record<string, unknown>;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const environmentId = randomUUID();
const runId = randomUUID();
const now = new Date();
let config = input.config;
await db.insert(companies).values({
id: companyId,
name: "Acme",
status: "active",
createdAt: now,
updatedAt: now,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Contract Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: now,
updatedAt: now,
});
if (typeof config.privateKey === "string" && config.privateKey.length > 0) {
const secret = await secretService(db).create(companyId, {
name: `environment-contract-private-key-${randomUUID()}`,
provider: "local_encrypted",
value: config.privateKey,
});
config = {
...config,
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: secret.id,
version: "latest",
},
};
}
await db.insert(environments).values({
id: environmentId,
companyId,
name: `${input.driver} contract`,
driver: input.driver,
status: "active",
config,
createdAt: now,
updatedAt: now,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
createdAt: now,
updatedAt: now,
});
return {
companyId,
issueId: null,
runId,
environment: {
id: environmentId,
companyId,
name: `${input.driver} contract`,
description: null,
driver: input.driver,
status: "active",
config,
metadata: null,
createdAt: now,
updatedAt: now,
} as Environment,
};
}
async function startHealthServer() {
const server = createServer((req, res) => {
if (req.url === "/api/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
servers.push(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected health server to listen on a TCP port.");
}
return `http://127.0.0.1:${address.port}`;
}
async function runContract(testCase: RuntimeContractCase) {
const cleanup = await testCase.setup?.();
try {
const runtime = environmentRuntimeService(db);
const { companyId, environment, issueId, runId } = await seedEnvironment({
driver: testCase.driver,
config: testCase.config,
});
const acquired = await runtime.acquireRunLease({
companyId,
environment,
issueId,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(acquired.environment.id).toBe(environment.id);
expect(acquired.lease.companyId).toBe(companyId);
expect(acquired.lease.environmentId).toBe(environment.id);
expect(acquired.lease.issueId).toBeNull();
expect(acquired.lease.heartbeatRunId).toBe(runId);
expect(acquired.lease.status).toBe("active");
expect(acquired.leaseContext).toEqual({
executionWorkspaceId: null,
executionWorkspaceMode: null,
});
expect(acquired.lease.metadata).toMatchObject({
driver: testCase.driver,
executionWorkspaceMode: null,
});
testCase.expectLease(acquired.lease, environment);
const released = await runtime.releaseRunLeases(runId);
expect(released).toHaveLength(1);
expect(released[0]?.environment.id).toBe(environment.id);
expect(released[0]?.lease.id).toBe(acquired.lease.id);
expect(released[0]?.lease.status).toBe("released");
const activeRows = await db
.select()
.from(environmentLeases)
.where(eq(environmentLeases.status, "active"));
expect(activeRows).toHaveLength(0);
await expect(runtime.releaseRunLeases(runId)).resolves.toEqual([]);
} finally {
await cleanup?.();
}
}
const contractCases: RuntimeContractCase[] = [
{
name: "local",
driver: "local",
config: {},
expectLease: (lease) => {
expect(lease.providerLeaseId).toBeNull();
},
},
{
name: "fake sandbox",
driver: "sandbox",
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: false,
},
expectLease: (lease) => {
expect(lease.providerLeaseId).toMatch(/^sandbox:\/\/fake\/[0-9a-f-]+\/[0-9a-f-]+$/);
expect(lease.metadata).toMatchObject({
provider: "fake",
image: "ubuntu:24.04",
reuseLease: false,
});
},
},
];
for (const testCase of contractCases) {
it(`${testCase.name} satisfies the acquire/release host contract`, async () => {
await runContract(testCase);
});
}
it("SSH satisfies the acquire/release host contract", async () => {
if (!sshFixtureSupport.supported) {
console.warn(`Skipping SSH driver contract test: ${sshFixtureSupport.reason ?? "unsupported environment"}`);
return;
}
const fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-env-runtime-contract-ssh-"));
fixtureRoots.push(fixtureRoot);
const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") });
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
const runtimeApiUrl = await startHealthServer();
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
await runContract({
name: "ssh",
driver: "ssh",
config: sshConfig as SshEnvironmentConfig as unknown as Record<string, unknown>,
expectLease: (lease) => {
expect(lease.providerLeaseId).toContain(`ssh://${sshConfig.username}@${sshConfig.host}:${sshConfig.port}`);
expect(lease.metadata).toMatchObject({
host: sshConfig.host,
port: sshConfig.port,
username: sshConfig.username,
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
remoteCwd: sshConfig.remoteWorkspacePath,
paperclipApiUrl: runtimeApiUrl,
});
},
setup: async () => async () => {
if (previousCandidates === undefined) {
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
} else {
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
}
},
});
});
});

View file

@ -0,0 +1,943 @@
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
buildSshEnvLabFixtureConfig,
getSshEnvLabSupport,
startSshEnvLabFixture,
stopSshEnvLabFixture,
} from "@paperclipai/adapter-utils/ssh";
import {
agents,
companies,
companySecretVersions,
companySecrets,
createDb,
environmentLeases,
environments,
heartbeatRuns,
plugins,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { environmentRuntimeService, findReusableSandboxLeaseId } from "../services/environment-runtime.ts";
import { environmentService } from "../services/environments.ts";
import { secretService } from "../services/secrets.ts";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const sshFixtureSupport = await getSshEnvLabSupport();
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres environment runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("findReusableSandboxLeaseId", () => {
it("matches reusable plugin-backed sandbox leases by provider", () => {
const selected = findReusableSandboxLeaseId({
config: {
provider: "fake-plugin",
image: "template-b",
timeoutMs: 300000,
reuseLease: true,
},
leases: [
{
providerLeaseId: "sandbox-template-a",
metadata: {
provider: "fake-plugin",
image: "template-a",
reuseLease: true,
},
},
{
providerLeaseId: "sandbox-template-b",
metadata: {
provider: "fake-plugin",
image: "template-b",
reuseLease: true,
},
},
],
});
expect(selected).toBe("sandbox-template-a");
});
it("requires image identity for reusable fake sandbox leases", () => {
const selected = findReusableSandboxLeaseId({
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
leases: [
{
providerLeaseId: "sandbox-image-a",
metadata: {
provider: "fake",
image: "debian:12",
reuseLease: true,
},
},
{
providerLeaseId: "sandbox-image-b",
metadata: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
},
],
});
expect(selected).toBe("sandbox-image-b");
});
});
describeEmbeddedPostgres("environmentRuntimeService", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
let runtime!: ReturnType<typeof environmentRuntimeService>;
const fixtureRoots: string[] = [];
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime");
stopDb = started.stop;
db = createDb(started.connectionString);
runtime = environmentRuntimeService(db);
});
afterEach(async () => {
while (fixtureRoots.length > 0) {
const root = fixtureRoots.pop();
if (!root) continue;
await stopSshEnvLabFixture(path.join(root, "state.json")).catch(() => undefined);
await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
await db.delete(environmentLeases);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(environments);
await db.delete(plugins);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
});
async function seedEnvironment(input: {
driver?: string;
name?: string;
status?: "active" | "disabled";
config?: Record<string, unknown>;
} = {}) {
const companyId = randomUUID();
const agentId = randomUUID();
const environmentId = randomUUID();
const runId = randomUUID();
let config = input.config ?? {};
await db.insert(companies).values({
id: companyId,
name: "Acme",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
if (typeof config.privateKey === "string" && config.privateKey.length > 0) {
const secret = await secretService(db).create(companyId, {
name: `environment-runtime-private-key-${randomUUID()}`,
provider: "local_encrypted",
value: config.privateKey,
});
config = {
...config,
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: secret.id,
version: "latest",
},
};
}
await db.insert(environments).values({
id: environmentId,
companyId,
name: input.name ?? "Local",
driver: input.driver ?? "local",
status: input.status ?? "active",
config,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
createdAt: new Date(),
updatedAt: new Date(),
});
return {
companyId,
environment: {
id: environmentId,
companyId,
name: input.name ?? "Local",
description: null,
driver: input.driver ?? "local",
status: input.status ?? "active",
config,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
} as const,
runId,
};
}
it("acquires and releases a local run lease through the runtime seam", async () => {
const { companyId, environment, runId } = await seedEnvironment();
const acquired = await runtime.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(acquired.lease.status).toBe("active");
expect(acquired.lease.metadata).toMatchObject({
driver: "local",
executionWorkspaceMode: null,
});
expect(acquired.leaseContext).toEqual({
executionWorkspaceId: null,
executionWorkspaceMode: null,
});
const released = await runtime.releaseRunLeases(runId);
expect(released).toHaveLength(1);
expect(released[0]?.environment.driver).toBe("local");
expect(released[0]?.lease.status).toBe("released");
const rows = await db
.select()
.from(environmentLeases)
.where(eq(environmentLeases.id, acquired.lease.id));
expect(rows[0]?.status).toBe("released");
});
it("allows projectless runs through the runtime seam", async () => {
const { companyId, environment, runId } = await seedEnvironment();
const acquired = await runtime.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(acquired.lease.executionWorkspaceId).toBeNull();
expect(acquired.leaseContext.executionWorkspaceId).toBeNull();
expect(acquired.leaseContext.executionWorkspaceMode).toBeNull();
});
it("rejects truly unsupported drivers before acquiring a lease", async () => {
const { companyId, environment, runId } = await seedEnvironment({
driver: "ssh",
name: "Fixture SSH",
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
});
const runtimeWithoutSsh = environmentRuntimeService(db, {
drivers: [
{
driver: "local",
acquireRunLease: async () => {
throw new Error("should not acquire");
},
releaseRunLease: async () => null,
},
],
});
await expect(
runtimeWithoutSsh.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
}),
).rejects.toThrow('Environment driver "ssh" is not registered in the environment runtime yet.');
const rows = await db.select().from(environmentLeases);
expect(rows).toHaveLength(0);
});
it("acquires and releases an SSH run lease through the runtime seam", async () => {
if (!sshFixtureSupport.supported) {
console.warn(
`Skipping SSH runtime fixture test: ${sshFixtureSupport.reason ?? "unsupported environment"}`,
);
return;
}
const fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-environment-runtime-ssh-"));
fixtureRoots.push(fixtureRoot);
const statePath = path.join(fixtureRoot, "state.json");
const fixture = await startSshEnvLabFixture({ statePath });
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
const healthServer = createServer((req, res) => {
if (req.url === "/api/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve, reject) => {
healthServer.once("error", reject);
healthServer.listen(0, "127.0.0.1", () => resolve());
});
const address = healthServer.address();
if (!address || typeof address === "string") {
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
throw new Error("Expected the test health server to listen on a TCP port.");
}
const runtimeApiUrl = `http://127.0.0.1:${address.port}`;
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
const { companyId, environment, runId } = await seedEnvironment({
driver: "ssh",
name: "Fixture SSH",
config: sshConfig,
});
try {
const acquired = await runtime.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(acquired.lease.status).toBe("active");
expect(acquired.lease.providerLeaseId).toContain(`ssh://${sshConfig.username}@${sshConfig.host}:${sshConfig.port}`);
expect(acquired.lease.metadata).toMatchObject({
driver: "ssh",
host: sshConfig.host,
port: sshConfig.port,
username: sshConfig.username,
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
remoteCwd: sshConfig.remoteWorkspacePath,
paperclipApiUrl: runtimeApiUrl,
});
const released = await runtime.releaseRunLeases(runId);
expect(released).toHaveLength(1);
expect(released[0]?.environment.driver).toBe("ssh");
expect(released[0]?.lease.status).toBe("released");
} finally {
if (previousCandidates === undefined) {
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
} else {
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
}
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
}
});
it("acquires and releases a fake sandbox run lease through the runtime seam", async () => {
const { companyId, environment, runId } = await seedEnvironment({
driver: "sandbox",
name: "Fake Sandbox",
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
});
const acquired = await runtime.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(acquired.lease.status).toBe("active");
expect(acquired.lease.providerLeaseId).toBe(`sandbox://fake/${environment.id}`);
expect(acquired.lease.metadata).toMatchObject({
driver: "sandbox",
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
});
const released = await runtime.releaseRunLeases(runId);
expect(released).toHaveLength(1);
expect(released[0]?.environment.driver).toBe("sandbox");
expect(released[0]?.lease.status).toBe("released");
});
it("uses plugin-backed sandbox config for execute and release", async () => {
const pluginId = randomUUID();
const { companyId, environment: baseEnvironment, runId } = await seedEnvironment();
const fakePluginConfig = {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 1234,
reuseLease: false,
};
const environment = {
...baseEnvironment,
name: "Fake Plugin Sandbox",
driver: "sandbox",
config: fakePluginConfig,
};
await environmentService(db).update(environment.id, {
driver: "sandbox",
name: environment.name,
config: fakePluginConfig,
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.fake-plugin-sandbox-provider",
packageName: "@paperclipai/plugin-fake-sandbox",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "paperclip.fake-plugin-sandbox-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "Fake Plugin Sandbox Provider",
description: "Test fake plugin provider",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake Plugin",
configSchema: { type: "object" },
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string, params: any) => {
expect(params.config).toEqual(expect.objectContaining(fakePluginConfig));
if (method === "environmentAcquireLease") {
return {
providerLeaseId: "sandbox-1",
metadata: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 1234,
reuseLease: false,
remoteCwd: "/workspace",
},
};
}
if (method === "environmentExecute") {
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
};
}
if (method === "environmentReleaseLease") {
expect(params.config).toEqual(fakePluginConfig);
expect(params.config).not.toHaveProperty("driver");
expect(params.config).not.toHaveProperty("executionWorkspaceMode");
expect(params.config).not.toHaveProperty("pluginId");
expect(params.config).not.toHaveProperty("pluginKey");
expect(params.config).not.toHaveProperty("providerMetadata");
expect(params.config).not.toHaveProperty("sandboxProviderPlugin");
return undefined;
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const acquired = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
const executed = await runtimeWithPlugin.execute({
environment,
lease: acquired.lease,
command: "printf",
args: ["ok"],
cwd: "/workspace",
env: {},
timeoutMs: 1000,
});
await environmentService(db).update(environment.id, {
driver: "local",
config: {},
});
const released = await runtimeWithPlugin.releaseRunLeases(runId);
expect(executed.stdout).toBe("ok\n");
expect(released).toHaveLength(1);
expect(released[0]?.lease.status).toBe("released");
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything());
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything());
});
it("releases a sandbox run lease from metadata after the environment config changes", async () => {
const { companyId, environment, runId } = await seedEnvironment({
driver: "sandbox",
name: "Fake Sandbox",
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
});
const acquired = await runtime.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
await environmentService(db).update(environment.id, {
driver: "local",
config: {},
});
const released = await runtime.releaseRunLeases(runId);
expect(released).toHaveLength(1);
expect(released[0]?.lease.id).toBe(acquired.lease.id);
expect(released[0]?.lease.status).toBe("released");
});
it("delegates plugin environment leases through the plugin worker manager", async () => {
const pluginId = randomUUID();
const expiresAt = new Date(Date.now() + 60_000).toISOString();
const workerManager = {
isRunning: vi.fn(() => true),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: "plugin-lease-1",
expiresAt,
metadata: {
driver: "local",
pluginId: "provider-plugin-id",
pluginKey: "provider.plugin",
driverKey: "provider-driver",
executionWorkspaceMode: "provider-mode",
provider: "test-provider",
remoteCwd: "/workspace",
},
};
}
return undefined;
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, {
pluginWorkerManager: workerManager,
});
const { companyId, environment, runId } = await seedEnvironment({
driver: "plugin",
name: "Plugin Fake plugin",
config: {
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: {
template: "base",
},
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Environments",
description: "Test plugin environment driver",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
displayName: "Fake plugin",
configSchema: { type: "object" },
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
const acquired = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
config: { template: "base" },
runId,
workspaceMode: undefined,
});
expect(acquired.lease.providerLeaseId).toBe("plugin-lease-1");
expect(acquired.lease.expiresAt?.toISOString()).toBe(expiresAt);
expect(acquired.lease.metadata).toMatchObject({
driver: "plugin",
pluginId,
pluginKey: "acme.environments",
driverKey: "fake-plugin",
executionWorkspaceMode: null,
providerMetadata: {
driver: "local",
pluginId: "provider-plugin-id",
pluginKey: "provider.plugin",
driverKey: "provider-driver",
executionWorkspaceMode: "provider-mode",
provider: "test-provider",
remoteCwd: "/workspace",
},
});
await environmentService(db).update(environment.id, {
driver: "local",
config: {},
});
const released = await runtimeWithPlugin.releaseRunLeases(runId);
expect(released).toHaveLength(1);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
config: {},
providerLeaseId: "plugin-lease-1",
leaseMetadata: expect.objectContaining({
driver: "plugin",
pluginId,
providerMetadata: expect.objectContaining({
driver: "local",
}),
}),
});
expect(released[0]?.lease.status).toBe("released");
});
it("delegates the full plugin environment lifecycle through the worker manager", async () => {
const pluginId = randomUUID();
const workerManager = {
isRunning: vi.fn(() => true),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: "plugin-lease-full",
metadata: {
remoteCwd: "/workspace",
},
};
}
if (method === "environmentResumeLease") {
return {
providerLeaseId: "plugin-lease-full",
metadata: {
resumed: true,
},
};
}
if (method === "environmentRealizeWorkspace") {
return {
cwd: "/workspace/project",
metadata: {
realized: true,
},
};
}
if (method === "environmentExecute") {
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
metadata: {
commandId: "cmd-1",
},
};
}
return undefined;
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, {
pluginWorkerManager: workerManager,
});
const { companyId, environment, runId } = await seedEnvironment({
driver: "plugin",
name: "Plugin Full Lifecycle",
config: {
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: {
template: "base",
},
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Environments",
description: "Test plugin environment driver",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
displayName: "Fake plugin",
configSchema: { type: "object" },
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
const acquired = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
const resumed = await runtimeWithPlugin.resumeRunLease({
environment,
lease: acquired.lease,
});
const realized = await runtimeWithPlugin.realizeWorkspace({
environment,
lease: acquired.lease,
workspace: {
localPath: "/tmp/project",
mode: "ephemeral",
},
});
const executed = await runtimeWithPlugin.execute({
environment,
lease: acquired.lease,
command: "echo",
args: ["ok"],
cwd: realized.cwd,
env: { FOO: "bar" },
stdin: "",
timeoutMs: 1000,
});
const destroyed = await runtimeWithPlugin.destroyRunLease({
environment,
lease: acquired.lease,
});
expect(resumed).toMatchObject({
providerLeaseId: "plugin-lease-full",
metadata: {
resumed: true,
},
});
expect(realized).toEqual({
cwd: "/workspace/project",
metadata: {
realized: true,
},
});
expect(executed).toMatchObject({
exitCode: 0,
timedOut: false,
stdout: "ok\n",
});
expect(destroyed?.status).toBe("failed");
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentResumeLease", {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
config: { template: "base" },
providerLeaseId: "plugin-lease-full",
leaseMetadata: expect.objectContaining({
driver: "plugin",
pluginId,
}),
});
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentRealizeWorkspace", expect.objectContaining({
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
config: { template: "base" },
workspace: {
localPath: "/tmp/project",
mode: "ephemeral",
},
}));
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.objectContaining({
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
command: "echo",
args: ["ok"],
cwd: "/workspace/project",
env: { FOO: "bar" },
}));
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
config: { template: "base" },
providerLeaseId: "plugin-lease-full",
leaseMetadata: expect.objectContaining({
driver: "plugin",
pluginId,
}),
});
});
it("releases with the driver captured on the lease even if the environment driver changes later", async () => {
const { companyId, environment, runId } = await seedEnvironment();
const environmentsSvc = environmentService(db);
const localRelease = vi.fn(async ({ lease, status }: { lease: { id: string }; status: "released" | "expired" | "failed" }) =>
await environmentsSvc.releaseLease(lease.id, status)
);
const sshRelease = vi.fn(async () => {
throw new Error("ssh release should not be called");
});
const runtimeWithSpies = environmentRuntimeService(db, {
drivers: [
{
driver: "local",
acquireRunLease: async (input) => await environmentsSvc.acquireLease({
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
metadata: {
driver: input.environment.driver,
executionWorkspaceMode: input.executionWorkspaceMode,
},
}),
releaseRunLease: localRelease,
},
{
driver: "ssh",
acquireRunLease: async () => {
throw new Error("ssh acquire should not be called");
},
releaseRunLease: sshRelease,
},
],
});
const acquired = await runtimeWithSpies.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
await environmentsSvc.update(environment.id, { driver: "ssh" });
const released = await runtimeWithSpies.releaseRunLeases(runId);
expect(released).toHaveLength(1);
expect(localRelease).toHaveBeenCalledTimes(1);
expect(sshRelease).not.toHaveBeenCalled();
expect(acquired.lease.metadata?.driver).toBe("local");
});
});

View file

@ -18,7 +18,6 @@ const mockProjectService = vi.hoisted(() => ({
const mockIssueService = vi.hoisted(() => ({
create: vi.fn(),
createChild: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
getByIdentifier: vi.fn(),
@ -29,10 +28,22 @@ const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockReferenceSummary = vi.hoisted(() => ({
inbound: [],
outbound: [],
documentSources: [],
const mockIssueReferenceService = vi.hoisted(() => ({
deleteDocumentSource: vi.fn(async () => undefined),
diffIssueReferenceSummary: vi.fn(() => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
})),
emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })),
listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })),
syncComment: vi.fn(async () => undefined),
syncDocument: vi.fn(async () => undefined),
syncIssue: vi.fn(async () => undefined),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: Record<string, unknown>) => env),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@ -41,10 +52,7 @@ 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),
}),
issueReferenceService: () => mockIssueReferenceService,
logActivity: mockLogActivity,
workspaceOperationService: () => ({}),
accessService: () => ({
@ -67,35 +75,19 @@ vi.mock("../services/index.js", () => ({
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/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/issue-assignment-wakeup.js", () => ({
queueIssueAssignmentWakeup: vi.fn(),
}));
@ -133,7 +125,7 @@ function createIssueApp() {
return issueServer;
}
const sshEnvironmentId = "11111111-1111-4111-8111-111111111111";
const sandboxEnvironmentId = "11111111-1111-4111-8111-111111111111";
async function closeServer(server: Server | null) {
if (!server) return;
@ -162,26 +154,33 @@ describe.sequential("execution environment route guards", () => {
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();
mockIssueReferenceService.deleteDocumentSource.mockClear();
mockIssueReferenceService.diffIssueReferenceSummary.mockClear();
mockIssueReferenceService.emptySummary.mockClear();
mockIssueReferenceService.listIssueReferenceSummary.mockClear();
mockIssueReferenceService.syncComment.mockClear();
mockIssueReferenceService.syncDocument.mockClear();
mockIssueReferenceService.syncIssue.mockClear();
mockSecretService.normalizeEnvBindingsForPersistence.mockClear();
mockLogActivity.mockReset();
});
it("accepts SSH environments on project create", async () => {
it("accepts sandbox environments on project create", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: sshEnvironmentId,
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "ssh",
config: {},
driver: "sandbox",
config: { provider: "fake-plugin" },
});
mockProjectService.create.mockResolvedValue({
id: "project-1",
companyId: "company-1",
name: "SSH Project",
name: "Sandboxed Project",
status: "backlog",
});
const app = createProjectApp();
@ -189,10 +188,10 @@ describe.sequential("execution environment route guards", () => {
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({
name: "SSH Project",
name: "Sandboxed Project",
executionWorkspacePolicy: {
enabled: true,
environmentId: sshEnvironmentId,
environmentId: sandboxEnvironmentId,
},
});
@ -200,24 +199,24 @@ describe.sequential("execution environment route guards", () => {
expect(mockProjectService.create).toHaveBeenCalled();
});
it("accepts SSH environments on project update", async () => {
it("accepts sandbox environments on project update", async () => {
mockProjectService.getById.mockResolvedValue({
id: "project-1",
companyId: "company-1",
name: "SSH Project",
name: "Sandboxed Project",
status: "backlog",
archivedAt: null,
});
mockEnvironmentService.getById.mockResolvedValue({
id: sshEnvironmentId,
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "ssh",
config: {},
driver: "sandbox",
config: { provider: "fake-plugin" },
});
mockProjectService.update.mockResolvedValue({
id: "project-1",
companyId: "company-1",
name: "SSH Project",
name: "Sandboxed Project",
status: "backlog",
});
const app = createProjectApp();
@ -227,7 +226,7 @@ describe.sequential("execution environment route guards", () => {
.send({
executionWorkspacePolicy: {
enabled: true,
environmentId: sshEnvironmentId,
environmentId: sandboxEnvironmentId,
},
});
@ -235,120 +234,17 @@ describe.sequential("execution environment route guards", () => {
expect(mockProjectService.update).toHaveBeenCalled();
});
it("rejects cross-company environments on project create", async () => {
it("accepts sandbox environments on issue 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",
id: sandboxEnvironmentId,
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: {},
driver: "sandbox",
config: { provider: "fake-plugin" },
});
mockIssueService.create.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
title: "SSH Issue",
title: "Sandboxed Issue",
status: "todo",
identifier: "PAPA-999",
});
@ -357,9 +253,9 @@ describe.sequential("execution environment route guards", () => {
const res = await request(app)
.post("/api/companies/company-1/issues")
.send({
title: "SSH Issue",
title: "Sandboxed Issue",
executionWorkspaceSettings: {
environmentId: sshEnvironmentId,
environmentId: sandboxEnvironmentId,
},
});
@ -369,7 +265,7 @@ describe.sequential("execution environment route guards", () => {
it("rejects unsupported driver environments on issue create", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: sshEnvironmentId,
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "unsupported_driver",
config: {},
@ -381,7 +277,7 @@ describe.sequential("execution environment route guards", () => {
.send({
title: "Unsupported Driver Issue",
executionWorkspaceSettings: {
environmentId: sshEnvironmentId,
environmentId: sandboxEnvironmentId,
},
});
@ -390,71 +286,59 @@ describe.sequential("execution environment route guards", () => {
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",
});
it("rejects built-in fake sandbox environments on issue create", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: sshEnvironmentId,
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "unsupported_driver",
config: {},
driver: "sandbox",
config: { provider: "fake" },
});
const app = createIssueApp();
const res = await request(app)
.post("/api/issues/parent-1/children")
.post("/api/companies/company-1/issues")
.send({
title: "Unsupported Child",
title: "Fake Sandbox Issue",
executionWorkspaceSettings: {
environmentId: sshEnvironmentId,
environmentId: sandboxEnvironmentId,
},
});
expect(res.status).toBe(422);
expect(res.body.error).toContain('Environment driver "unsupported_driver" is not allowed here');
expect(mockIssueService.createChild).not.toHaveBeenCalled();
expect(res.body.error).toContain('Environment sandbox provider "fake" is not allowed here');
expect(mockIssueService.create).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",
});
it("accepts plugin-backed sandbox environments on issue create", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: sshEnvironmentId,
companyId: "company-2",
driver: "ssh",
config: {},
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "sandbox",
config: { provider: "fake-plugin" },
});
mockIssueService.create.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
title: "Plugin Sandbox Issue",
status: "todo",
identifier: "PAPA-999",
});
const app = createIssueApp();
const res = await request(app)
.post("/api/issues/parent-1/children")
.post("/api/companies/company-1/issues")
.send({
title: "Cross Company Child",
title: "Plugin Sandbox Issue",
executionWorkspaceSettings: {
environmentId: sshEnvironmentId,
environmentId: sandboxEnvironmentId,
},
});
expect(res.status).toBe(422);
expect(res.body.error).toBe("Environment not found.");
expect(mockIssueService.createChild).not.toHaveBeenCalled();
expect(res.status).not.toBe(422);
expect(mockIssueService.create).toHaveBeenCalled();
});
it("accepts SSH environments on issue update", async () => {
it("accepts sandbox environments on issue update", async () => {
mockIssueService.getById.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
@ -465,10 +349,10 @@ describe.sequential("execution environment route guards", () => {
identifier: "PAPA-999",
});
mockEnvironmentService.getById.mockResolvedValue({
id: sshEnvironmentId,
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "ssh",
config: {},
driver: "sandbox",
config: { provider: "fake-plugin" },
});
mockIssueService.update.mockResolvedValue({
id: "issue-1",
@ -482,7 +366,7 @@ describe.sequential("execution environment route guards", () => {
.patch("/api/issues/issue-1")
.send({
executionWorkspaceSettings: {
environmentId: sshEnvironmentId,
environmentId: sandboxEnvironmentId,
},
});

View file

@ -0,0 +1,237 @@
import { describe, expect, it } from "vitest";
import {
createEnvironmentTestHarness,
createFakeEnvironmentDriver,
filterEnvironmentEvents,
assertEnvironmentEventOrder,
assertLeaseLifecycle,
assertWorkspaceRealizationLifecycle,
assertExecutionLifecycle,
assertEnvironmentError,
} from "@paperclipai/plugin-sdk/testing";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
const FAKE_MANIFEST: PaperclipPluginManifestV1 = {
id: "test-env-plugin",
apiVersion: 1,
version: "0.1.0",
displayName: "Test Environment Plugin",
description: "Test fixture",
author: "test",
categories: ["connector"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "./worker.js" },
environmentDrivers: [{ driverKey: "fake", displayName: "Fake Driver" }],
};
const BASE_PARAMS = {
driverKey: "fake",
companyId: "co-1",
environmentId: "env-1",
config: {},
};
describe("environment test harness", () => {
it("records lifecycle events through a full acquire → realize → execute → release cycle", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
expect(lease.providerLeaseId).toBe("fake-lease-1");
await harness.realizeWorkspace({
...BASE_PARAMS,
lease,
workspace: { localPath: "/tmp/test" },
});
const execResult = await harness.execute({
...BASE_PARAMS,
lease,
command: "echo",
args: ["hello"],
});
expect(execResult.exitCode).toBe(0);
expect(execResult.stdout).toContain("echo hello");
await harness.releaseLease({
...BASE_PARAMS,
providerLeaseId: lease.providerLeaseId,
});
expect(harness.environmentEvents).toHaveLength(4);
assertEnvironmentEventOrder(harness.environmentEvents, [
"acquireLease",
"realizeWorkspace",
"execute",
"releaseLease",
]);
});
it("records validateConfig and probe events", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const validation = await harness.validateConfig({
driverKey: "fake",
config: { host: "test" },
});
expect(validation.ok).toBe(true);
const probe = await harness.probe(BASE_PARAMS);
expect(probe.ok).toBe(true);
expect(filterEnvironmentEvents(harness.environmentEvents, "validateConfig")).toHaveLength(1);
expect(filterEnvironmentEvents(harness.environmentEvents, "probe")).toHaveLength(1);
});
it("supports probe failure injection", async () => {
const driver = createFakeEnvironmentDriver({ probeFailure: true });
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const probe = await harness.probe(BASE_PARAMS);
expect(probe.ok).toBe(false);
});
it("supports acquire failure injection and records errors", async () => {
const driver = createFakeEnvironmentDriver({ acquireFailure: "No capacity" });
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
await expect(harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" })).rejects.toThrow("No capacity");
const errorEvent = assertEnvironmentError(harness.environmentEvents, "acquireLease");
expect(errorEvent.error).toBe("No capacity");
});
it("supports execute failure injection", async () => {
const driver = createFakeEnvironmentDriver({ executeFailure: true });
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
const result = await harness.execute({
...BASE_PARAMS,
lease,
command: "failing-cmd",
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Simulated execution failure");
});
it("supports lease resume", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
const resumed = await harness.resumeLease({
...BASE_PARAMS,
providerLeaseId: lease.providerLeaseId!,
});
expect(resumed.metadata).toHaveProperty("resumed", true);
});
it("resume throws for unknown lease", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
await expect(
harness.resumeLease({ ...BASE_PARAMS, providerLeaseId: "nonexistent" }),
).rejects.toThrow("not found");
});
it("supports destroyLease", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
await harness.destroyLease({
...BASE_PARAMS,
providerLeaseId: lease.providerLeaseId,
});
assertLeaseLifecycle(harness.environmentEvents, "env-1");
});
it("assertLeaseLifecycle throws when acquire is missing", () => {
expect(() => assertLeaseLifecycle([], "env-1")).toThrow("No acquireLease event");
});
it("assertWorkspaceRealizationLifecycle validates workspace between acquire and release", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
await harness.realizeWorkspace({
...BASE_PARAMS,
lease,
workspace: { localPath: "/tmp/ws" },
});
await harness.releaseLease({ ...BASE_PARAMS, providerLeaseId: lease.providerLeaseId });
const realize = assertWorkspaceRealizationLifecycle(harness.environmentEvents, "env-1");
expect(realize.type).toBe("realizeWorkspace");
});
it("assertExecutionLifecycle validates execute within lease bounds", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: driver,
});
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
await harness.execute({ ...BASE_PARAMS, lease, command: "ls" });
await harness.execute({ ...BASE_PARAMS, lease, command: "pwd" });
await harness.releaseLease({ ...BASE_PARAMS, providerLeaseId: lease.providerLeaseId });
const execs = assertExecutionLifecycle(harness.environmentEvents, "env-1");
expect(execs).toHaveLength(2);
});
it("throws when driver does not implement a required hook", async () => {
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
environmentDriver: { driverKey: "bare" },
});
await expect(harness.probe(BASE_PARAMS)).rejects.toThrow("does not implement onProbe");
assertEnvironmentError(harness.environmentEvents, "probe");
});
it("base harness methods remain functional", async () => {
const driver = createFakeEnvironmentDriver();
const harness = createEnvironmentTestHarness({
manifest: FAKE_MANIFEST,
capabilities: [...FAKE_MANIFEST.capabilities, "events.subscribe", "plugin.state.read", "plugin.state.write"],
environmentDriver: driver,
});
harness.ctx.logger.info("test");
expect(harness.logs).toHaveLength(1);
});
});

View file

@ -0,0 +1,223 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
companies,
createDb,
environments,
plugins,
projects,
projectWorkspaces,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts";
const adapterExecute = vi.hoisted(() => vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "session-1" },
sessionDisplayId: "session-1",
provider: "test",
model: "test-model",
})));
vi.mock("../adapters/index.js", () => ({
getServerAdapter: () => ({
type: "codex_local",
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
runningProcesses: new Map(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat plugin environment tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("heartbeat plugin environments", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
const tempRoots: string[] = [];
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("heartbeat-plugin-environment");
stopDb = started.stop;
db = createDb(started.connectionString);
}, 20_000);
afterEach(async () => {
adapterExecute.mockClear();
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
});
afterAll(async () => {
await stopDb?.();
});
it("acquires plugin environment leases through the heartbeat execution path", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const workspaceId = randomUUID();
const environmentId = randomUUID();
const pluginId = randomUUID();
const agentId = randomUUID();
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-heartbeat-"));
tempRoots.push(workspaceRoot);
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: "plugin-heartbeat-lease",
metadata: {
remoteCwd: "/workspace/project",
},
};
}
if (method === "environmentReleaseLease") {
return undefined;
}
throw new Error(`Unexpected plugin environment method: ${method}`);
}),
} as unknown as PluginWorkerManager;
await db.insert(companies).values({
id: companyId,
name: "Acme",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Plugin Environment Heartbeat",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: workspaceId,
companyId,
projectId,
name: "Primary",
cwd: workspaceRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Environments",
description: "Test plugin environment driver",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "sandbox",
displayName: "Sandbox",
configSchema: { type: "object" },
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await db.insert(environments).values({
id: environmentId,
companyId,
name: "Plugin Sandbox",
driver: "plugin",
status: "active",
config: {
pluginKey: "acme.environments",
driverKey: "sandbox",
driverConfig: {
template: "base",
},
},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: environmentId,
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
const run = await heartbeat.wakeup(agentId, {
source: "on_demand",
triggerDetail: "manual",
contextSnapshot: { projectId },
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 5_000 });
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", {
driverKey: "sandbox",
companyId,
environmentId,
config: { template: "base" },
runId: run!.id,
workspaceMode: "shared_workspace",
});
await vi.waitFor(() => {
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", {
driverKey: "sandbox",
companyId,
environmentId,
config: { template: "base" },
providerLeaseId: "plugin-heartbeat-lease",
leaseMetadata: expect.objectContaining({
driver: "plugin",
pluginId,
pluginKey: "acme.environments",
driverKey: "sandbox",
}),
});
}, { timeout: 5_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
});
});

View file

@ -320,8 +320,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(activityLog);
await db.delete(heartbeatRunEvents);
try {
await db.delete(heartbeatRuns);
break;
} catch (error) {
if (attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await db.delete(agentWakeupRequests);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(agentRuntimeState);

View file

@ -9,6 +9,7 @@ import {
deriveTaskKeyWithHeartbeatFallback,
extractWakeCommentIds,
formatRuntimeWorkspaceWarningLog,
mergeExecutionWorkspaceMetadataForPersistence,
mergeCoalescedContextSnapshot,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
@ -158,6 +159,58 @@ describe("applyPersistedExecutionWorkspaceConfig", () => {
});
});
describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
it("merges config snapshot for newly realized workspaces", () => {
expect(mergeExecutionWorkspaceMetadataForPersistence({
existingMetadata: null,
source: "task_session",
createdByRuntime: true,
configSnapshot: {
environmentId: "env-new",
provisionCommand: "bash ./scripts/provision.sh",
},
shouldReuseExisting: false,
})).toEqual({
source: "task_session",
createdByRuntime: true,
config: {
environmentId: "env-new",
provisionCommand: "bash ./scripts/provision.sh",
teardownCommand: null,
cleanupCommand: null,
desiredState: null,
serviceStates: null,
workspaceRuntime: null,
},
});
});
it("preserves persisted config snapshot when reusing an existing workspace", () => {
expect(mergeExecutionWorkspaceMetadataForPersistence({
existingMetadata: {
config: {
environmentId: "env-old",
provisionCommand: "bash ./scripts/existing-provision.sh",
},
},
source: "task_session",
createdByRuntime: false,
configSnapshot: {
environmentId: "env-new",
provisionCommand: "bash ./scripts/new-provision.sh",
},
shouldReuseExisting: true,
})).toEqual({
config: {
environmentId: "env-old",
provisionCommand: "bash ./scripts/existing-provision.sh",
},
source: "task_session",
createdByRuntime: false,
});
});
});
describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
it("reuses the persisted execution workspace path instead of deriving a new worktree", () => {
const result = buildRealizedExecutionWorkspaceFromPersisted({

View file

@ -53,6 +53,23 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(async () => null),
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
const mockIssueReferenceService = vi.hoisted(() => ({
deleteDocumentSource: vi.fn(async () => undefined),
diffIssueReferenceSummary: vi.fn(() => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
})),
emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })),
listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })),
syncComment: vi.fn(async () => undefined),
syncDocument: vi.fn(async () => undefined),
syncIssue: vi.fn(async () => undefined),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@ -68,25 +85,11 @@ function registerModuleMocks() {
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueReferenceService: () => mockIssueReferenceService,
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
@ -94,6 +97,22 @@ function registerModuleMocks() {
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.doMock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
}
async function createApp(actor: Record<string, unknown>) {
@ -118,6 +137,10 @@ describe("issue feedback trace routes", () => {
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/environments.js");
vi.doUnmock("../services/execution-workspaces.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();

View file

@ -41,6 +41,7 @@ function registerModuleMocks() {
getById: vi.fn(),
}),
documentService: () => mockDocumentsService,
environmentService: () => ({}),
executionWorkspaceService: () => mockExecutionWorkspaceService,
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
@ -85,6 +86,10 @@ function registerModuleMocks() {
listForIssue: vi.fn(async () => []),
}),
}));
vi.doMock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
}
async function createApp() {
@ -145,6 +150,7 @@ describe("issue goal context routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/execution-workspaces.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");

View file

@ -0,0 +1,168 @@
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import {
PLUGIN_RPC_ERROR_CODES,
createRequest,
isJsonRpcErrorResponse,
isJsonRpcSuccessResponse,
parseMessage,
serializeMessage,
} from "../../../packages/plugins/sdk/src/protocol.js";
import { definePlugin } from "../../../packages/plugins/sdk/src/define-plugin.js";
import { startWorkerRpcHost } from "../../../packages/plugins/sdk/src/worker-rpc-host.js";
import { pluginManifestV1Schema, type PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { pluginCapabilityValidator } from "../services/plugin-capability-validator.js";
const baseManifest: PaperclipPluginManifestV1 = {
id: "test.environment-driver",
apiVersion: 1,
version: "1.0.0",
displayName: "Environment Driver",
description: "Test environment driver plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
displayName: "Fake plugin",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
},
required: ["template"],
},
},
],
};
describe("plugin environment driver seam", () => {
it("validates environment driver manifest declarations", () => {
expect(pluginManifestV1Schema.safeParse(baseManifest).success).toBe(true);
const missingCapability = pluginManifestV1Schema.safeParse({
...baseManifest,
capabilities: ["http.outbound"],
});
expect(missingCapability.success).toBe(false);
expect(JSON.stringify(missingCapability.error?.issues)).toContain(
"environment.drivers.register",
);
const duplicateDriver = pluginManifestV1Schema.safeParse({
...baseManifest,
environmentDrivers: [
baseManifest.environmentDrivers![0],
{ ...baseManifest.environmentDrivers![0], displayName: "Duplicate" },
],
});
expect(duplicateDriver.success).toBe(false);
expect(JSON.stringify(duplicateDriver.error?.issues)).toContain(
"Duplicate environment driver keys",
);
});
it("enforces environment driver capability requirements", () => {
const validator = pluginCapabilityValidator();
expect(validator.getRequiredCapabilities("environment.acquireLease")).toEqual([
"environment.drivers.register",
]);
expect(validator.checkOperation(baseManifest, "environment.execute").allowed).toBe(true);
const withoutCapability = {
...baseManifest,
capabilities: ["http.outbound"],
} satisfies PaperclipPluginManifestV1;
expect(validator.checkOperation(withoutCapability, "environment.execute")).toMatchObject({
allowed: false,
missing: ["environment.drivers.register"],
});
expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({
allowed: false,
missing: ["environment.drivers.register"],
});
});
it("dispatches environment driver worker hooks and reports support", async () => {
const plugin = definePlugin({
async setup() {},
async onEnvironmentProbe(params) {
return {
ok: true,
summary: `probed ${params.driverKey}`,
metadata: { environmentId: params.environmentId },
};
},
});
const stdin = new PassThrough();
const stdout = new PassThrough();
const host = startWorkerRpcHost({ plugin, stdin, stdout });
const responses: unknown[] = [];
stdout.on("data", (chunk) => {
const lines = String(chunk).split("\n").filter(Boolean);
for (const line of lines) {
responses.push(parseMessage(line));
}
});
stdin.write(serializeMessage(createRequest("initialize", {
manifest: baseManifest,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
}, 1)));
await waitForResponses(responses, 1);
const initializeResponse = responses[0];
expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(initializeResponse)) return;
expect(initializeResponse.result.supportedMethods).toContain("environmentProbe");
stdin.write(serializeMessage(createRequest("environmentProbe", {
driverKey: "fake-plugin",
companyId: "company-1",
environmentId: "environment-1",
config: { template: "base" },
}, 2)));
await waitForResponses(responses, 2);
const probeResponse = responses[1];
expect(isJsonRpcSuccessResponse(probeResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(probeResponse)) return;
expect(probeResponse.result).toMatchObject({
ok: true,
summary: "probed fake-plugin",
metadata: { environmentId: "environment-1" },
});
stdin.write(serializeMessage(createRequest("environmentExecute", {
driverKey: "fake-plugin",
companyId: "company-1",
environmentId: "environment-1",
config: { template: "base" },
lease: { providerLeaseId: "lease-1" },
command: "echo",
}, 3)));
await waitForResponses(responses, 3);
const executeResponse = responses[2];
expect(isJsonRpcErrorResponse(executeResponse)).toBe(true);
if (!isJsonRpcErrorResponse(executeResponse)) return;
expect(executeResponse.error.code).toBe(PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED);
expect(executeResponse.error.message).toContain("environmentExecute");
host.stop();
});
});
async function waitForResponses(responses: unknown[], count: number): Promise<void> {
const deadline = Date.now() + 1_000;
while (responses.length < count && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(responses.length).toBeGreaterThanOrEqual(count);
}

View file

@ -36,6 +36,14 @@ vi.mock("../services/index.js", () => ({
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
@ -54,6 +62,14 @@ function registerModuleMocks() {
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.doMock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.doMock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
@ -126,6 +142,8 @@ describe("project env routes", () => {
vi.doUnmock("../routes/projects.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../services/environments.js");
vi.doUnmock("../services/secrets.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "../runtime-api.js";
describe("runtime API discovery", () => {
it("prefers the explicit public base URL for the primary runtime URL", () => {
expect(
choosePrimaryRuntimeApiUrl({
authPublicBaseUrl: "https://paperclip.example.com/base/path",
allowedHostnames: ["198.51.100.10"],
bindHost: "0.0.0.0",
port: 3102,
}),
).toBe("https://paperclip.example.com");
});
it("builds ordered callback candidates from explicit, allowed, bind, and interface hosts", () => {
expect(
buildRuntimeApiCandidateUrls({
authPublicBaseUrl: null,
allowedHostnames: ["198.51.100.10", "runtime-host.example.test", "203.0.113.42"],
bindHost: "0.0.0.0",
port: 3102,
networkInterfacesMap: {
en0: [
{
address: "203.0.113.42",
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
cidr: "203.0.113.42/24",
mac: "00:00:00:00:00:00",
},
{
address: "fe80::1",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff:ffff::",
cidr: "fe80::1/64",
mac: "00:00:00:00:00:00",
scopeid: 1,
},
],
lo0: [
{
address: "127.0.0.1",
family: "IPv4",
internal: true,
netmask: "255.0.0.0",
cidr: "127.0.0.1/8",
mac: "00:00:00:00:00:00",
},
],
},
}),
).toEqual([
"http://198.51.100.10:3102",
"http://runtime-host.example.test:3102",
"http://203.0.113.42:3102",
"http://[fe80::1]:3102",
]);
});
it("adds host.docker.internal when the explicit base URL is loopback", () => {
expect(
buildRuntimeApiCandidateUrls({
authPublicBaseUrl: "http://127.0.0.1:3102",
allowedHostnames: [],
bindHost: "127.0.0.1",
port: 3102,
networkInterfacesMap: {},
}),
).toEqual([
"http://127.0.0.1:3102",
"http://host.docker.internal:3102",
]);
});
});

View file

@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
acquireSandboxProviderLease,
findReusableSandboxProviderLeaseId,
getSandboxProvider,
listSandboxProviders,
probeSandboxProvider,
releaseSandboxProviderLease,
sandboxConfigFromLeaseMetadata,
sandboxConfigFromLeaseMetadataLoose,
validateSandboxProviderConfig,
} from "../services/sandbox-provider-runtime.ts";
describe("sandbox provider runtime", () => {
it("exposes fake as the built-in sandbox provider implementation", async () => {
expect(listSandboxProviders().map((provider) => provider.provider).sort()).toEqual(["fake"]);
expect(getSandboxProvider("fake")?.provider).toBe("fake");
expect(getSandboxProvider("fake-plugin")).toBeNull();
await expect(
validateSandboxProviderConfig({
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
}),
).resolves.toEqual(
expect.objectContaining({
ok: true,
details: expect.objectContaining({
provider: "fake",
image: "ubuntu:24.04",
}),
}),
);
});
it("does not route plugin-backed providers through the built-in provider helper", async () => {
await expect(probeSandboxProvider({
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 300000,
reuseLease: false,
})).rejects.toThrow('Sandbox provider "fake-plugin" is not registered as a built-in provider.');
});
it("acquires and resumes fake leases deterministically", async () => {
const lease = await acquireSandboxProviderLease({
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
environmentId: "env-1",
heartbeatRunId: "run-1",
issueId: "issue-1",
});
expect(lease.providerLeaseId).toBe("sandbox://fake/env-1");
expect(lease.metadata).toEqual(expect.objectContaining({
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
}));
const resumed = await acquireSandboxProviderLease({
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
environmentId: "env-1",
heartbeatRunId: "run-2",
issueId: "issue-1",
reusableProviderLeaseId: lease.providerLeaseId,
});
expect(resumed.providerLeaseId).toBe(lease.providerLeaseId);
expect(resumed.metadata).toEqual(expect.objectContaining({ resumedLease: true }));
});
it("matches reusable fake leases through the selected provider implementation", () => {
expect(
findReusableSandboxProviderLeaseId({
config: {
provider: "fake",
image: "image-b",
reuseLease: true,
},
leases: [
{
providerLeaseId: "sandbox-image-a",
metadata: {
provider: "fake",
image: "image-a",
reuseLease: true,
},
},
{
providerLeaseId: "sandbox-image-b",
metadata: {
provider: "fake",
image: "image-b",
reuseLease: true,
},
},
],
}),
).toBe("sandbox-image-b");
});
it("reconstructs fake sandbox config from lease metadata for later release", () => {
const metadata = {
provider: "fake",
image: "paperclip-test",
reuseLease: true,
};
expect(sandboxConfigFromLeaseMetadata({ metadata })).toEqual({
provider: "fake",
image: "paperclip-test",
reuseLease: true,
});
expect(sandboxConfigFromLeaseMetadataLoose({ metadata })).toEqual({
provider: "fake",
image: "paperclip-test",
reuseLease: true,
});
});
it("reconstructs plugin-backed sandbox config from lease metadata for runtime recovery", () => {
const metadata = {
provider: "fake-plugin",
reuseLease: true,
timeoutMs: 45_000,
remoteCwd: "/workspace/project",
fakeRootDir: "/tmp/fake-root",
};
expect(sandboxConfigFromLeaseMetadataLoose({ metadata })).toEqual({
provider: "fake-plugin",
reuseLease: true,
timeoutMs: 45_000,
remoteCwd: "/workspace/project",
fakeRootDir: "/tmp/fake-root",
});
});
it("releases fake leases without external side effects", async () => {
await expect(releaseSandboxProviderLease({
config: {
provider: "fake",
image: "ubuntu:24.04",
reuseLease: true,
},
providerLeaseId: "sandbox://fake/env-1",
status: "released",
})).resolves.toBeUndefined();
});
});

View file

@ -104,6 +104,9 @@ vi.mock("../config.js", () => ({
vi.mock("../middleware/logger.js", () => ({
logger: {
child: vi.fn(function child() {
return this;
}),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),