mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## 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
109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
import type { Request, RequestHandler } from "express";
|
|
|
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
const DEFAULT_DEV_ORIGINS = [
|
|
"http://localhost:3100",
|
|
"http://127.0.0.1:3100",
|
|
];
|
|
|
|
function parseOrigin(value: string | undefined) {
|
|
if (!value) return null;
|
|
try {
|
|
const url = new URL(value);
|
|
return `${url.protocol}//${url.host}`.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the host used for same-origin checks without letting a direct client
|
|
* promote its own X-Forwarded-Host value into the trusted-origin set. Express
|
|
* compiles the operator's TRUST_PROXY setting into `trust proxy fn`; only a
|
|
* trusted immediate peer may supply the forwarded host.
|
|
*/
|
|
function requestHost(req: Request): string | undefined {
|
|
const host = req.header("host")?.trim();
|
|
const remoteAddress = req.socket?.remoteAddress;
|
|
const trustProxy = req.app?.get("trust proxy fn") as
|
|
| ((address: string, hop: number) => boolean)
|
|
| undefined;
|
|
|
|
if (
|
|
remoteAddress
|
|
&& typeof trustProxy === "function"
|
|
&& trustProxy(remoteAddress, 0)
|
|
) {
|
|
return req.header("x-forwarded-host")?.split(",")[0]?.trim() || host;
|
|
}
|
|
|
|
return host;
|
|
}
|
|
|
|
function trustedOriginsForRequest(req: Request) {
|
|
const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase()));
|
|
const host = requestHost(req);
|
|
if (host) {
|
|
origins.add(`http://${host}`.toLowerCase());
|
|
origins.add(`https://${host}`.toLowerCase());
|
|
}
|
|
// Behind some reverse proxies the Host / X-Forwarded-Host header may
|
|
// not match the public URL (for example when TLS terminates at the
|
|
// edge and the inbound Host is an internal service name). Trust the
|
|
// explicitly-configured PAPERCLIP_PUBLIC_URL when it's set.
|
|
const publicUrl = parseOrigin(process.env.PAPERCLIP_PUBLIC_URL?.trim());
|
|
if (publicUrl) origins.add(publicUrl);
|
|
return origins;
|
|
}
|
|
|
|
/**
|
|
* Return the browser origin only when it is the same origin Paperclip's CSRF
|
|
* guard accepts for this request. Callers may use this as browser-reachability
|
|
* evidence, but must still apply any protocol-specific constraints (for
|
|
* example OAuth requiring HTTPS outside loopback).
|
|
*/
|
|
export function trustedBoardMutationOrigin(req: Request): string | null {
|
|
const allowedOrigins = trustedOriginsForRequest(req);
|
|
const origin = parseOrigin(req.header("origin"));
|
|
if (origin && allowedOrigins.has(origin)) return origin;
|
|
|
|
const refererOrigin = parseOrigin(req.header("referer"));
|
|
if (refererOrigin && allowedOrigins.has(refererOrigin)) return refererOrigin;
|
|
|
|
return null;
|
|
}
|
|
|
|
export function boardMutationGuard(): RequestHandler {
|
|
return (req, res, next) => {
|
|
if (SAFE_METHODS.has(req.method.toUpperCase())) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (req.actor.type !== "board") {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Local-trusted mode, board bearer keys, trusted Cloud tenant calls, and
|
|
// signed Cloud control assertions are not browser-session requests.
|
|
// In these modes, origin/referer headers can be absent; do not block those mutations.
|
|
if (
|
|
req.actor.source === "local_implicit"
|
|
|| req.actor.source === "board_key"
|
|
|| req.actor.source === "cloud_tenant"
|
|
|| req.actor.source === "cloud_control"
|
|
) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (!trustedBoardMutationOrigin(req)) {
|
|
res.status(403).json({ error: "Board mutation requires trusted browser origin" });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|