mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Confinement providers protect agent runs with default-deny network policies > - Kubernetes environments currently apply only provider-level, namespace-wide egress allowances > - Tasks that legitimately need GitHub or package registries therefore cannot request narrow access, while network failures do not explain the governing policy or how to request a grant > - This pull request adds issue-scoped egress grants that become workload-owned, run-label-selected policies and carries the effective grant through lease audit metadata > - The benefit is that internet-dependent work can run without enabling broad egress for every concurrent task, and denied requests point operators to the exact grant path ## Linked Issues or Issue Description No public issue exists. Related but distinct: Refs #9944, which adds a provider-wide open-internet posture; this PR keeps provider defaults narrow and adds per-task grants. **Problem / motivation** Kubernetes sandbox egress is configured at the provider/tenant level. A task that needs to clone from GitHub or install from PyPI cannot request those destinations without changing the policy for every run in the tenant namespace. DNS/connectivity failures also surface as generic tool errors with no policy name or remediation path. **Proposed solution** Accept `executionWorkspaceSettings.networkEgress.allowFqdns` and `allowCidrs`, forward the setting through heartbeat environment acquisition, and create a workload-owned NetworkPolicy or CiliumNetworkPolicy selected by `paperclip.io/run-id`. Record the effective grant in lease activity/metadata, expose policy context through `PAPERCLIP_NETWORK_EGRESS_*`, and append the grant path to likely policy-related stderr failures. **Alternatives considered** A provider-wide open-internet switch is broader than required and is already covered by #9944. Mutating the existing namespace policy would leak each task's destinations to other concurrent runs. Standard Kubernetes NetworkPolicy cannot enforce FQDNs exactly, so standard mode uses the existing hardened public-IPv4 TCP 80/443 fallback only for the selected run; Cilium mode remains exact. **Roadmap alignment** This extends the existing cloud/sandbox agent roadmap capability with task-level control-plane policy and does not duplicate a planned roadmap item. ## What Changed - Added validated `networkEgress` grants to issue execution workspace settings and forwarded them through environment lease acquisition. - Added workload-owned, run-label-scoped NetworkPolicy/CiliumNetworkPolicy resources for task FQDN/CIDR grants. - Added lease audit metadata, sandbox policy environment variables, and actionable network-denial stderr guidance. - Added focused parser, manifest, policy creation, and denial-message tests plus Kubernetes provider documentation. ## Verification - `pnpm -C packages/shared exec vitest run src/validators/issue.test.ts` — 27 passed. - `pnpm -C packages/plugins/sandbox-providers/kubernetes test -- --run test/unit/network-policy.test.ts test/unit/cilium-network-policy.test.ts test/unit/scoped-network-egress.test.ts` — 21 passed. - `pnpm -C server exec vitest run src/__tests__/execution-workspace-policy.test.ts` — 15 passed. - `pnpm exec vitest run server/src/__tests__/heartbeat-plugin-environment.test.ts server/src/__tests__/environment-runtime.test.ts` — 26 passed. - `pnpm --dir packages/db build && pnpm --dir packages/shared build && pnpm --dir packages/plugins/sdk build` — passed, including migration safety checks. - `pnpm --dir packages/plugins/sandbox-providers/kubernetes typecheck && pnpm --dir server typecheck` — passed after refreshing the worktree's frozen offline dependencies. - End-to-end cluster validation of the `build-cython-ext` benchmark remains for CI/maintainer Kubernetes infrastructure; the focused tests assert `github.com` and `pypi.org` produce a policy selected only by the granted run. ## Risks - Standard NetworkPolicy cannot express FQDNs, so an FQDN grant allows hardened public IPv4 TCP 80/443 for that run; use Cilium mode for exact hostname enforcement. - The new field is additive and absent by default, so existing runs keep the current provider-level policy. - Workload owner references garbage-collect scoped policies with the Job/Sandbox; a cluster/controller that ignores owner references could temporarily strand a policy that still selects no future run ID. > 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, exact model ID `gpt-5.6-sol`, high reasoning mode, tool use and code execution. The runtime did not expose a context-window size. ## 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 - [x] 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 - [x] 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>
688 lines
21 KiB
TypeScript
688 lines
21 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
environments,
|
|
executionWorkspaces,
|
|
issues,
|
|
plugins,
|
|
projects,
|
|
projectWorkspaces,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { heartbeatService } from "../services/heartbeat.ts";
|
|
import { instanceSettingsService } from "../services/instance-settings.ts";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts";
|
|
|
|
const adapterExecute = vi.hoisted(() => vi.fn(async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: { sessionId: "session-1" },
|
|
sessionDisplayId: "session-1",
|
|
provider: "test",
|
|
model: "test-model",
|
|
})));
|
|
|
|
vi.mock("../adapters/index.js", () => ({
|
|
getServerAdapter: () => ({
|
|
type: "codex_local",
|
|
execute: adapterExecute,
|
|
supportsLocalAgentJwt: false,
|
|
}),
|
|
findActiveServerAdapter: () => ({
|
|
type: "codex_local",
|
|
execute: adapterExecute,
|
|
supportsLocalAgentJwt: false,
|
|
}),
|
|
listAdapterModelProfiles: async () => [],
|
|
runningProcesses: new Map(),
|
|
}));
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres heartbeat plugin environment tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
|
let stopDb: (() => Promise<void>) | null = null;
|
|
let db!: ReturnType<typeof createDb>;
|
|
const tempRoots: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const started = await startEmbeddedPostgresTestDatabase("heartbeat-plugin-environment");
|
|
stopDb = started.stop;
|
|
db = createDb(started.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
adapterExecute.mockClear();
|
|
while (tempRoots.length > 0) {
|
|
const root = tempRoots.pop();
|
|
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.$client.end();
|
|
await stopDb?.();
|
|
});
|
|
|
|
it("acquires plugin environment leases through the heartbeat execution path", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const workspaceId = randomUUID();
|
|
const environmentId = randomUUID();
|
|
const pluginId = randomUUID();
|
|
const pluginKey = `acme.environments.${pluginId}`;
|
|
const agentId = randomUUID();
|
|
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-heartbeat-"));
|
|
tempRoots.push(workspaceRoot);
|
|
const workerManager = {
|
|
isRunning: vi.fn((id: string) => id === pluginId),
|
|
call: vi.fn(async (_pluginId: string, method: string) => {
|
|
if (method === "environmentAcquireLease") {
|
|
return {
|
|
providerLeaseId: "plugin-heartbeat-lease",
|
|
metadata: {
|
|
remoteCwd: "/workspace/project",
|
|
},
|
|
};
|
|
}
|
|
if (method === "environmentReleaseLease") {
|
|
return undefined;
|
|
}
|
|
throw new Error(`Unexpected plugin environment method: ${method}`);
|
|
}),
|
|
} as unknown as PluginWorkerManager;
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Acme",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
status: "active",
|
|
defaultResponsibleUserId: "responsible-user",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Plugin Environment Heartbeat",
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: workspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary",
|
|
cwd: workspaceRoot,
|
|
isPrimary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey,
|
|
packageName: "@acme/paperclip-environments",
|
|
version: "1.0.0",
|
|
apiVersion: 1,
|
|
categories: ["automation"],
|
|
manifestJson: {
|
|
id: pluginKey,
|
|
apiVersion: 1,
|
|
version: "1.0.0",
|
|
displayName: "Acme Environments",
|
|
description: "Test plugin environment driver",
|
|
author: "Acme",
|
|
categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"],
|
|
entrypoints: { worker: "dist/worker.js" },
|
|
environmentDrivers: [
|
|
{
|
|
driverKey: "sandbox",
|
|
displayName: "Sandbox",
|
|
configSchema: { type: "object" },
|
|
},
|
|
],
|
|
},
|
|
status: "ready",
|
|
installOrder: 1,
|
|
updatedAt: new Date(),
|
|
} as any);
|
|
await db.insert(environments).values({
|
|
id: environmentId,
|
|
companyId,
|
|
name: "Plugin Sandbox",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "base",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: environmentId,
|
|
permissions: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
|
const run = await heartbeat.wakeup(agentId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextSnapshot: { projectId },
|
|
});
|
|
|
|
expect(run).not.toBeNull();
|
|
await vi.waitFor(async () => {
|
|
const latest = await heartbeat.getRun(run!.id);
|
|
expect(latest?.status).toBe("succeeded");
|
|
}, { timeout: 5_000 });
|
|
|
|
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId,
|
|
executionWorkspaceId: expect.any(String),
|
|
executionWorkspaceSettings: null,
|
|
issueId: null,
|
|
config: { template: "base" },
|
|
agentId,
|
|
runId: run!.id,
|
|
workspaceMode: "shared_workspace",
|
|
// Pins the HEARTBEAT-path lease call forwarding the AGENT's adapter type
|
|
// (per-run adapter / mixed-harness envs). environment-runtime.ts has two
|
|
// drivers calling environmentAcquireLease; regressions here previously
|
|
// shipped by editing only the non-heartbeat one.
|
|
adapterType: "codex_local",
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", {
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId,
|
|
issueId: null,
|
|
config: { template: "base" },
|
|
providerLeaseId: "plugin-heartbeat-lease",
|
|
leaseMetadata: expect.objectContaining({
|
|
driver: "plugin",
|
|
pluginId,
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
}),
|
|
});
|
|
}, { timeout: 5_000 });
|
|
expect(adapterExecute).toHaveBeenCalledTimes(1);
|
|
}, 15_000);
|
|
|
|
it("inherits the instance default environment across companies while preserving explicit agent overrides", async () => {
|
|
const sharedEnvironmentId = randomUUID();
|
|
const overrideEnvironmentId = randomUUID();
|
|
const pluginId = randomUUID();
|
|
const pluginKey = `acme.environments.${pluginId}`;
|
|
const companyAId = randomUUID();
|
|
const companyBId = randomUUID();
|
|
const projectAId = randomUUID();
|
|
const projectBId = randomUUID();
|
|
const workspaceAId = randomUUID();
|
|
const workspaceBId = randomUUID();
|
|
const agentAId = randomUUID();
|
|
const agentBId = randomUUID();
|
|
const workspaceRootA = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-company-a-"));
|
|
const workspaceRootB = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-company-b-"));
|
|
tempRoots.push(workspaceRootA, workspaceRootB);
|
|
const workerManager = {
|
|
isRunning: vi.fn((id: string) => id === pluginId),
|
|
call: vi.fn(async (_pluginId: string, method: string, payload: Record<string, unknown>) => {
|
|
if (method === "environmentAcquireLease") {
|
|
return {
|
|
providerLeaseId: `plugin-heartbeat-lease-${String(payload.environmentId)}`,
|
|
metadata: {
|
|
remoteCwd: `/workspace/${String(payload.environmentId)}`,
|
|
},
|
|
};
|
|
}
|
|
if (method === "environmentReleaseLease") {
|
|
return undefined;
|
|
}
|
|
throw new Error(`Unexpected plugin environment method: ${method}`);
|
|
}),
|
|
} as unknown as PluginWorkerManager;
|
|
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey,
|
|
packageName: "@acme/paperclip-environments",
|
|
version: "1.0.0",
|
|
apiVersion: 1,
|
|
categories: ["automation"],
|
|
manifestJson: {
|
|
id: pluginKey,
|
|
apiVersion: 1,
|
|
version: "1.0.0",
|
|
displayName: "Acme Environments",
|
|
description: "Test plugin environment driver",
|
|
author: "Acme",
|
|
categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"],
|
|
entrypoints: { worker: "dist/worker.js" },
|
|
environmentDrivers: [
|
|
{
|
|
driverKey: "sandbox",
|
|
displayName: "Sandbox",
|
|
configSchema: { type: "object" },
|
|
},
|
|
],
|
|
},
|
|
status: "ready",
|
|
installOrder: 1,
|
|
updatedAt: new Date(),
|
|
} as any);
|
|
await db.insert(environments).values([
|
|
{
|
|
id: sharedEnvironmentId,
|
|
name: "Shared Plugin Sandbox",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "shared",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: overrideEnvironmentId,
|
|
name: "Override Plugin Sandbox",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "override",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await instanceSettingsService(db).update({ defaultEnvironmentId: sharedEnvironmentId });
|
|
|
|
await db.insert(companies).values([
|
|
{
|
|
id: companyAId,
|
|
name: "Acme A",
|
|
issuePrefix: `T${companyAId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
status: "active",
|
|
defaultResponsibleUserId: "responsible-user-a",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: companyBId,
|
|
name: "Acme B",
|
|
issuePrefix: `T${companyBId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
status: "active",
|
|
defaultResponsibleUserId: "responsible-user-b",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await db.insert(projects).values([
|
|
{
|
|
id: projectAId,
|
|
companyId: companyAId,
|
|
name: "Company A Project",
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: projectBId,
|
|
companyId: companyBId,
|
|
name: "Company B Project",
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: workspaceAId,
|
|
companyId: companyAId,
|
|
projectId: projectAId,
|
|
name: "Primary",
|
|
cwd: workspaceRootA,
|
|
isPrimary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: workspaceBId,
|
|
companyId: companyBId,
|
|
projectId: projectBId,
|
|
name: "Primary",
|
|
cwd: workspaceRootB,
|
|
isPrimary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await db.insert(agents).values([
|
|
{
|
|
id: agentAId,
|
|
companyId: companyAId,
|
|
name: "SharedEnvAgent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: null,
|
|
permissions: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: agentBId,
|
|
companyId: companyBId,
|
|
name: "OverrideEnvAgent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: overrideEnvironmentId,
|
|
permissions: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
|
const sharedRun = await heartbeat.wakeup(agentAId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextSnapshot: { projectId: projectAId },
|
|
});
|
|
const overrideRun = await heartbeat.wakeup(agentBId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextSnapshot: { projectId: projectBId },
|
|
});
|
|
|
|
expect(sharedRun).not.toBeNull();
|
|
expect(overrideRun).not.toBeNull();
|
|
await vi.waitFor(async () => {
|
|
const [latestShared, latestOverride] = await Promise.all([
|
|
heartbeat.getRun(sharedRun!.id),
|
|
heartbeat.getRun(overrideRun!.id),
|
|
]);
|
|
expect(latestShared?.status).toBe("succeeded");
|
|
expect(latestOverride?.status).toBe("succeeded");
|
|
}, { timeout: 5_000 });
|
|
|
|
const acquireCalls = workerManager.call.mock.calls
|
|
.filter(([, method]) => method === "environmentAcquireLease");
|
|
const acquirePayloads = acquireCalls.map(([, , payload]) => payload);
|
|
|
|
expect(acquireCalls).toHaveLength(2);
|
|
expect(acquirePayloads).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: companyAId,
|
|
environmentId: sharedEnvironmentId,
|
|
config: { template: "shared" },
|
|
agentId: agentAId,
|
|
runId: sharedRun!.id,
|
|
adapterType: "codex_local",
|
|
}),
|
|
expect.objectContaining({
|
|
companyId: companyBId,
|
|
environmentId: overrideEnvironmentId,
|
|
config: { template: "override" },
|
|
agentId: agentBId,
|
|
runId: overrideRun!.id,
|
|
adapterType: "codex_local",
|
|
}),
|
|
]));
|
|
}, 15_000);
|
|
|
|
it("ignores stale non-reused workspace environment config in favor of the assignee selection", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const workspaceId = randomUUID();
|
|
const oldEnvironmentId = randomUUID();
|
|
const newEnvironmentId = randomUUID();
|
|
const pluginId = randomUUID();
|
|
const pluginKey = `acme.environments.${pluginId}`;
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const staleExecutionWorkspaceId = randomUUID();
|
|
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-issue-"));
|
|
tempRoots.push(workspaceRoot);
|
|
const workerManager = {
|
|
isRunning: vi.fn((id: string) => id === pluginId),
|
|
call: vi.fn(async (_pluginId: string, method: string, payload: Record<string, unknown>) => {
|
|
if (method === "environmentAcquireLease") {
|
|
return {
|
|
providerLeaseId: `plugin-heartbeat-lease-${String(payload.environmentId)}`,
|
|
metadata: {
|
|
remoteCwd: `/workspace/${String(payload.environmentId)}`,
|
|
},
|
|
};
|
|
}
|
|
if (method === "environmentReleaseLease") {
|
|
return undefined;
|
|
}
|
|
throw new Error(`Unexpected plugin environment method: ${method}`);
|
|
}),
|
|
} as unknown as PluginWorkerManager;
|
|
|
|
await instanceSettingsService(db).updateExperimental({
|
|
enableEnvironments: true,
|
|
enableIsolatedWorkspaces: true,
|
|
});
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Acme",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
status: "active",
|
|
defaultResponsibleUserId: "responsible-user",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Plugin Environment Issue",
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: workspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary",
|
|
cwd: workspaceRoot,
|
|
isPrimary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey,
|
|
packageName: "@acme/paperclip-environments",
|
|
version: "1.0.0",
|
|
apiVersion: 1,
|
|
categories: ["automation"],
|
|
manifestJson: {
|
|
id: pluginKey,
|
|
apiVersion: 1,
|
|
version: "1.0.0",
|
|
displayName: "Acme Environments",
|
|
description: "Test plugin environment driver",
|
|
author: "Acme",
|
|
categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"],
|
|
entrypoints: { worker: "dist/worker.js" },
|
|
environmentDrivers: [
|
|
{
|
|
driverKey: "sandbox",
|
|
displayName: "Sandbox",
|
|
configSchema: { type: "object" },
|
|
},
|
|
],
|
|
},
|
|
status: "ready",
|
|
installOrder: 1,
|
|
updatedAt: new Date(),
|
|
} as any);
|
|
await db.insert(environments).values([
|
|
{
|
|
id: oldEnvironmentId,
|
|
companyId,
|
|
name: "QA SSH",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "old",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: newEnvironmentId,
|
|
companyId,
|
|
name: "QA E2B",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "new",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: newEnvironmentId,
|
|
permissions: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: staleExecutionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: workspaceId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Stale workspace",
|
|
status: "active",
|
|
cwd: workspaceRoot,
|
|
providerType: "local_fs",
|
|
providerRef: workspaceRoot,
|
|
metadata: {
|
|
config: {
|
|
environmentId: oldEnvironmentId,
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: workspaceId,
|
|
title: "Environment matrix: e2b / codex_local",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
executionWorkspaceId: staleExecutionWorkspaceId,
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
|
const run = await heartbeat.wakeup(agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "manual",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
|
|
expect(run).not.toBeNull();
|
|
await vi.waitFor(async () => {
|
|
const latest = await heartbeat.getRun(run!.id);
|
|
expect(latest?.status).toBe("succeeded");
|
|
}, { timeout: 5_000 });
|
|
|
|
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId: newEnvironmentId,
|
|
executionWorkspaceId: expect.any(String),
|
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
|
issueId,
|
|
config: { template: "new" },
|
|
agentId,
|
|
runId: run!.id,
|
|
workspaceMode: "shared_workspace",
|
|
adapterType: "codex_local",
|
|
});
|
|
}, 15_000);
|
|
});
|