Files
PaperClipAI/server/src/__tests__/heartbeat-plugin-environment.test.ts
T
DottaandPaperclip 7f766526a6 feat(sandbox): add task-scoped egress grants (#10155)
## 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>
2026-07-24 09:58:58 -05:00

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