mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its tests protect company boundaries, approvals, durable tasks, credentials and the board UI. > - Several owner suites repeat large database fixtures and render frames. > - Some tests repeat stronger proof or pass when the behavior they describe is broken. > - This pull request shares exact setup, removes proven duplicate cases and repairs weak observations. > - It preserves distinct security, lifecycle, concurrency and user-facing contracts. > - The benefit is 5,027 fewer test lines and stronger regression checks. ## Linked Issues or Issue Description Refs #15978. This continues the merged test-audit cleanup in [PR #15978](https://github.com/paperclipai/paperclip/pull/15978), which removed 11,976 test/support lines. The savings in this pull request are additional. Related open work: [PR #13460](https://github.com/paperclipai/paperclip/pull/13460) changes legacy OAuth parent/version lock ordering. This pull request retains those production paths and the existing lock-order test. Its secrets changes compact fixtures and repair the two AWS wrapper exclusion observations. Scoped title searches found no open duplicate audit or fixture-consolidation PR and no matching audit issue. The problem is described here using the enhancement template. **What existing behavior does this improve?** The maintenance and regression value of the existing server, UI, CLI, shared and wiki-plugin test suites. **Subsystem affected** Cross-cutting test code. Two UI modules also lose unused test inspection exports. **Current behavior** Owner tests repeat database rows, service inputs, HTTP setup and render frames. Some cases repeat stronger assertions. Two AWS lock tests record only cleanup events, so an AWS provider write can happen inside the held lock without failing those observations. Several other negative tests accept or do not observe the wrong result. Worktree fixtures also use a temporary path alias that conflicts with the existing canonical-path checks on macOS. **Proposed behavior** Use small local helpers that preserve exact inputs, fresh allocations, callbacks, query order and await positions. Remove only cases with named stronger keepers. Tighten negative assertions and observe actual AWS provider entry while a confirmed lock holder remains parked. Resolve temporary fixture roots to their canonical paths. **Reason and benefit** Remove 5,027 net test lines and 15 redundant registered executions. Remove 11 lines of unused UI inspection exports. Keep every distinct company, actor, approval, credential, persistence, stale-write and concurrency contract. No wall-time speedup is claimed for setup compaction. **Breaking changes** None for application behavior, APIs or stored data. The removed exports expose private UI test state and have no remaining callers. ## What Changed - Consolidate repeated runtime, workspace, portability, route, tool-access, policy, gateway and state-service fixtures. - Share repeated UI render frames and CLI/wiki-plugin setup while retaining the original actions and expected results. - Remove 15 repeated case executions only where the audit records an exact stronger keeper. - Repair weak HTTP/UI negative assertions and two AWS account-home lock observations. Keep the existing durable results and cleanup behavior. - Remove the unused `__inflightGetCount` and `__secondTickInternals` inspection exports. Keep observable request coalescing and ticker lifecycle checks. - Canonicalize temporary worktree fixture paths without weakening production path checks. - Record the owner decisions, savings, independent preservation reviews and validation in [the dated audit report](https://github.com/paperclipai/paperclip/blob/58c5c4aa241c85f4665a3159c192888bbb2a5cfa/doc/plans/2026-10-12-test-audit-rounds.md) and [the compact control evidence](https://github.com/paperclipai/paperclip/blob/58c5c4aa241c85f4665a3159c192888bbb2a5cfa/doc/plans/2026-10-12-test-audit-controls.json). ## Verification - Complete owner and retained-sibling baselines passed before each audit cutover. Independent reviewers checked the full original tests, actual helper bodies, exact inputs, receipt provenance, async order, fresh allocations and case registrations. - All 14 UI/route/wiki mutations failed at their intended assertions: ten UI/API/ticker controls, one route control and three wiki persistence/receipt controls. Each mutated production source was restored to its exact original bytes. - Both old AWS lock tests passed with their respective AWS-only lock bypass. Both repaired tests failed at the intended assertions with the same bypasses. These give 16 intended failures and two passing weak controls across 18 executions. Each source was restored exactly. - Final complete state-owner/sibling run: 329/329 passed at code revision `64370831ebe191e5ce092f85f79b6fb661df7e16`. - Final `pnpm -r typecheck`, `pnpm check:token-gates` and `pnpm build`: passed at the same code revision. - Delivery head `58c5c4aa241c85f4665a3159c192888bbb2a5cfa` includes the evidence commit and master `4b766b872993045a2e787404f0475503683e5ded`. All 27 audited test files and both application files match the locally verified code revision byte-for-byte. Current-head CI validates the integrated branch, including the incoming master changes. - All eight production-owner SHA guards for the 16 deliberate failing mutation controls still match the integrated delivery checkout. The mutation receipts therefore apply to those current owners. - Full default `pnpm test:run`: **failed** with exit 1 at frozen code revision `64370831ebe191e5ce092f85f79b6fb661df7e16`; HEAD and tracked source bytes remained unchanged. The first general-server group reported 878 passing files, four failing files and six skipped files; 17,846 passing cases, 24 failing cases and 105 skipped cases. Later groups did not run. Failures were `http-log-redaction` (socket hangup), `public-mcp` (initial 15-second timeout followed by hosted token “Refresh before subscribing” failures), `remove-test-home` (macOS `ENOTEMPTY` where permission errors were expected, plus cleanup failure), and `workspace-git-snapshot-streaming` (300-second timeout). All four failing test files and the relevant cleanup/MCP production owners are unchanged by this audit. Full local success is not claimed. - Focused complete-owner diagnosis at the same frozen revision: 149/152 passed, three failed, zero skipped. `public-mcp` passed 82/82 and `http-log-redaction` passed 66/66 unchanged. The two `remove-test-home` cases reproduced macOS `ENOTEMPTY` against an expected `EACCES`/`EPERM` contract; the 32 MiB workspace snapshot case reproduced its existing 300,000 ms timeout. HEAD and tracked files stayed clean. Exact blob comparison confirmed all four test files and the relevant cleanup/MCP owners match both inspected master `4b766b872993045a2e787404f0475503683e5ded` and the frozen local revision. The three remaining local failures are unresolved; no timeout, retry or assertion was weakened. - Current-head CI: [workflow 38299262149](https://github.com/paperclipai/paperclip/actions/runs/38299262149) has passed runner selection and created all downstream test/typecheck/build gates; those gates are queued, with no failures reported. Greptile completed successfully at `58c5c4aa241c85f4665a3159c192888bbb2a5cfa` with [5/5 and zero comments](https://github.com/paperclipai/paperclip/pull/16022#issuecomment-6131457592). All review threads are resolved or absent. This pull request will remain open after verification. Reviewers can reproduce the required workspace checks: ```sh pnpm -r typecheck pnpm check:token-gates pnpm test:run pnpm build ``` Browser suites were not run. Their sources and user journeys are unchanged by this audit. ## Risks - A fixture helper can hide a specialized input or change object freshness. Full-program expansion checks and independent reviews preserve the exact old inputs and execution order. - Removing duplicate proof can remove a distinct contract by mistake. The audit records the keeper for each deletion; all distinct security and lifecycle cases remain. - Concurrency checks can miss progress during a fixed delay. The AWS repairs record actual provider entry, confirm holder entry, use measured entry windows, require exact final event order and drain both operations in `finally`. - The full local default run failed. Focused diagnosis reproduced two macOS cleanup failures and one existing snapshot timeout in unchanged owners; these remain unresolved. Current-head CI is pending. No test deadline, retry policy, skip or CI shard was weakened to obtain a pass. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with GPT-6, reasoning, repository inspection, code execution and delegated audit/review. The runtime does not expose the exact served model identifier or context-window size; those values are unavailable and are not inferred from the model catalog. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
3064 lines
110 KiB
TypeScript
3064 lines
110 KiB
TypeScript
import type { Server } from "node:http";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { unprocessable } from "../errors.js";
|
|
import { environmentRoutes } from "../routes/environments.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
canUser: vi.fn(),
|
|
hasPermission: vi.fn(),
|
|
decide: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
clearExecutionWorkspaceEnvironmentSelection: vi.fn(),
|
|
}));
|
|
|
|
const mockProjectService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
clearExecutionWorkspaceEnvironmentSelection: vi.fn(),
|
|
}));
|
|
|
|
const mockEnvironmentRuntimeService = vi.hoisted(() => ({
|
|
destroyReusableSandboxLeasesForEnvironment: vi.fn(async () => ({ destroyed: 0, failed: 0, skippedLiveRun: 0 })),
|
|
}));
|
|
const mockCloseWarmNativeSessionsForEnvironment = vi.hoisted(() =>
|
|
vi.fn(async () => ({ closed: 0, busy: 0, failed: 0 })),
|
|
);
|
|
const mockInstanceSettingsService = vi.hoisted(() => ({
|
|
listCompanyIds: vi.fn(),
|
|
getGeneral: vi.fn(),
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
|
|
const mockEnvironmentService = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
getById: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
removeIfDeletable: vi.fn(),
|
|
getDeleteBlastRadius: vi.fn(),
|
|
hasUnresolvedPendingCleanupLeases: vi.fn(),
|
|
listLeases: vi.fn(),
|
|
getLeaseById: vi.fn(),
|
|
}));
|
|
|
|
const mockEnvironmentCustomImageService = vi.hoisted(() => ({
|
|
getOverview: vi.fn(),
|
|
getActiveTemplate: vi.fn(),
|
|
getSessionById: vi.fn(),
|
|
startSetupSession: vi.fn(),
|
|
refreshSetupSession: vi.fn(),
|
|
finishSetupSession: vi.fn(),
|
|
cancelSetupSession: vi.fn(),
|
|
rollbackTemplate: vi.fn(),
|
|
disableTemplate: vi.fn(),
|
|
cleanupExpiredSetupSessions: vi.fn(),
|
|
}));
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
const mockProbeEnvironment = vi.hoisted(() => vi.fn());
|
|
const mockSecretService = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
normalizeEnvBindingsForPersistence: vi.fn(),
|
|
listBindingCompanyIdsForTarget: vi.fn(),
|
|
resolveSecretValue: vi.fn(),
|
|
resolveSecretValueForEphemeralAccess: vi.fn(),
|
|
syncEnvBindingsForTarget: vi.fn(),
|
|
syncSecretRefsForTarget: vi.fn(),
|
|
replaceSecretRefsForInstanceTarget: vi.fn(),
|
|
describeSecretRefs: vi.fn(),
|
|
remove: vi.fn(),
|
|
}));
|
|
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
|
|
const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn());
|
|
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
|
|
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
|
|
const mockStartPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn());
|
|
const mockGetPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn());
|
|
const mockCapturePluginEnvironmentTemplate = vi.hoisted(() => vi.fn());
|
|
const mockCancelPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn());
|
|
const mockDeletePluginEnvironmentTemplate = vi.hoisted(() => vi.fn());
|
|
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
|
clearEnvironmentSelection: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
issueService: () => mockIssueService,
|
|
instanceSettingsService: () => mockInstanceSettingsService,
|
|
environmentCustomImageService: () => mockEnvironmentCustomImageService,
|
|
environmentService: () => mockEnvironmentService,
|
|
logActivity: mockLogActivity,
|
|
projectService: () => mockProjectService,
|
|
}));
|
|
|
|
vi.mock("../services/environment-probe.js", () => ({
|
|
probeEnvironment: mockProbeEnvironment,
|
|
}));
|
|
|
|
vi.mock("../services/secrets.js", () => ({
|
|
secretService: () => mockSecretService,
|
|
}));
|
|
|
|
vi.mock("../services/environments.js", () => ({
|
|
environmentService: () => mockEnvironmentService,
|
|
}));
|
|
|
|
vi.mock("../services/environment-runtime.js", () => ({
|
|
environmentRuntimeService: () => mockEnvironmentRuntimeService,
|
|
}));
|
|
vi.mock("../services/native-runtime/native-session-executor.js", () => ({
|
|
closeWarmNativeSessionsForEnvironment:
|
|
mockCloseWarmNativeSessionsForEnvironment,
|
|
}));
|
|
|
|
vi.mock("../services/execution-workspaces.js", () => ({
|
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
|
}));
|
|
|
|
vi.mock("../services/plugin-environment-driver.js", () => ({
|
|
// The runtime reads this published constant at import time. Mirror the real
|
|
// value so the mocked module keeps the same reusable-lease method contract.
|
|
REUSABLE_LEASE_WORKER_METHODS: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"],
|
|
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
|
|
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
|
|
startPluginEnvironmentInteractiveSetup: mockStartPluginEnvironmentInteractiveSetup,
|
|
getPluginEnvironmentInteractiveSetup: mockGetPluginEnvironmentInteractiveSetup,
|
|
capturePluginEnvironmentTemplate: mockCapturePluginEnvironmentTemplate,
|
|
cancelPluginEnvironmentInteractiveSetup: mockCancelPluginEnvironmentInteractiveSetup,
|
|
deletePluginEnvironmentTemplate: mockDeletePluginEnvironmentTemplate,
|
|
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
|
|
validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig,
|
|
}));
|
|
|
|
function createEnvironment() {
|
|
const now = new Date("2026-04-16T05:00:00.000Z");
|
|
return {
|
|
id: "env-1",
|
|
companyId: "company-1",
|
|
name: "Local",
|
|
description: "Current development machine",
|
|
driver: "local",
|
|
status: "active" as const,
|
|
config: { shell: "zsh" },
|
|
envVars: {},
|
|
metadata: { source: "manual" },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
function createDeleteBlastRadius(overrides: Partial<{
|
|
isManagedLocal: boolean;
|
|
isInstanceDefault: boolean;
|
|
agentDefaultCount: number;
|
|
executionWorkspaceSelectionCount: number;
|
|
issueSelectionCount: number;
|
|
projectSelectionCount: number;
|
|
secretBindingCount: number;
|
|
activeLeaseCount: number;
|
|
activeCustomImageSetupSessionCount: number;
|
|
pendingCleanupLeaseCount: number;
|
|
reusableSandboxLeaseCount: number;
|
|
reusableSandboxLeaseHolders: Array<{
|
|
leaseId: string;
|
|
executionWorkspaceId: string | null;
|
|
executionWorkspaceName: string | null;
|
|
issueId: string | null;
|
|
issueIdentifier: string | null;
|
|
issueTitle: string | null;
|
|
}>;
|
|
}> = {}) {
|
|
const staticReferences = {
|
|
isManagedLocal: overrides.isManagedLocal ?? false,
|
|
isInstanceDefault: overrides.isInstanceDefault ?? false,
|
|
agentDefaultCount: overrides.agentDefaultCount ?? 0,
|
|
executionWorkspaceSelectionCount: overrides.executionWorkspaceSelectionCount ?? 0,
|
|
issueSelectionCount: overrides.issueSelectionCount ?? 0,
|
|
projectSelectionCount: overrides.projectSelectionCount ?? 0,
|
|
secretBindingCount: overrides.secretBindingCount ?? 0,
|
|
};
|
|
const activeRuntimeUse = {
|
|
activeLeaseCount: overrides.activeLeaseCount ?? 0,
|
|
activeCustomImageSetupSessionCount: overrides.activeCustomImageSetupSessionCount ?? 0,
|
|
hasActiveRuntimeUse:
|
|
(overrides.activeLeaseCount ?? 0) > 0
|
|
|| (overrides.activeCustomImageSetupSessionCount ?? 0) > 0,
|
|
};
|
|
const pendingCleanupLeaseCount = overrides.pendingCleanupLeaseCount ?? 0;
|
|
const reusableSandboxLeaseCount = overrides.reusableSandboxLeaseCount ?? 0;
|
|
const reusableSandboxLeaseHolders =
|
|
overrides.reusableSandboxLeaseHolders
|
|
?? Array.from({ length: reusableSandboxLeaseCount }, (_, index) => ({
|
|
leaseId: `lease-${index + 1}`,
|
|
executionWorkspaceId: null,
|
|
executionWorkspaceName: null,
|
|
issueId: null,
|
|
issueIdentifier: null,
|
|
issueTitle: null,
|
|
}));
|
|
const deleteBlockedReasons = [
|
|
...(staticReferences.isManagedLocal ? ["managed_local" as const] : []),
|
|
...(staticReferences.isInstanceDefault ? ["instance_default" as const] : []),
|
|
...(pendingCleanupLeaseCount > 0 ? ["pending_sandbox_cleanup" as const] : []),
|
|
...(reusableSandboxLeaseCount > 0 ? ["reusable_sandbox_lease" as const] : []),
|
|
];
|
|
return {
|
|
environmentId: "env-1",
|
|
canDelete: deleteBlockedReasons.length === 0,
|
|
deleteBlockedReasons,
|
|
pendingCleanupLeaseCount,
|
|
reusableSandboxLeaseCount,
|
|
reusableSandboxLeaseHolders,
|
|
staticReferences,
|
|
activeRuntimeUse,
|
|
};
|
|
}
|
|
|
|
let server: Server | null = null;
|
|
let currentActor: Record<string, unknown> = localBoardActor();
|
|
const routeOptions: Record<string, unknown> = {};
|
|
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
|
|
|
// The routes open a transaction around environment writes and their binding
|
|
// syncs. Service calls are mocked, so the executor never runs a real query —
|
|
// it only needs to be identity-checkable in assertions.
|
|
const routeDbTx = { __routeDbTx: true };
|
|
const routeDb = {
|
|
transaction: async <T>(fn: (tx: unknown) => Promise<T>): Promise<T> =>
|
|
fn(routeDbTx),
|
|
};
|
|
|
|
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();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = currentActor;
|
|
next();
|
|
});
|
|
app.use("/api", environmentRoutes(routeDb as any, routeOptions as any));
|
|
app.use(errorHandler);
|
|
server = app.listen(0);
|
|
return server;
|
|
}
|
|
|
|
function localBoardActor(userId = "user-1") {
|
|
return {
|
|
type: "board",
|
|
userId: userId,
|
|
source: "local_implicit",
|
|
};
|
|
}
|
|
|
|
function createSecurePluginDriver() {
|
|
return {
|
|
driverKey: "secure-plugin",
|
|
kind: "sandbox_provider",
|
|
displayName: "Secure Sandbox",
|
|
configSchema: {
|
|
type: "object",
|
|
properties: {
|
|
template: { type: "string" },
|
|
apiKey: { type: "string", format: "secret-ref" },
|
|
timeoutMs: { type: "number" },
|
|
reuseLease: { type: "boolean" },
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSecretBackedSshEnvironment() {
|
|
return {
|
|
...createEnvironment(),
|
|
id: "env-ssh",
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSshConfig() {
|
|
return {
|
|
host: "ssh.example.test",
|
|
port: 22,
|
|
username: "ssh-user",
|
|
remoteWorkspacePath: "/srv/paperclip/workspace",
|
|
privateKey: null,
|
|
privateKeySecretRef: null,
|
|
knownHosts: null,
|
|
strictHostKeyChecking: true,
|
|
};
|
|
}
|
|
|
|
describe("environment routes", () => {
|
|
afterAll(async () => {
|
|
if (originalSecretsProviderEnv === undefined) {
|
|
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
|
|
} else {
|
|
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
|
|
}
|
|
if (!server) return;
|
|
await new Promise<void>((resolve, reject) => {
|
|
server?.close((err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
server = null;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockAccessService.canUser.mockReset();
|
|
mockAccessService.hasPermission.mockReset();
|
|
mockAccessService.decide.mockReset();
|
|
mockAgentService.getById.mockReset();
|
|
mockIssueService.getById.mockReset();
|
|
mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
|
|
mockProjectService.getById.mockReset();
|
|
mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
|
|
mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockReset();
|
|
mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockResolvedValue(
|
|
{ destroyed: 0, failed: 0, skippedLiveRun: 0 },
|
|
);
|
|
mockCloseWarmNativeSessionsForEnvironment.mockReset();
|
|
mockCloseWarmNativeSessionsForEnvironment.mockResolvedValue({
|
|
closed: 0,
|
|
busy: 0,
|
|
failed: 0,
|
|
});
|
|
mockInstanceSettingsService.listCompanyIds.mockReset();
|
|
mockInstanceSettingsService.getGeneral.mockReset();
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "any" });
|
|
mockInstanceSettingsService.getExperimental.mockReset();
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
|
|
mockEnvironmentService.list.mockReset();
|
|
mockEnvironmentService.list.mockResolvedValue([]);
|
|
mockEnvironmentService.getById.mockReset();
|
|
mockEnvironmentService.create.mockReset();
|
|
mockEnvironmentService.update.mockReset();
|
|
mockEnvironmentService.removeIfDeletable.mockReset();
|
|
mockEnvironmentService.getDeleteBlastRadius.mockReset();
|
|
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockReset();
|
|
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(false);
|
|
mockEnvironmentService.listLeases.mockReset();
|
|
mockEnvironmentService.getLeaseById.mockReset();
|
|
mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset();
|
|
Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset());
|
|
mockEnvironmentCustomImageService.getOverview.mockResolvedValue({
|
|
activeTemplate: null,
|
|
activeSession: null,
|
|
latestSession: null,
|
|
});
|
|
mockEnvironmentCustomImageService.getActiveTemplate.mockResolvedValue(null);
|
|
mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(null);
|
|
mockLogActivity.mockReset();
|
|
mockProbeEnvironment.mockReset();
|
|
mockSecretService.create.mockReset();
|
|
mockSecretService.normalizeEnvBindingsForPersistence.mockReset();
|
|
mockSecretService.listBindingCompanyIdsForTarget.mockReset();
|
|
mockSecretService.resolveSecretValue.mockReset();
|
|
mockSecretService.resolveSecretValueForEphemeralAccess.mockReset();
|
|
mockSecretService.syncEnvBindingsForTarget.mockReset();
|
|
mockSecretService.syncSecretRefsForTarget.mockReset();
|
|
mockSecretService.replaceSecretRefsForInstanceTarget.mockReset();
|
|
mockSecretService.describeSecretRefs.mockReset();
|
|
mockSecretService.describeSecretRefs.mockResolvedValue([]);
|
|
mockSecretService.remove.mockReset();
|
|
mockSecretService.create.mockResolvedValue({
|
|
id: "11111111-1111-1111-1111-111111111111",
|
|
});
|
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
|
mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockResolvedValue(0);
|
|
mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockResolvedValue(0);
|
|
mockExecutionWorkspaceService.clearEnvironmentSelection.mockResolvedValue(0);
|
|
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env ?? {});
|
|
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]);
|
|
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue([]);
|
|
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
|
|
mockSecretService.replaceSecretRefsForInstanceTarget.mockResolvedValue([]);
|
|
mockSecretService.remove.mockResolvedValue(null);
|
|
mockSecretService.resolveSecretValueForEphemeralAccess.mockResolvedValue("resolved-provider-key");
|
|
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
|
|
mockValidatePluginEnvironmentDriverConfig.mockReset();
|
|
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
|
|
mockValidatePluginSandboxProviderConfig.mockReset();
|
|
mockValidatePluginSandboxProviderConfig.mockImplementation(async ({ provider, config }) => ({
|
|
normalizedConfig: config,
|
|
pluginId: `plugin-${provider}`,
|
|
pluginKey: `plugin.${provider}`,
|
|
driver: {
|
|
driverKey: provider,
|
|
kind: "sandbox_provider",
|
|
displayName: provider,
|
|
configSchema: { type: "object" },
|
|
},
|
|
}));
|
|
mockResolvePluginSandboxProviderDriverByKey.mockReset();
|
|
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(async ({ driverKey }) => (
|
|
driverKey === "secure-plugin"
|
|
? {
|
|
pluginId: "plugin-secure",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driver: createSecurePluginDriver(),
|
|
}
|
|
: null
|
|
));
|
|
mockListReadyPluginEnvironmentDrivers.mockReset();
|
|
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]);
|
|
mockStartPluginEnvironmentInteractiveSetup.mockReset();
|
|
mockGetPluginEnvironmentInteractiveSetup.mockReset();
|
|
mockCapturePluginEnvironmentTemplate.mockReset();
|
|
mockCancelPluginEnvironmentInteractiveSetup.mockReset();
|
|
mockDeletePluginEnvironmentTemplate.mockReset();
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: true,
|
|
explanation: "Allowed by test harness",
|
|
});
|
|
});
|
|
|
|
it("lists instance-scoped environments through the company route alias", async () => {
|
|
mockEnvironmentService.list.mockResolvedValue([createEnvironment()]);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments?driver=local");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(1);
|
|
expect(mockEnvironmentService.list).toHaveBeenCalledWith({
|
|
status: undefined,
|
|
driver: "local",
|
|
});
|
|
});
|
|
|
|
it("redacts environment config for non-admin board readers", async () => {
|
|
mockEnvironmentService.list.mockResolvedValue([createEnvironment()]);
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-2",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body[0]).toMatchObject({
|
|
id: "env-1",
|
|
name: "Local",
|
|
config: {},
|
|
envVars: {},
|
|
metadata: null,
|
|
});
|
|
});
|
|
|
|
it("redacts environment detail config for non-admin board readers", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-2",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(app).get("/api/environments/env-1");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
id: "env-1",
|
|
config: {},
|
|
envVars: {},
|
|
metadata: null,
|
|
});
|
|
});
|
|
|
|
describe("platform-provisioned environment floor on cloud-managed instances", () => {
|
|
function createPlatformSandboxEnvironment() {
|
|
const now = new Date("2026-04-16T05:00:00.000Z");
|
|
return {
|
|
id: "env-managed-1",
|
|
companyId: "company-1",
|
|
name: "Daytona",
|
|
description: "Managed sandbox environment",
|
|
driver: "sandbox",
|
|
status: "active" as const,
|
|
config: {
|
|
provider: "daytona",
|
|
image: "custom-image:latest",
|
|
target: "us",
|
|
apiKey: "config-credential-must-never-echo",
|
|
},
|
|
envVars: { MY_AGENT_TOOL_SETTING: "tenant-env-value" },
|
|
metadata: { managedByPaperclip: true, managedSandboxProvider: "daytona" },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
const ownerAdminActor = {
|
|
type: "board",
|
|
userId: "owner-1",
|
|
source: "cloud_tenant",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "owner" }],
|
|
isInstanceAdmin: true,
|
|
};
|
|
|
|
// A minimal valid managed-config document whose `environments` entry makes
|
|
// the managed-sandbox provisioner own the marked sandbox slot row.
|
|
const MANAGED_CONFIG_WITH_SANDBOX_ENTRY = JSON.stringify({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: {},
|
|
plugins: { autoInstall: [] },
|
|
environments: [{ name: "Sandbox", provider: "daytona" }],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
|
|
});
|
|
afterEach(() => {
|
|
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
|
});
|
|
|
|
it("never echoes credential-shaped config keys, while tenant env vars round-trip", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app).get("/api/environments/env-managed-1");
|
|
|
|
expect(res.status).toBe(200);
|
|
// Env vars are the tenant-owned field on the managed sandbox row
|
|
// (the platform never writes them), so they echo for editing.
|
|
expect(res.body.envVars).toEqual({ MY_AGENT_TOOL_SETTING: "tenant-env-value" });
|
|
expect(res.body.config).toEqual({
|
|
provider: "daytona",
|
|
image: "custom-image:latest",
|
|
target: "us",
|
|
});
|
|
expect(res.body.metadata).toMatchObject({ managedByPaperclip: true });
|
|
expect(JSON.stringify(res.body)).not.toContain("config-credential-must-never-echo");
|
|
});
|
|
|
|
it("keeps blanking env vars on legacy kubernetes-marker rows", async () => {
|
|
// Pre-generalization builds may have written platform values into
|
|
// these rows' env vars, so the legacy floor stays absolute there.
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
...createPlatformSandboxEnvironment(),
|
|
metadata: {
|
|
managedByPaperclip: true,
|
|
managedSandboxProvider: "kubernetes",
|
|
managedKubernetesSandbox: true,
|
|
},
|
|
});
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app).get("/api/environments/env-managed-1");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.envVars).toEqual({});
|
|
});
|
|
|
|
it("exposes structural config to restricted company readers instead of blanking it", async () => {
|
|
mockEnvironmentService.list.mockResolvedValue([createPlatformSandboxEnvironment()]);
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-2",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body[0].config).toEqual({
|
|
provider: "daytona",
|
|
image: "custom-image:latest",
|
|
target: "us",
|
|
});
|
|
// Tenant env vars can carry pasted credentials, so restricted readers
|
|
// get the same blank envVars posture as on every other environment.
|
|
expect(res.body[0].envVars).toEqual({});
|
|
expect(res.body[0].metadata).toMatchObject({ managedByPaperclip: true });
|
|
});
|
|
|
|
it("rejects updates to platform-provisioned rows, including for instance admins", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app).patch("/api/environments/env-managed-1").send({ name: "Renamed" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows an envVars-only patch on the managed sandbox row", async () => {
|
|
const row = createPlatformSandboxEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(row);
|
|
mockEnvironmentService.update.mockResolvedValue({
|
|
...row,
|
|
envVars: { MY_AGENT_TOOL_SETTING: "updated-value", EXTRA: "added" },
|
|
});
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ envVars: { MY_AGENT_TOOL_SETTING: "updated-value", EXTRA: "added" } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.update).toHaveBeenCalledWith(
|
|
"env-managed-1",
|
|
expect.objectContaining({
|
|
envVars: { MY_AGENT_TOOL_SETTING: "updated-value", EXTRA: "added" },
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
expect(res.body.envVars).toEqual({ MY_AGENT_TOOL_SETTING: "updated-value", EXTRA: "added" });
|
|
});
|
|
|
|
it("scopes the envVars patch to an explicit companyId query for a multi-company actor", async () => {
|
|
const row = createPlatformSandboxEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(row);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...row, envVars: { A: "1" } });
|
|
// No prior bindings and two memberships: neither inference path can
|
|
// pin a company, so the explicit query context must carry the save.
|
|
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]);
|
|
const app = createApp({
|
|
...ownerAdminActor,
|
|
companyIds: ["company-1", "company-2"],
|
|
memberships: [
|
|
{ companyId: "company-1", status: "active", membershipRole: "owner" },
|
|
{ companyId: "company-2", status: "active", membershipRole: "member" },
|
|
],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1?companyId=company-1")
|
|
.send({ envVars: { A: "1" } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
|
|
"company-1",
|
|
{ A: "1" },
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("falls back to the instance's only company when the actor's memberships cannot pin one", async () => {
|
|
const row = createPlatformSandboxEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(row);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...row, envVars: { A: "1" } });
|
|
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]);
|
|
// An instance admin provisioned without membership rows — the
|
|
// owner-as-admin shape on managed stacks.
|
|
const app = createApp({
|
|
...ownerAdminActor,
|
|
companyIds: [],
|
|
memberships: [],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ envVars: { A: "1" } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
|
|
"company-1",
|
|
{ A: "1" },
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("still fails closed when no companyId context is resolvable on a multi-company instance", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]);
|
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
|
const app = createApp({
|
|
...ownerAdminActor,
|
|
companyIds: ["company-1", "company-2"],
|
|
memberships: [
|
|
{ companyId: "company-1", status: "active", membershipRole: "owner" },
|
|
{ companyId: "company-2", status: "active", membershipRole: "member" },
|
|
],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ envVars: { A: "1" } });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("requires a companyId context");
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a patch that mixes envVars with any other field on the managed sandbox row", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ envVars: { A: "1" }, name: "Renamed" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an envVars-only patch on a legacy kubernetes-marker row", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
...createPlatformSandboxEnvironment(),
|
|
metadata: {
|
|
managedByPaperclip: true,
|
|
managedSandboxProvider: "kubernetes",
|
|
managedKubernetesSandbox: true,
|
|
},
|
|
});
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ envVars: { A: "1" } });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
|
|
|
|
it("hides the local environment from every read surface under managed-sandbox-only", async () => {
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
|
const localRow = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-local-1",
|
|
name: "Local",
|
|
driver: "local",
|
|
config: {},
|
|
envVars: {},
|
|
metadata: { managedByPaperclip: true, defaultForInstance: true },
|
|
};
|
|
mockEnvironmentService.list.mockResolvedValue([localRow, createPlatformSandboxEnvironment()]);
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const listRes = await request(app).get("/api/companies/company-1/environments");
|
|
expect(listRes.status).toBe(200);
|
|
expect(listRes.body.map((row: { id: string }) => row.id)).toEqual(["env-managed-1"]);
|
|
|
|
mockEnvironmentService.getById.mockResolvedValue(localRow);
|
|
const byIdRes = await request(app).get("/api/environments/env-local-1");
|
|
expect(byIdRes.status).toBe(404);
|
|
});
|
|
|
|
it("keeps the local environment visible when managed-sandbox-only is off", async () => {
|
|
const localRow = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-local-1",
|
|
name: "Local",
|
|
driver: "local",
|
|
config: {},
|
|
envVars: {},
|
|
metadata: { managedByPaperclip: true },
|
|
};
|
|
mockEnvironmentService.list.mockResolvedValue([localRow]);
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const listRes = await request(app).get("/api/companies/company-1/environments");
|
|
expect(listRes.status).toBe(200);
|
|
expect(listRes.body.map((row: { id: string }) => row.id)).toEqual(["env-local-1"]);
|
|
});
|
|
|
|
it("allows a marker-clear-only patch to unblock a row with a stale legacy kubernetes marker", async () => {
|
|
// A sandbox row carrying only the legacy wrapper marker does not hold
|
|
// the managed sandbox slot (`environments_managed_sandbox_idx` keys on
|
|
// `managedByPaperclip`), and with the persisted execution mode not
|
|
// forcing kubernetes nothing selects rows by that marker either — so
|
|
// the marker is a stale leftover, not live platform state.
|
|
const staleRow = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-legacy-1",
|
|
metadata: { managedKubernetesSandbox: true },
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(staleRow);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} });
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-legacy-1")
|
|
.send({ metadata: { managedKubernetesSandbox: false } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows a marker-clear-only patch on a non-slot driver with a stale platform marker", async () => {
|
|
const staleRow = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-stale-ssh-1",
|
|
driver: "ssh",
|
|
metadata: { managedByPaperclip: true },
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(staleRow);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} });
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-stale-ssh-1")
|
|
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses the marker-clear patch on the sandbox slot row while managed provisioning is configured", async () => {
|
|
// With a managed-config `environments` entry, driver=sandbox +
|
|
// managedByPaperclip is THE provisioner-owned slot row, adopted and
|
|
// refreshed on every boot — clearing its markers would reclassify it
|
|
// tenant-managed and let the next PATCH/DELETE bypass the write floor.
|
|
process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_WITH_SANDBOX_ENTRY;
|
|
try {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
} finally {
|
|
delete process.env.PAPERCLIP_MANAGED_CONFIG;
|
|
}
|
|
});
|
|
|
|
it("refuses the marker-clear patch on the sandbox slot row under the forced kubernetes execution mode", async () => {
|
|
// PAPERCLIP_EXECUTION_MODE=kubernetes is the other bootstrap path that
|
|
// owns (adopts and refreshes) the single marked sandbox row.
|
|
process.env.PAPERCLIP_EXECUTION_MODE = "kubernetes";
|
|
try {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
} finally {
|
|
delete process.env.PAPERCLIP_EXECUTION_MODE;
|
|
}
|
|
});
|
|
|
|
it("refuses the marker-clear patch on a kubernetes-marked row while the persisted execution mode forces kubernetes", async () => {
|
|
// `findKubernetesEnvironment` selects sandbox rows by the legacy
|
|
// marker alone whenever the persisted executionMode forces kubernetes
|
|
// — including when the bootstrap env that seeded the setting is gone
|
|
// (rollback / config drift, which the heartbeat handles explicitly).
|
|
// Clearing the marker would declassify the live runtime row.
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "kubernetes" });
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-legacy-1",
|
|
metadata: { managedKubernetesSandbox: true },
|
|
});
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-legacy-1")
|
|
.send({ metadata: { managedKubernetesSandbox: false } });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses the marker-clear patch on the kubernetes-managed row when only the persisted execution mode remains forced", async () => {
|
|
// Drift variant with the fully-stamped managed row: no managed-config
|
|
// entry and no bootstrap env, but the persisted executionMode still
|
|
// forces kubernetes, so the marker keeps selecting this row for runs.
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "kubernetes" });
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
...createPlatformSandboxEnvironment(),
|
|
metadata: {
|
|
managedByPaperclip: true,
|
|
managedSandboxProvider: "kubernetes",
|
|
managedKubernetesSandbox: true,
|
|
},
|
|
});
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows the marker-clear patch on a marked sandbox row when no provisioning path is configured", async () => {
|
|
// Without a managed-config `environments` entry or a forced execution
|
|
// mode, nothing on this instance provisions a sandbox environment, so a
|
|
// platform marker on a sandbox row can only be a stale leftover of the
|
|
// old unrestricted API — the recovery hatch must apply or the row is
|
|
// locked forever.
|
|
const staleRow = createPlatformSandboxEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(staleRow);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} });
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses the marker-clear patch on the managed local row", async () => {
|
|
// The local slot needs no configuration check: on a cloud-managed
|
|
// instance `ensureLocalEnvironment` adopts and stamps the single local
|
|
// row from every caller, so its markers are always live platform state.
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-local-1",
|
|
driver: "local",
|
|
metadata: { managedByPaperclip: true, defaultForInstance: true },
|
|
});
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-local-1")
|
|
.send({ metadata: { managedByPaperclip: false } });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects non-marker-only patches to platform-provisioned rows even from instance admins", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-managed-1")
|
|
.send({ name: "Renamed", metadata: { managedByPaperclip: false } });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects deletes of platform-provisioned rows, including for instance admins", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app).delete("/api/environments/env-managed-1");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
|
expect(mockEnvironmentService.getDeleteBlastRadius).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects creates that stamp platform markers so tenants cannot self-lock rows", async () => {
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Fake managed",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
metadata: { managedByPaperclip: true },
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_marker_reserved" });
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects tenant patches that stamp platform markers so the row cannot become locked", async () => {
|
|
const tenantEnvironment = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-tenant-1",
|
|
metadata: { source: "manual" },
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment);
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-tenant-1")
|
|
.send({ metadata: { source: "manual", managedKubernetesSandbox: true } });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.details).toMatchObject({ code: "environment_platform_marker_reserved" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts platform markers in client payloads on self-hosted instances", async () => {
|
|
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
|
const existing = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-tenant-1",
|
|
metadata: { source: "manual" },
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(existing);
|
|
mockEnvironmentService.update.mockResolvedValue({
|
|
...existing,
|
|
metadata: { source: "manual", managedByPaperclip: true },
|
|
});
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "admin-1",
|
|
source: "session",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-tenant-1")
|
|
.send({ metadata: { source: "manual", managedByPaperclip: true } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it("still updates tenant-created environments for instance admins on cloud-managed instances", async () => {
|
|
const tenantEnvironment = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-tenant-1",
|
|
metadata: { source: "manual" },
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...tenantEnvironment, name: "Renamed" });
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app).patch("/api/environments/env-tenant-1").send({ name: "Renamed" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe("Renamed");
|
|
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not floor writes to platform-marked rows on self-hosted instances", async () => {
|
|
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
|
const existing = createPlatformSandboxEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(existing);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...existing, name: "Renamed" });
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "admin-1",
|
|
source: "session",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const res = await request(app).patch("/api/environments/env-managed-1").send({ name: "Renamed" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe("Renamed");
|
|
});
|
|
|
|
it("leaves tenant-created environments unfloored for instance admins", async () => {
|
|
const tenantEnvironment = {
|
|
...createPlatformSandboxEnvironment(),
|
|
id: "env-tenant-1",
|
|
metadata: { source: "manual" },
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment);
|
|
const app = createApp(ownerAdminActor);
|
|
|
|
const res = await request(app).get("/api/environments/env-tenant-1");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.envVars).toEqual({ MY_AGENT_TOOL_SETTING: "tenant-env-value" });
|
|
expect(res.body.config.apiKey).toBe("config-credential-must-never-echo");
|
|
});
|
|
|
|
it("does not floor platform-marked rows on self-hosted instances", async () => {
|
|
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
|
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "admin-1",
|
|
source: "session",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const res = await request(app).get("/api/environments/env-managed-1");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.envVars).toEqual({ MY_AGENT_TOOL_SETTING: "tenant-env-value" });
|
|
expect(res.body.config.apiKey).toBe("config-credential-must-never-echo");
|
|
});
|
|
});
|
|
|
|
it("rejects non-admin blast-radius reads for instance-scoped environments", async () => {
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-2",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(app).get("/api/environments/env-1/delete-blast-radius");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockEnvironmentService.getDeleteBlastRadius).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns delete blast radius counts for instance admins", async () => {
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
|
agentDefaultCount: 2,
|
|
secretBindingCount: 3,
|
|
activeLeaseCount: 1,
|
|
}));
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "admin-1",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const res = await request(app).get("/api/environments/env-1/delete-blast-radius");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
environmentId: "env-1",
|
|
canDelete: true,
|
|
deleteBlockedReasons: [],
|
|
pendingCleanupLeaseCount: 0,
|
|
reusableSandboxLeaseCount: 0,
|
|
reusableSandboxLeaseHolders: [],
|
|
staticReferences: {
|
|
isManagedLocal: false,
|
|
isInstanceDefault: false,
|
|
agentDefaultCount: 2,
|
|
executionWorkspaceSelectionCount: 0,
|
|
issueSelectionCount: 0,
|
|
projectSelectionCount: 0,
|
|
secretBindingCount: 3,
|
|
},
|
|
activeRuntimeUse: {
|
|
activeLeaseCount: 1,
|
|
activeCustomImageSetupSessionCount: 0,
|
|
hasActiveRuntimeUse: true,
|
|
},
|
|
});
|
|
expect(res.body).not.toHaveProperty("config");
|
|
expect(res.body).not.toHaveProperty("envVars");
|
|
expect(res.body).not.toHaveProperty("metadata");
|
|
});
|
|
|
|
it("returns 404 for missing delete blast radius targets", async () => {
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(null);
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "admin-1",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const res = await request(app).get("/api/environments/missing/delete-blast-radius");
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toBe("Environment not found");
|
|
});
|
|
|
|
it("returns provider capabilities for the company", async () => {
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.drivers.ssh).toBe("supported");
|
|
expect(res.body.sandboxProviders.fake.supportsRunExecution).toBe(false);
|
|
expect(res.body.sandboxProviders).not.toHaveProperty("fake-plugin");
|
|
});
|
|
|
|
it("returns installed plugin-backed sandbox capabilities for environment creation", async () => {
|
|
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([
|
|
{
|
|
pluginId: "plugin-1",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driverKey: "secure-plugin",
|
|
displayName: "Secure Sandbox",
|
|
description: "Provisions schema-driven cloud sandboxes.",
|
|
supportsInteractiveSetup: true,
|
|
interactiveSetupConnectionTypes: ["ssh"],
|
|
supportsTemplateCapture: true,
|
|
templateRefKind: "snapshot",
|
|
templateConfigBinding: {
|
|
field: "template",
|
|
unsetFields: ["image"],
|
|
},
|
|
supportsTemplateDelete: true,
|
|
configSchema: {
|
|
type: "object",
|
|
properties: {
|
|
template: { type: "string" },
|
|
apiKey: { type: "string", format: "secret-ref" },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.sandboxProviders["secure-plugin"]).toMatchObject({
|
|
status: "supported",
|
|
supportsRunExecution: true,
|
|
// The mock manifest omits reusable leases, so the capability defaults to
|
|
// false and agrees with the execution guard.
|
|
supportsReusableLeases: false,
|
|
supportsInteractiveSetup: true,
|
|
interactiveSetupConnectionTypes: ["ssh"],
|
|
supportsTemplateCapture: true,
|
|
templateRefKind: "snapshot",
|
|
templateConfigBinding: {
|
|
field: "template",
|
|
unsetFields: ["image"],
|
|
},
|
|
supportsTemplateDelete: true,
|
|
displayName: "Secure Sandbox",
|
|
source: "plugin",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
pluginId: "plugin-1",
|
|
configSchema: {
|
|
type: "object",
|
|
properties: {
|
|
template: { type: "string" },
|
|
apiKey: { type: "string", format: "secret-ref" },
|
|
},
|
|
},
|
|
});
|
|
expect(res.body.adapters.find((row: any) => row.adapterType === "codex_local").sandboxProviders["secure-plugin"])
|
|
.toBe("supported");
|
|
});
|
|
|
|
it("publishes reusable leases from the nested capability, not the legacy flag, so the API agrees with acquisition", async () => {
|
|
// The manifest sets the legacy flag `true` but the nested override `false`.
|
|
// Acquisition lets the nested value win and refuses reuse. The published
|
|
// API value must derive from the same declaration resolver and present the
|
|
// provider as not reusable, even when the worker verified both lifecycle
|
|
// methods.
|
|
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([
|
|
{
|
|
pluginId: "plugin-1",
|
|
pluginKey: "acme.legacy-override-provider",
|
|
driverKey: "override-plugin",
|
|
displayName: "Override Sandbox",
|
|
supportsReusableLeases: true,
|
|
sandboxCapabilities: { reusableLeases: false },
|
|
reusableLeaseMethodsVerified: true,
|
|
configSchema: { type: "object", properties: {} },
|
|
},
|
|
]);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.sandboxProviders["override-plugin"].supportsReusableLeases).toBe(false);
|
|
});
|
|
|
|
it("publishes reusable leases from the legacy flag when the manifest omits the nested override and the worker verified both methods", async () => {
|
|
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([
|
|
{
|
|
pluginId: "plugin-1",
|
|
pluginKey: "acme.legacy-only-provider",
|
|
driverKey: "legacy-plugin",
|
|
displayName: "Legacy Sandbox",
|
|
supportsReusableLeases: true,
|
|
reusableLeaseMethodsVerified: true,
|
|
configSchema: { type: "object", properties: {} },
|
|
},
|
|
]);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.sandboxProviders["legacy-plugin"].supportsReusableLeases).toBe(true);
|
|
});
|
|
|
|
it("does not publish reusable leases when the declaration allows them but the worker omits a lifecycle method", async () => {
|
|
// A positive declaration alone is not enough. Acquisition verifies both
|
|
// reuse lifecycle methods live and falls back to an ephemeral lease when one
|
|
// is missing. The published value must agree and present as not reusable.
|
|
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([
|
|
{
|
|
pluginId: "plugin-1",
|
|
pluginKey: "acme.unverified-reuse-provider",
|
|
driverKey: "unverified-plugin",
|
|
displayName: "Unverified Reuse Sandbox",
|
|
sandboxCapabilities: { reusableLeases: true },
|
|
reusableLeaseMethodsVerified: false,
|
|
configSchema: { type: "object", properties: {} },
|
|
},
|
|
]);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.sandboxProviders["unverified-plugin"].supportsReusableLeases).toBe(false);
|
|
});
|
|
|
|
it("rejects agent list reads for instance-scoped environments", async () => {
|
|
mockEnvironmentService.list.mockResolvedValue([createEnvironment()]);
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
role: "engineer",
|
|
permissions: { canCreateAgents: false },
|
|
});
|
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
|
const app = createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Board access required");
|
|
expect(mockEnvironmentService.list).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects agent detail reads for instance-scoped environments", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
role: "cto",
|
|
permissions: { canCreateAgents: true },
|
|
});
|
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
|
const app = createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await request(app).get("/api/environments/env-1");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Board access required");
|
|
});
|
|
|
|
it("creates an environment and logs activity", async () => {
|
|
const environment = createEnvironment();
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Local",
|
|
driver: "local",
|
|
description: "Current development machine",
|
|
config: { shell: "zsh" },
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockEnvironmentService.create).toHaveBeenCalledWith({
|
|
name: "Local",
|
|
driver: "local",
|
|
description: "Current development machine",
|
|
status: "active",
|
|
config: { shell: "zsh" },
|
|
envVars: {},
|
|
}, undefined, { db: routeDbTx });
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
companyId: "company-1",
|
|
actorType: "user",
|
|
actorId: "user-1",
|
|
agentId: null,
|
|
runId: null,
|
|
action: "environment.created",
|
|
entityType: "environment",
|
|
entityId: environment.id,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns conflict when creating a second local environment", async () => {
|
|
mockEnvironmentService.list.mockResolvedValue([createEnvironment()]);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Another Local",
|
|
driver: "local",
|
|
config: {},
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toBe("A local environment already exists for this instance.");
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects non-admin board users even when they have company environment permissions", async () => {
|
|
const environment = createEnvironment();
|
|
mockAccessService.canUser.mockResolvedValue(true);
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Local",
|
|
driver: "local",
|
|
config: {},
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Instance admin access required");
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects non-admin board users without instance admin access", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Local",
|
|
driver: "local",
|
|
config: {},
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Instance admin access required");
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects agent environment creation even with explicit company grants", async () => {
|
|
const environment = createEnvironment();
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
role: "engineer",
|
|
permissions: { canCreateAgents: false },
|
|
});
|
|
mockAccessService.hasPermission.mockResolvedValue(true);
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
const app = createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Local",
|
|
driver: "local",
|
|
config: {},
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("board operators");
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects deleting the managed local environment", async () => {
|
|
const environment = createEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
|
isManagedLocal: true,
|
|
}));
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-1");
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toBe("Cannot delete the managed local environment.");
|
|
expect(res.body.details).toEqual({ deleteBlockedReasons: ["managed_local"] });
|
|
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
|
expect(mockExecutionWorkspaceService.clearEnvironmentSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects deleting the current instance default environment", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
driver: "ssh" as const,
|
|
name: "SSH Fixture",
|
|
config: createSshConfig(),
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
|
isInstanceDefault: true,
|
|
}));
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-1");
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toBe(
|
|
"Cannot delete the current instance default environment. Set a new default environment before deleting this one.",
|
|
);
|
|
expect(res.body.details).toEqual({ deleteBlockedReasons: ["instance_default"] });
|
|
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects deleting an environment with a pending sandbox cleanup", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
driver: "ssh" as const,
|
|
name: "SSH Fixture",
|
|
config: createSshConfig(),
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
|
pendingCleanupLeaseCount: 1,
|
|
}));
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-1");
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toBe(
|
|
"Cannot delete this environment while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry.",
|
|
);
|
|
expect(res.body.details).toEqual({ deleteBlockedReasons: ["pending_sandbox_cleanup"] });
|
|
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects deleting an environment with a live reusable sandbox lease", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
driver: "sandbox" as const,
|
|
name: "Reusable Sandbox Fixture",
|
|
config: {
|
|
provider: "fake-plugin",
|
|
image: "fixture:test",
|
|
reuseLease: true,
|
|
},
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
|
activeLeaseCount: 1,
|
|
reusableSandboxLeaseCount: 1,
|
|
}));
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-1");
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toBe(
|
|
"Cannot delete this environment while it has a reusable sandbox lease. Remove the associated execution workspace or issue so Paperclip can destroy the sandbox, then retry.",
|
|
);
|
|
expect(res.body.details).toEqual({ deleteBlockedReasons: ["reusable_sandbox_lease"] });
|
|
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("destroys reusable sandbox leases and deletes with explicit consent", async () => {
|
|
const environment = createEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius
|
|
.mockResolvedValueOnce(createDeleteBlastRadius({ reusableSandboxLeaseCount: 2 }))
|
|
.mockResolvedValueOnce(createDeleteBlastRadius());
|
|
mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockResolvedValue({
|
|
destroyed: 2,
|
|
failed: 0,
|
|
skippedLiveRun: 0,
|
|
});
|
|
mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment);
|
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(
|
|
mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment,
|
|
).toHaveBeenCalledExactlyOnceWith({
|
|
environmentId: "env-1",
|
|
failureReason: "environment_deleted",
|
|
});
|
|
expect(
|
|
mockCloseWarmNativeSessionsForEnvironment,
|
|
).toHaveBeenCalledExactlyOnceWith({
|
|
environmentId: "env-1",
|
|
reason: "environment deleted",
|
|
});
|
|
expect(mockEnvironmentService.removeIfDeletable).toHaveBeenCalledWith(
|
|
"env-1",
|
|
);
|
|
expect(res.body.destroyedReusableSandboxLeaseCount).toBe(2);
|
|
});
|
|
|
|
it("keeps rejecting a consented delete when a non-lease blocker remains", async () => {
|
|
const environment = createEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
|
isInstanceDefault: true,
|
|
reusableSandboxLeaseCount: 1,
|
|
}));
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true");
|
|
|
|
// Destroying provider sandboxes and then rejecting on the other gate would
|
|
// be an irreversible action with nothing gained, so the destroy must not run.
|
|
expect(res.status).toBe(409);
|
|
expect(mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps rejecting when leases survive the consented destroy", async () => {
|
|
const environment = createEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius
|
|
.mockResolvedValueOnce(createDeleteBlastRadius({ reusableSandboxLeaseCount: 1 }))
|
|
.mockResolvedValueOnce(createDeleteBlastRadius({ pendingCleanupLeaseCount: 1 }));
|
|
mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockResolvedValue({
|
|
destroyed: 1,
|
|
failed: 1,
|
|
skippedLiveRun: 0,
|
|
});
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true");
|
|
|
|
expect(res.status).toBe(409);
|
|
// The rejection names what the consented destroy already did: provider
|
|
// destruction is not transactional with the delete guard.
|
|
expect(res.body.details).toEqual({
|
|
deleteBlockedReasons: ["pending_sandbox_cleanup"],
|
|
destroyedReusableSandboxLeaseCount: 1,
|
|
});
|
|
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a driver or provider config change while a sandbox cleanup is pending", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
id: "env-ssh",
|
|
driver: "ssh" as const,
|
|
name: "SSH Fixture",
|
|
config: createSshConfig(),
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true);
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-ssh")
|
|
.send({
|
|
config: {
|
|
host: "changed.example.test",
|
|
port: 22,
|
|
username: "ssh-user",
|
|
remoteWorkspacePath: "/srv/paperclip/workspace",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toBe(
|
|
"Cannot change the driver or provider config while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry.",
|
|
);
|
|
expect(res.body.details).toEqual({ code: "environment_pending_sandbox_cleanup" });
|
|
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows a non-provider update while a sandbox cleanup is pending", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
id: "env-ssh",
|
|
driver: "ssh" as const,
|
|
name: "SSH Fixture",
|
|
config: createSshConfig(),
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true);
|
|
mockEnvironmentService.update.mockResolvedValue({ ...environment, description: "Updated" });
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-ssh")
|
|
.send({ description: "Updated" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.hasUnresolvedPendingCleanupLeases).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it("describes an environment's secret refs with owner metadata", async () => {
|
|
const secretId = "22222222-2222-2222-2222-222222222222";
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
...createEnvironment(),
|
|
id: "env-sandbox",
|
|
name: "Daytona",
|
|
driver: "sandbox" as const,
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: secretId,
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
mockSecretService.describeSecretRefs.mockResolvedValue([
|
|
{
|
|
configPath: "apiKey",
|
|
secretId,
|
|
name: "DAYTONA_API_KEY",
|
|
status: "active",
|
|
companyId: "company-2",
|
|
companyName: "Other Team",
|
|
},
|
|
]);
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).get("/api/environments/env-sandbox/secret-refs");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.refs).toEqual([
|
|
{
|
|
configPath: "apiKey",
|
|
secretId,
|
|
name: "DAYTONA_API_KEY",
|
|
status: "active",
|
|
companyId: "company-2",
|
|
companyName: "Other Team",
|
|
},
|
|
]);
|
|
expect(mockSecretService.describeSecretRefs).toHaveBeenCalledWith([
|
|
{ secretId, configPath: "apiKey", versionSelector: "latest" },
|
|
]);
|
|
});
|
|
|
|
it("denies secret-ref descriptors to agents without instance environment access", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
role: "engineer",
|
|
permissions: { canCreateAgents: false },
|
|
});
|
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
|
const app = createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
});
|
|
|
|
const res = await request(app).get("/api/environments/env-1/secret-refs");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.describeSecretRefs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears environment selections and secret bindings across all companies when deleting an environment", async () => {
|
|
const environment = createSecretBackedSshEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius());
|
|
mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment);
|
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
|
const app = createApp(localBoardActor("admin-1"));
|
|
|
|
const res = await request(app).delete("/api/environments/env-ssh");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.removeIfDeletable).toHaveBeenCalledWith("env-ssh");
|
|
for (const companyId of ["company-1", "company-2"]) {
|
|
expect(mockExecutionWorkspaceService.clearEnvironmentSelection)
|
|
.toHaveBeenCalledWith(companyId, "env-ssh");
|
|
expect(mockIssueService.clearExecutionWorkspaceEnvironmentSelection)
|
|
.toHaveBeenCalledWith(companyId, "env-ssh");
|
|
expect(mockProjectService.clearExecutionWorkspaceEnvironmentSelection)
|
|
.toHaveBeenCalledWith(companyId, "env-ssh");
|
|
expect(mockSecretService.syncEnvBindingsForTarget).toHaveBeenCalledWith(
|
|
companyId,
|
|
{ targetType: "environment", targetId: "env-ssh" },
|
|
{},
|
|
);
|
|
expect(mockSecretService.syncSecretRefsForTarget).toHaveBeenCalledWith(
|
|
companyId,
|
|
{ targetType: "environment", targetId: "env-ssh" },
|
|
[],
|
|
{ replaceAll: true },
|
|
);
|
|
}
|
|
expect(mockSecretService.remove).toHaveBeenCalledWith("11111111-1111-1111-1111-111111111111");
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
companyId: "company-1",
|
|
action: "environment.deleted",
|
|
entityType: "environment",
|
|
entityId: "env-ssh",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects invalid SSH config on create", async () => {
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "SSH Fixture",
|
|
driver: "ssh",
|
|
config: {
|
|
host: "ssh.example.test",
|
|
username: "ssh-user",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("remote workspace path");
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("normalizes SSH private keys into secret refs before persistence", async () => {
|
|
const environment = createSecretBackedSshEnvironment();
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
const pluginWorkerManager = {};
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "SSH Fixture",
|
|
driver: "ssh",
|
|
config: {
|
|
host: "ssh.example.test",
|
|
username: "ssh-user",
|
|
remoteWorkspacePath: "/srv/paperclip/workspace",
|
|
privateKey: " super-secret-key ",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockEnvironmentService.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
config: expect.objectContaining({
|
|
privateKey: null,
|
|
privateKeySecretRef: {
|
|
type: "secret_ref",
|
|
secretId: "11111111-1111-1111-1111-111111111111",
|
|
version: "latest",
|
|
},
|
|
}),
|
|
envVars: {},
|
|
}), undefined, { db: routeDbTx });
|
|
expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][0])).not.toContain("super-secret-key");
|
|
expect(mockSecretService.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
provider: "local_encrypted",
|
|
value: "super-secret-key",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("uses the configured provider for SSH private key secret materialization", async () => {
|
|
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
|
|
const environment = createSecretBackedSshEnvironment();
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "SSH Fixture",
|
|
driver: "ssh",
|
|
config: {
|
|
host: "ssh.example.test",
|
|
username: "ssh-user",
|
|
remoteWorkspacePath: "/srv/paperclip/workspace",
|
|
privateKey: "super-secret-key",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockSecretService.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
provider: "aws_secrets_manager",
|
|
value: "super-secret-key",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("rejects persisted fake sandbox environments", async () => {
|
|
const app = createApp(localBoardActor());
|
|
|
|
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 pluginWorkerManager = {};
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
|
|
|
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(mockValidatePluginSandboxProviderConfig).toHaveBeenCalledWith({
|
|
db: expect.anything(),
|
|
workerManager: pluginWorkerManager,
|
|
provider: "fake-plugin",
|
|
config: {
|
|
image: "fake:test",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
expect(mockEnvironmentService.create).toHaveBeenCalledWith({
|
|
name: "Fake plugin Sandbox",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: {
|
|
provider: "fake-plugin",
|
|
image: "fake:test",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
envVars: {},
|
|
}, undefined, { db: routeDbTx });
|
|
expect(mockSecretService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps host-owned sandbox flags and drops a removed flag a saved config still carries", async () => {
|
|
// The host owns run-log streaming and runner lifecycle. A provider plugin
|
|
// may normalize only its own driver fields, so it drops these host flags.
|
|
// The host must re-apply them or a saved warm environment silently becomes
|
|
// per-turn at execution time.
|
|
//
|
|
// `streamAgentSessionOutput` is a removed operator flag. A saved config can
|
|
// still carry it, but session-output streaming now follows the capability
|
|
// snapshot alone. The removed key must load and then drop, so it never
|
|
// reaches the persisted config.
|
|
const environment = {
|
|
...createEnvironment(),
|
|
id: "env-sandbox-fake-plugin",
|
|
name: "Streamed Sandbox",
|
|
driver: "sandbox" as const,
|
|
config: { provider: "fake-plugin", image: "fake:test" },
|
|
};
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
mockValidatePluginSandboxProviderConfig.mockImplementation(
|
|
async ({ provider, config }) => {
|
|
// Drop the host flag to reproduce a plugin that allowlists driver fields.
|
|
const {
|
|
streamRunLogs,
|
|
runnerLifecycleMode,
|
|
runnerIdleTimeoutMs,
|
|
...driverConfig
|
|
} = config as Record<string, unknown>;
|
|
void streamRunLogs;
|
|
void runnerLifecycleMode;
|
|
void runnerIdleTimeoutMs;
|
|
return {
|
|
normalizedConfig: driverConfig,
|
|
pluginId: `plugin-${provider}`,
|
|
pluginKey: `plugin.${provider}`,
|
|
driver: {
|
|
driverKey: provider,
|
|
kind: "sandbox_provider",
|
|
displayName: provider,
|
|
configSchema: { type: "object" },
|
|
},
|
|
};
|
|
},
|
|
);
|
|
const pluginWorkerManager = {};
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Streamed Sandbox",
|
|
driver: "sandbox",
|
|
config: {
|
|
provider: "fake-plugin",
|
|
image: "fake:test",
|
|
streamRunLogs: false,
|
|
runnerLifecycleMode: "warm",
|
|
runnerIdleTimeoutMs: 180_000,
|
|
streamAgentSessionOutput: true,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const persisted = mockEnvironmentService.create.mock.calls[0][0].config as Record<string, unknown>;
|
|
// The removed key never reaches the persisted config.
|
|
expect(persisted.streamAgentSessionOutput).toBeUndefined();
|
|
expect(persisted.streamRunLogs).toBe(false);
|
|
expect(persisted.runnerLifecycleMode).toBe("warm");
|
|
expect(persisted.runnerIdleTimeoutMs).toBe(180_000);
|
|
});
|
|
|
|
it("creates a schema-driven sandbox environment with secret-ref fields persisted as secrets", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
id: "env-sandbox-secure-plugin",
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox" as const,
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
};
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
|
|
normalizedConfig: {
|
|
template: "base",
|
|
apiKey: "test-provider-key",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
pluginId: "plugin-secure",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driver: createSecurePluginDriver(),
|
|
});
|
|
const pluginWorkerManager = {};
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox",
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: " base ",
|
|
apiKey: " test-provider-key ",
|
|
timeoutMs: "450000",
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockValidatePluginSandboxProviderConfig).toHaveBeenCalledWith({
|
|
db: expect.anything(),
|
|
workerManager: pluginWorkerManager,
|
|
provider: "secure-plugin",
|
|
config: {
|
|
template: " base ",
|
|
apiKey: " test-provider-key ",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
expect(mockEnvironmentService.create).toHaveBeenCalledWith({
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
envVars: {},
|
|
}, undefined, { db: routeDbTx });
|
|
expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][0])).not.toContain("test-provider-key");
|
|
expect(mockSecretService.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
provider: "local_encrypted",
|
|
value: "test-provider-key",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("persists a picker-submitted secret_ref binding object as the bare secret id", async () => {
|
|
const secretId = "11111111-1111-1111-1111-111111111111";
|
|
const environment = {
|
|
...createEnvironment(),
|
|
id: "env-sandbox-secure-plugin",
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox" as const,
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: secretId,
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
};
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
mockValidatePluginSandboxProviderConfig.mockImplementation(async ({ config }: { config: Record<string, unknown> }) => ({
|
|
normalizedConfig: { ...config },
|
|
pluginId: "plugin-secure",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driver: createSecurePluginDriver(),
|
|
}));
|
|
const pluginWorkerManager = {};
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox",
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: { type: "secret_ref", secretId, version: "latest" },
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockEnvironmentService.create).toHaveBeenCalledWith({
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: secretId,
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
envVars: {},
|
|
}, undefined, { db: routeDbTx });
|
|
expect(mockSecretService.create).not.toHaveBeenCalled();
|
|
expect(mockSecretService.replaceSecretRefsForInstanceTarget).toHaveBeenCalledWith(
|
|
{ targetType: "environment", targetId: "env-sandbox-secure-plugin" },
|
|
[{ secretId, configPath: "apiKey", versionSelector: "latest" }],
|
|
{ db: routeDbTx },
|
|
);
|
|
});
|
|
|
|
it("uses the configured provider for schema-driven sandbox secret fields", async () => {
|
|
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
|
|
const environment = {
|
|
...createEnvironment(),
|
|
id: "env-sandbox-secure-plugin",
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox" as const,
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
};
|
|
mockEnvironmentService.create.mockResolvedValue(environment);
|
|
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
|
|
normalizedConfig: {
|
|
template: "base",
|
|
apiKey: "test-provider-key",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
pluginId: "plugin-secure",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driver: createSecurePluginDriver(),
|
|
});
|
|
const pluginWorkerManager = {};
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Secure Sandbox",
|
|
driver: "sandbox",
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: "test-provider-key",
|
|
timeoutMs: "450000",
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockSecretService.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
provider: "aws_secrets_manager",
|
|
value: "test-provider-key",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
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(localBoardActor(), { 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(expect.objectContaining({
|
|
config: environment.config,
|
|
envVars: {},
|
|
}), undefined, { db: routeDbTx });
|
|
});
|
|
|
|
it("rejects agent mutations for instance-scoped environments", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
role: "engineer",
|
|
permissions: { canCreateAgents: false },
|
|
});
|
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
|
const app = createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments")
|
|
.send({
|
|
name: "Sandbox host",
|
|
driver: "local",
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("board operators");
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists leases for an environment after company access is confirmed", async () => {
|
|
const environment = createEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.listLeases.mockResolvedValue([
|
|
{
|
|
id: "lease-1",
|
|
companyId: "company-1",
|
|
environmentId: environment.id,
|
|
executionWorkspaceId: "workspace-1",
|
|
issueId: null,
|
|
heartbeatRunId: null,
|
|
status: "active",
|
|
providerLeaseId: "provider-lease-1",
|
|
acquiredAt: new Date("2026-04-16T05:00:00.000Z"),
|
|
lastUsedAt: new Date("2026-04-16T05:05:00.000Z"),
|
|
expiresAt: null,
|
|
releasedAt: null,
|
|
metadata: { provider: "fake" },
|
|
createdAt: new Date("2026-04-16T05:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-16T05:05:00.000Z"),
|
|
},
|
|
]);
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get(`/api/environments/${environment.id}/leases?status=active`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.listLeases).toHaveBeenCalledWith(environment.id, {
|
|
status: "active",
|
|
});
|
|
});
|
|
|
|
it("returns a single lease after company access is confirmed", 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"),
|
|
});
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app).get("/api/environment-leases/lease-1");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.provider).toBe("ssh");
|
|
expect(mockEnvironmentService.getLeaseById).toHaveBeenCalledWith("lease-1");
|
|
});
|
|
|
|
it("rejects agent access regardless of company when environment management is instance-scoped", async () => {
|
|
mockEnvironmentService.list.mockResolvedValue([]);
|
|
const app = createApp({
|
|
type: "agent",
|
|
agentId: "agent-2",
|
|
companyId: "company-2",
|
|
source: "agent_key",
|
|
runId: "run-2",
|
|
});
|
|
|
|
const res = await request(app).get("/api/companies/company-1/environments");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Board access required");
|
|
expect(mockEnvironmentService.list).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("logs a redacted update summary instead of raw config or metadata", async () => {
|
|
const environment = createEnvironment();
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.update.mockResolvedValue({
|
|
...environment,
|
|
status: "archived",
|
|
});
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/environments/${environment.id}?companyId=company-1`)
|
|
.send({
|
|
status: "archived",
|
|
config: {
|
|
apiKey: "super-secret",
|
|
token: "another-secret",
|
|
},
|
|
metadata: {
|
|
password: "do-not-log",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "environment.updated",
|
|
details: {
|
|
changedFields: ["config", "metadata", "status"],
|
|
status: "archived",
|
|
configChanged: true,
|
|
configTopLevelKeyCount: expect.any(Number),
|
|
metadataChanged: true,
|
|
metadataTopLevelKeyCount: 1,
|
|
},
|
|
}),
|
|
);
|
|
expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("super-secret");
|
|
expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("do-not-log");
|
|
});
|
|
|
|
it("resets config instead of inheriting SSH secrets when switching to local without an explicit config", 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: "super-secret-key",
|
|
knownHosts: "known-host",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockEnvironmentService.update.mockResolvedValue({
|
|
...createEnvironment(),
|
|
driver: "local" as const,
|
|
config: {},
|
|
});
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/environments/${environment.id}?companyId=company-1`)
|
|
.send({
|
|
driver: "local",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.update).toHaveBeenCalledWith(environment.id, {
|
|
driver: "local",
|
|
config: {},
|
|
}, { db: routeDbTx });
|
|
expect(JSON.stringify(mockEnvironmentService.update.mock.calls[0][1])).not.toContain("super-secret-key");
|
|
expect(JSON.stringify(mockEnvironmentService.update.mock.calls[0][1])).not.toContain("known-host");
|
|
});
|
|
|
|
it("re-points a sandbox secret ref to another company's secret even when existing bindings disagree", async () => {
|
|
const oldSecretId = "11111111-1111-1111-1111-111111111111";
|
|
const newSecretId = "22222222-2222-2222-2222-222222222222";
|
|
const existing = {
|
|
...createEnvironment(),
|
|
id: "env-sandbox",
|
|
name: "Daytona",
|
|
driver: "sandbox" as const,
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: oldSecretId,
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
};
|
|
const updated = {
|
|
...existing,
|
|
config: { ...existing.config, apiKey: newSecretId },
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(existing);
|
|
mockEnvironmentService.update.mockResolvedValue(updated);
|
|
mockValidatePluginSandboxProviderConfig.mockImplementation(async ({ config }: { config: Record<string, unknown> }) => ({
|
|
normalizedConfig: { ...config },
|
|
pluginId: "plugin-secure",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driver: createSecurePluginDriver(),
|
|
}));
|
|
// The environment's only binding still lives in another company. Before
|
|
// bindings moved with the referenced secret, this state made every save
|
|
// from the caller's company fail, with no route-level way out.
|
|
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue(["company-old"]);
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager: {} });
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-sandbox?companyId=company-new")
|
|
.send({
|
|
config: {
|
|
apiKey: { type: "secret_ref", secretId: newSecretId, version: "latest" },
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.update).toHaveBeenCalledWith(
|
|
"env-sandbox",
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({ apiKey: newSecretId }),
|
|
}),
|
|
{ db: routeDbTx },
|
|
);
|
|
expect(mockSecretService.replaceSecretRefsForInstanceTarget).toHaveBeenCalledWith(
|
|
{ targetType: "environment", targetId: "env-sandbox" },
|
|
[{ secretId: newSecretId, configPath: "apiKey", versionSelector: "latest" }],
|
|
{ db: routeDbTx },
|
|
);
|
|
// Explicit caller context wins outright; stale bindings are never consulted.
|
|
expect(mockSecretService.listBindingCompanyIdsForTarget).not.toHaveBeenCalled();
|
|
expect(mockSecretService.syncSecretRefsForTarget).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails the whole save when a referenced secret cannot be bound", async () => {
|
|
const existing = {
|
|
...createEnvironment(),
|
|
id: "env-sandbox",
|
|
name: "Daytona",
|
|
driver: "sandbox" as const,
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
timeoutMs: 450000,
|
|
reuseLease: true,
|
|
},
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(existing);
|
|
mockEnvironmentService.update.mockResolvedValue({
|
|
...existing,
|
|
config: { ...existing.config, apiKey: "33333333-3333-3333-3333-333333333333" },
|
|
});
|
|
mockSecretService.replaceSecretRefsForInstanceTarget.mockRejectedValue(
|
|
unprocessable("Secret referenced at apiKey was not found", { code: "secret_missing" }),
|
|
);
|
|
const app = createApp(localBoardActor(), { pluginWorkerManager: {} });
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-sandbox?companyId=company-1")
|
|
.send({
|
|
config: {
|
|
apiKey: { type: "secret_ref", secretId: "33333333-3333-3333-3333-333333333333", version: "latest" },
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("was not found");
|
|
});
|
|
|
|
it("requires explicit SSH config when switching from local to SSH", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
|
|
const app = createApp(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-1?companyId=company-1")
|
|
.send({
|
|
driver: "ssh",
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("host");
|
|
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(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/env-1?companyId=company-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(localBoardActor());
|
|
|
|
const res = await request(app)
|
|
.patch("/api/environments/missing-env")
|
|
.send({ status: "archived" });
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toBe("Environment not found");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("probes an SSH environment and logs the result", 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,
|
|
knownHosts: null,
|
|
strictHostKeyChecking: 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: {
|
|
host: "ssh.example.test",
|
|
},
|
|
});
|
|
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.ok).toBe(true);
|
|
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
|
|
// The instance has exactly one company, so the secret-context fallback
|
|
// resolves it even though the actor carries no memberships.
|
|
companyId: "company-1",
|
|
pluginWorkerManager: undefined,
|
|
applyCustomImageTemplate: false,
|
|
acquireSandboxRuntimeLease: false,
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
companyId: "company-1",
|
|
action: "environment.probed",
|
|
entityType: "environment",
|
|
entityId: environment.id,
|
|
details: expect.objectContaining({
|
|
driver: "ssh",
|
|
ok: true,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("requires explicit companyId when probing a secret-backed environment without inferable secret context", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
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);
|
|
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]);
|
|
// A multi-company instance keeps the context genuinely ambiguous — a
|
|
// single-company instance would resolve via the instance fallback.
|
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
companyIds: ["company-1", "company-2"],
|
|
memberships: [
|
|
{ companyId: "company-1", status: "active", membershipRole: "member" },
|
|
{ companyId: "company-2", status: "active", membershipRole: "member" },
|
|
],
|
|
isInstanceAdmin: true,
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/environments/${environment.id}/probe`)
|
|
.send({});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("explicit companyId");
|
|
expect(mockProbeEnvironment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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: "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, {
|
|
companyId: "company-1",
|
|
pluginWorkerManager: undefined,
|
|
applyCustomImageTemplate: true,
|
|
acquireSandboxRuntimeLease: true,
|
|
});
|
|
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 saved sandbox environments with the active custom image template without company context", async () => {
|
|
const environment = {
|
|
...createEnvironment(),
|
|
id: "env-sandbox",
|
|
name: "Daytona Sandbox",
|
|
driver: "sandbox" as const,
|
|
config: {
|
|
provider: "daytona",
|
|
image: "ubuntu:24.04",
|
|
reuseLease: true,
|
|
},
|
|
};
|
|
mockEnvironmentService.getById.mockResolvedValue(environment);
|
|
mockProbeEnvironment.mockResolvedValue({
|
|
ok: true,
|
|
driver: "sandbox",
|
|
summary: "Connected to Daytona sandbox.",
|
|
details: {
|
|
provider: "daytona",
|
|
snapshot: "captured-template",
|
|
},
|
|
});
|
|
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, {
|
|
companyId: "company-1",
|
|
pluginWorkerManager: undefined,
|
|
applyCustomImageTemplate: true,
|
|
acquireSandboxRuntimeLease: 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 pluginWorkerManager = {};
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "local_implicit",
|
|
runId: "run-1",
|
|
}, { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments/probe-config")
|
|
.send({
|
|
name: "Draft Fake plugin",
|
|
driver: "sandbox",
|
|
config: {
|
|
provider: "fake-plugin",
|
|
template: "base",
|
|
apiKey: "unsaved-test-key",
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
expect(mockSecretService.create).not.toHaveBeenCalled();
|
|
expect(mockProbeEnvironment).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
id: "unsaved",
|
|
driver: "sandbox",
|
|
config: expect.objectContaining({
|
|
apiKey: "unsaved-test-key",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
pluginWorkerManager,
|
|
resolvedConfig: expect.objectContaining({
|
|
driver: "sandbox",
|
|
}),
|
|
}),
|
|
);
|
|
expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("unsaved-test-key");
|
|
});
|
|
|
|
it("resolves selected secret refs before probing unsaved provider config", async () => {
|
|
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
|
|
normalizedConfig: {
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
pluginId: "plugin-secure",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driver: createSecurePluginDriver(),
|
|
});
|
|
mockProbeEnvironment.mockResolvedValue({
|
|
ok: true,
|
|
driver: "sandbox",
|
|
summary: "Secure sandbox provider is ready.",
|
|
details: { provider: "secure-plugin" },
|
|
});
|
|
const pluginWorkerManager = {};
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "local_implicit",
|
|
runId: "run-1",
|
|
}, { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments/probe-config")
|
|
.send({
|
|
name: "Draft Secure Sandbox",
|
|
driver: "sandbox",
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
|
expect(mockSecretService.create).not.toHaveBeenCalled();
|
|
expect(mockSecretService.resolveSecretValueForEphemeralAccess).toHaveBeenCalledWith(
|
|
"company-1",
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"latest",
|
|
{
|
|
consumerType: "system",
|
|
consumerId: "environment-probe-config",
|
|
configPath: "apiKey",
|
|
actorType: "user",
|
|
actorId: "user-1",
|
|
actorSource: "local_implicit",
|
|
heartbeatRunId: "run-1",
|
|
},
|
|
);
|
|
expect(mockProbeEnvironment).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
id: "unsaved",
|
|
driver: "sandbox",
|
|
config: expect.objectContaining({
|
|
apiKey: "resolved-provider-key",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
pluginWorkerManager,
|
|
resolvedConfig: expect.objectContaining({
|
|
driver: "sandbox",
|
|
config: expect.objectContaining({
|
|
apiKey: "resolved-provider-key",
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("resolved-provider-key");
|
|
});
|
|
|
|
it("rejects sandbox draft probes for non-admin board users", async () => {
|
|
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
|
|
normalizedConfig: {
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
},
|
|
pluginId: "plugin-secure",
|
|
pluginKey: "acme.secure-sandbox-provider",
|
|
driver: {
|
|
driverKey: "secure-plugin",
|
|
kind: "sandbox_provider",
|
|
displayName: "Secure Sandbox",
|
|
configSchema: {
|
|
type: "object",
|
|
properties: {
|
|
template: { type: "string" },
|
|
apiKey: { type: "string", format: "secret-ref" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
mockAccessService.canUser.mockResolvedValue(true);
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: false,
|
|
explanation: "Missing permission: secrets:read",
|
|
});
|
|
const pluginWorkerManager = {};
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "user-2",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
|
|
}, { pluginWorkerManager });
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/environments/probe-config")
|
|
.send({
|
|
name: "Draft Secure Sandbox",
|
|
driver: "sandbox",
|
|
config: {
|
|
provider: "secure-plugin",
|
|
template: "base",
|
|
apiKey: "11111111-1111-1111-1111-111111111111",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Instance admin access required");
|
|
expect(mockSecretService.resolveSecretValueForEphemeralAccess).not.toHaveBeenCalled();
|
|
expect(mockProbeEnvironment).not.toHaveBeenCalled();
|
|
});
|
|
});
|