mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
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:
parent
641eb44949
commit
70679a3321
91 changed files with 10469 additions and 1498 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
58
server/src/__tests__/environment-execution-target.test.ts
Normal file
58
server/src/__tests__/environment-execution-target.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"), {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
350
server/src/__tests__/environment-run-orchestrator.test.ts
Normal file
350
server/src/__tests__/environment-run-orchestrator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
319
server/src/__tests__/environment-runtime-driver-contract.test.ts
Normal file
319
server/src/__tests__/environment-runtime-driver-contract.test.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
943
server/src/__tests__/environment-runtime.test.ts
Normal file
943
server/src/__tests__/environment-runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
237
server/src/__tests__/environment-test-harness.test.ts
Normal file
237
server/src/__tests__/environment-test-harness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
223
server/src/__tests__/heartbeat-plugin-environment.test.ts
Normal file
223
server/src/__tests__/heartbeat-plugin-environment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
168
server/src/__tests__/plugin-environment-driver-seam.test.ts
Normal file
168
server/src/__tests__/plugin-environment-driver-seam.test.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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() });
|
||||
|
|
|
|||
77
server/src/__tests__/runtime-api.test.ts
Normal file
77
server/src/__tests__/runtime-api.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
160
server/src/__tests__/sandbox-provider-runtime.test.ts
Normal file
160
server/src/__tests__/sandbox-provider-runtime.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue