Files
PaperClipAI/server/src/__tests__/cloud-control-task-drain.test.ts
Devin Foley daea92b647 feat(server): accept a Cloud control assertion on the task-drain endpoint (#13125)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server has a task-drain admission hold so operators can stop new
agent work and wait for quiescence before maintenance
> - Cloud deploys restart tenant containers, but the Cloud control plane
has no sanctioned credential for the drain routes, so agent runs are
killed mid-restart
> - The only Cloud credential this server trusts is the runtime identity
assertion, deliberately scoped to the one-time bootstrap health call
> - This pull request adds a disjoint, action-bound Cloud control
assertion accepted only on the task-drain endpoint
> - The benefit is that Cloud can hold new work and drain a stack before
it restarts the container, through the same authorization and audit
paths a human operator uses

## Linked Issues or Issue Description

Refs #12485 (the task-drain admission hold this makes reachable for the
Cloud control plane).

**Problem or motivation**

Cloud deploys restart the container without stopping agent work first.
The task-drain hold from #12485 exists for exactly this, but its routes
require instance-admin board authority. The Cloud control plane holds no
such credential: the runtime identity assertion is accepted only on `GET
/api/health`, by design. So in-flight runs die at every deploy.

**Proposed solution**

A second, deliberately disjoint use of the same Cloud signing key
(`PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS`): a control assertion with its
own JWS type (`paperclip-cloud-control+jwt`), its own audience, an
`action` claim, a request id, and a short maximum lifetime. A new
middleware accepts the `x-paperclip-cloud-control` header only on
`/api/instance/task-drain`, binds each method to one exact action
(`task-drain:read` / `task-drain:start` / `task-drain:stop`), verifies
the assertion against the configured JWKS and
`PAPERCLIP_CLOUD_STACK_ID`, and installs a synthetic instance-admin
board actor so the existing route authorization, validation,
transactional audit, and activity publishing run unchanged (audit rows
record actor id `paperclip-cloud`). The header is rejected with 400
anywhere else, so it can never become an ambient credential. The board
mutation guard exempts the new `cloud_control` source exactly like the
other non-browser lanes.

**Alternatives considered**

Widening the existing runtime identity middleware would conflate a
one-time bootstrap claim with a repeatable management credential and
weaken both. A per-stack minted instance-admin API key would work with
no auth change but adds a long-lived privileged credential per tenant to
store and rotate. The action-bound short-lived assertion keeps
authorization per-call and stateless.

**Additional context**

Self-hosted instances have no `PAPERCLIP_CLOUD_STACK_ID` and reject
every assertion — the feature is inert off Cloud. A runtime identity
token cannot replay as a control token or vice versa (disjoint `typ` and
`aud`, covered by tests). The Cloud-side caller (drain before deploy,
bounded quiescence wait) lands separately in the Cloud control plane.

## What Changed

- `server/src/services/cloud-runtime-identity.ts`:
`verifyCloudControlAssertion` plus the control
header/audience/type/action constants, reusing the existing JWKS
resolution, JWS parsing, and lifetime discipline.
- `server/src/middleware/cloud-control.ts` (new): accepts the header
only on the task-drain endpoint, per-method action binding, installs the
synthetic instance-admin actor on success, 401 on invalid assertions,
400 anywhere else.
- `server/src/app.ts`: mounts the middleware directly after the actor
middleware, so a valid assertion replaces whatever actor the request
otherwise resolved to.
- `server/src/middleware/board-mutation-guard.ts`: `cloud_control` joins
the non-browser exemptions.
- `server/src/types/express.d.ts`,
`server/src/services/authorization.ts`: `"cloud_control"` added to the
actor source unions.

## Verification

- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/cloud-control-task-drain.test.ts
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/heartbeat-task-drain.test.ts
server/src/__tests__/heartbeat-scheduling-suppression.test.ts
server/src/__tests__/cloud-runtime-identity.test.ts` — 87 tests, all
passing.
- `pnpm --filter @paperclipai/server exec tsc --noEmit` reports no new
errors against the base commit's known pre-existing set.
- The new suite covers: acceptance per method, cross-action rejection,
unknown-action rejection, runtime-identity-token replay rejection,
wrong-audience rejection, wrong-stack and self-hosted rejection, expiry
and oversized-lifetime rejection, unknown-key rejection, request id
validation, endpoint containment (400 elsewhere, 400 on unbound
methods), pass-through without the header, and the mutation-guard
exemption.

## Risks

Low risk, additive. No behavior changes without the header; the header
grants nothing outside the one endpoint; each assertion authorizes one
action for at most five minutes; the existing route-level validation,
queued transitions, and audit writes are unchanged. The browser-facing
Cloud proxy strips Cloud headers, and possession of the shared
tenant-session token cannot mint an assertion (signing key never leaves
Cloud).

## Model Used

Claude (Anthropic) — Fable 5 (`claude-fable-5`), extended thinking,
agentic tool use via Claude Code.

## 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
(module doc comments carry the contract)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-09-10 11:56:28 -07:00

327 lines
12 KiB
TypeScript

import { generateKeyPairSync, sign } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
CLOUD_CONTROL_AUDIENCE,
CLOUD_CONTROL_HEADER,
CLOUD_CONTROL_JWS_TYPE,
CLOUD_RUNTIME_IDENTITY_AUDIENCE,
CLOUD_RUNTIME_IDENTITY_ISSUER,
CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
resetCloudControlReplayFenceForTests,
verifyCloudControlAssertion,
type CloudControlAction,
} from "../services/cloud-runtime-identity.js";
import { cloudControlMiddleware } from "../middleware/cloud-control.js";
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
const STACK_ID = "stack-drain-test";
const NOW = new Date("2099-01-01T00:00:00.000Z");
const pair = generateKeyPairSync("ed25519");
const otherPair = generateKeyPairSync("ed25519");
const publicJwk = {
...pair.publicKey.export({ format: "jwk" }),
kid: "cloud-control-test-key",
use: "sig",
alg: "EdDSA",
};
const ENV = {
PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: JSON.stringify({ keys: [publicJwk] }),
PAPERCLIP_CLOUD_STACK_ID: STACK_ID,
} as NodeJS.ProcessEnv;
function encodeJson(value: Record<string, unknown>) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
let requestIdCounter = 0;
function controlAssertion(input: {
claims?: Record<string, unknown>;
header?: Record<string, unknown>;
signingKey?: typeof pair.privateKey;
action?: CloudControlAction;
} = {}) {
const iat = Math.floor(NOW.getTime() / 1000);
const header = encodeJson({
alg: "EdDSA",
typ: CLOUD_CONTROL_JWS_TYPE,
kid: publicJwk.kid,
...input.header,
});
const payload = encodeJson({
v: 1,
iss: CLOUD_RUNTIME_IDENTITY_ISSUER,
aud: CLOUD_CONTROL_AUDIENCE,
sub: STACK_ID,
action: input.action ?? "task-drain:start",
// Unique per assertion: request ids are single-use by design.
requestId: `drain-req-${(requestIdCounter += 1)}`,
iat,
exp: iat + 60,
...input.claims,
});
const signature = sign(
null,
Buffer.from(`${header}.${payload}`, "ascii"),
input.signingKey ?? pair.privateKey,
).toString("base64url");
return `${header}.${payload}.${signature}`;
}
describe("verifyCloudControlAssertion", () => {
beforeEach(() => {
resetCloudControlReplayFenceForTests();
});
const verify = (jws: string, expectedAction: CloudControlAction = "task-drain:start") =>
verifyCloudControlAssertion({ compactJws: jws, expectedAction, env: ENV, now: NOW });
it("accepts a well-formed assertion bound to the expected action", () => {
const claims = verify(controlAssertion());
expect(claims.sub).toBe(STACK_ID);
expect(claims.action).toBe("task-drain:start");
expect(claims.requestId).toMatch(/^drain-req-\d+$/);
});
it("rejects a replay: each assertion's request id is single-use", () => {
const jws = controlAssertion();
verify(jws);
expect(() => verify(jws)).toThrow(/already been used/);
// A distinct assertion (fresh request id) still verifies.
verify(controlAssertion());
});
it("a rejected assertion does not burn its request id", () => {
// The consume runs last: replaying a mangled copy first must not
// deny the legitimate call.
const jws = controlAssertion();
expect(() => verify(jws, "task-drain:stop")).toThrow(/does not authorize/);
verify(jws);
});
it("rejects an assertion for a different action — read cannot start a drain", () => {
expect(() => verify(controlAssertion({ action: "task-drain:read" }))).toThrow(
/does not authorize this action/,
);
});
it("rejects an unknown action even when it matches the expectation", () => {
expect(() =>
verifyCloudControlAssertion({
compactJws: controlAssertion({ claims: { action: "instance:shutdown" } }),
expectedAction: "instance:shutdown" as CloudControlAction,
env: ENV,
now: NOW,
}),
).toThrow(/does not authorize this action/);
});
it("rejects a runtime identity assertion replayed as a control assertion", () => {
// Same key, disjoint typ/aud/claims — the one-time bootstrap claim can
// never double as a management credential.
const iat = Math.floor(NOW.getTime() / 1000);
const header = encodeJson({ alg: "EdDSA", typ: CLOUD_RUNTIME_IDENTITY_JWS_TYPE, kid: publicJwk.kid });
const payload = encodeJson({
v: 1,
iss: CLOUD_RUNTIME_IDENTITY_ISSUER,
aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE,
sub: STACK_ID,
claimId: "claim-1",
previousOrigin: "https://pool-1.staging.paperclip.app",
canonicalOrigin: "https://gonzo.staging.paperclip.app",
stackSlug: "gonzo",
iat,
exp: iat + 60,
});
const signature = sign(null, Buffer.from(`${header}.${payload}`, "ascii"), pair.privateKey).toString("base64url");
expect(() => verify(`${header}.${payload}.${signature}`)).toThrow(/protected header is invalid/);
});
it("rejects a control assertion whose audience is the runtime identity audience", () => {
expect(() => verify(controlAssertion({ claims: { aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE } }))).toThrow(
/claims are incomplete|is invalid/,
);
});
it("rejects an assertion for another stack, and any assertion when the instance is self-hosted", () => {
expect(() => verify(controlAssertion({ claims: { sub: "stack-other" } }))).toThrow(
/does not match this instance/,
);
expect(() =>
verifyCloudControlAssertion({
compactJws: controlAssertion(),
expectedAction: "task-drain:start",
env: { PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: ENV.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS } as NodeJS.ProcessEnv,
now: NOW,
}),
).toThrow(/does not match this instance/);
});
it("rejects expired assertions and oversized lifetimes", () => {
const iat = Math.floor(NOW.getTime() / 1000);
expect(() => verify(controlAssertion({ claims: { iat: iat - 600, exp: iat - 300 } }))).toThrow(
/expired or has an invalid lifetime/,
);
expect(() => verify(controlAssertion({ claims: { exp: iat + 3600 } }))).toThrow(
/expired or has an invalid lifetime/,
);
});
it("rejects a signature from an unknown key", () => {
expect(() => verify(controlAssertion({ signingKey: otherPair.privateKey }))).toThrow(
/signature is invalid/,
);
});
it("rejects a blank or padded request id", () => {
expect(() => verify(controlAssertion({ claims: { requestId: "" } }))).toThrow(/claims are incomplete|request id/);
expect(() => verify(controlAssertion({ claims: { requestId: " padded " } }))).toThrow(/request id is invalid/);
});
});
describe("cloudControlMiddleware", () => {
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
resetCloudControlReplayFenceForTests();
savedEnv.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS = process.env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS;
savedEnv.PAPERCLIP_CLOUD_STACK_ID = process.env.PAPERCLIP_CLOUD_STACK_ID;
process.env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS = ENV.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS;
process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID;
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function createApp() {
const app = express();
app.use((req, _res, next) => {
req.actor = { type: "none", source: "none" };
next();
});
app.use(cloudControlMiddleware());
app.all("/api/instance/task-drain", (req, res) => {
res.json({ actor: req.actor });
});
app.get("/api/instance/settings", (req, res) => {
res.json({ actor: req.actor });
});
return app;
}
function freshAssertion(action: CloudControlAction) {
// The middleware verifies against the real clock; sign a live token.
const iat = Math.floor(Date.now() / 1000);
const header = encodeJson({ alg: "EdDSA", typ: CLOUD_CONTROL_JWS_TYPE, kid: publicJwk.kid });
const payload = encodeJson({
v: 1,
iss: CLOUD_RUNTIME_IDENTITY_ISSUER,
aud: CLOUD_CONTROL_AUDIENCE,
sub: STACK_ID,
action,
requestId: `drain-req-live-${(requestIdCounter += 1)}`,
iat,
exp: iat + 60,
});
const signature = sign(null, Buffer.from(`${header}.${payload}`, "ascii"), pair.privateKey).toString("base64url");
return `${header}.${payload}.${signature}`;
}
it("installs a synthetic instance-admin board actor for a valid assertion, per method", async () => {
const app = createApp();
for (const [method, action] of [
["get", "task-drain:read"],
["post", "task-drain:start"],
["delete", "task-drain:stop"],
] as const) {
const res = await (request(app) as any)[method]("/api/instance/task-drain")
.set(CLOUD_CONTROL_HEADER, freshAssertion(action));
expect(res.status).toBe(200);
expect(res.body.actor).toMatchObject({
type: "board",
userId: "paperclip-cloud",
isInstanceAdmin: true,
source: "cloud_control",
});
}
});
it("rejects an assertion bound to a different method's action", async () => {
const app = createApp();
const res = await request(app)
.post("/api/instance/task-drain")
.set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:read"));
expect(res.status).toBe(401);
expect(res.body.error).toBe("invalid_cloud_control_assertion");
});
it("accepts the conventional trailing-slash form of the endpoint", async () => {
const app = createApp();
const res = await request(app)
.get("/api/instance/task-drain/")
.set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:read"));
expect(res.status).toBe(200);
expect(res.body.actor).toMatchObject({ source: "cloud_control" });
});
it("rejects a replayed assertion at the middleware", async () => {
const app = createApp();
const jws = freshAssertion("task-drain:read");
const first = await request(app).get("/api/instance/task-drain").set(CLOUD_CONTROL_HEADER, jws);
expect(first.status).toBe(200);
const replay = await request(app).get("/api/instance/task-drain").set(CLOUD_CONTROL_HEADER, jws);
expect(replay.status).toBe(401);
});
it("rejects the header anywhere but the task-drain endpoint", async () => {
const app = createApp();
const res = await request(app)
.get("/api/instance/settings")
.set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:read"));
expect(res.status).toBe(400);
expect(res.body.error).toBe("cloud_control_wrong_endpoint");
});
it("rejects methods with no bound action even on the right endpoint", async () => {
const app = createApp();
const res = await request(app)
.patch("/api/instance/task-drain")
.set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:start"));
expect(res.status).toBe(400);
});
it("passes requests without the header through untouched", async () => {
const app = createApp();
const res = await request(app).get("/api/instance/task-drain");
expect(res.status).toBe(200);
expect(res.body.actor).toMatchObject({ type: "none" });
});
it("the board mutation guard exempts cloud_control mutations from the browser-origin check", async () => {
const app = express();
app.use((req, _res, next) => {
req.actor = {
type: "board",
userId: "paperclip-cloud",
isInstanceAdmin: true,
source: "cloud_control",
};
next();
});
app.use(boardMutationGuard());
app.post("/api/instance/task-drain", (_req, res) => {
res.json({ ok: true });
});
const res = await request(app).post("/api/instance/task-drain");
expect(res.status).toBe(200);
});
});