Secure Cloud canonical runtime identity (#12766)

Accept and persist Cloud-signed canonical runtime identity before activation, then route absolute self-URLs through the durable runtime identity provider.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Dotta 2026-09-03 12:01:47 -05:00 committed by GitHub
parent 2e8521e57c
commit 0798c77fde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 933 additions and 6 deletions

View File

@ -175,6 +175,18 @@ When authoring migrations or one-time backfills:
- Do not hand-edit a snapshot to resolve a merge conflict. Renumber your migration and run `generate` again, as `packages/db/.gitattributes` describes.
- `packages/db/src/migration-snapshot-drift.test.ts` is the enforcement backstop. It repeats the diff that `generate` performs and fails when the newest snapshot no longer matches `packages/db/src/schema/`.
## Cloud runtime identity singleton
The private `instance_settings` row whose singleton key is
`cloud-runtime-identity/v1` records the immutable Cloud stack id, warm-pool
claim id, previous pool origin, canonical origin, and stack slug accepted from
Cloud's signed pre-activation assertion. It is separate from the normal
`default` settings row and never appears in the settings API. This is
intentionally instance-scoped rather than company-scoped: an instance has one
public identity, and the existing unique singleton-key index makes concurrent
or later attempts to replace it fail closed. The server loads the row before
constructing URL-dependent runtime services on every boot.
## Resource membership tables
Paperclip stores current-user sidebar membership state in:

View File

@ -64,6 +64,24 @@ Paperclip now treats **bind** as a separate concern from auth:
- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced
- local stdio MCP runtime slots fail closed by default; set `PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` only when a trusted worker/runtime host is configured to supervise those processes. Remote HTTP MCP remains the preferred public-hosted path.
### Paperclip Cloud warm-pool identity
A Cloud-managed warm-pool process initially boots under a `pool-*` origin. It
receives only Cloud's public verification set in
`PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS`. Before Cloud activates a claimed stack,
the existing server-to-server health request carries a short-lived Ed25519 JWS
that binds the immutable `PAPERCLIP_CLOUD_STACK_ID`, pool claim, previous
origin, canonical HTTPS origin, and slug. Paperclip verifies and persists that
one-time assertion, updates its live public/API URL provider, and acknowledges
the exact origin in `/api/health` before the first user request is admitted.
The Harness signing private key is never present in Paperclip, browsers, or
other tenant stacks. A different claim or destination cannot replace the
persisted identity. On restart, the durable identity is loaded before auth,
routes, and child-runtime configuration, even when provider variables are
temporarily stale. Self-hosted deployments continue to use their configured
`PAPERCLIP_PUBLIC_URL` and do not participate in this protocol.
## 4. Onboarding UX Contract
Default onboarding remains interactive and flagless:

View File

@ -0,0 +1,261 @@
import { generateKeyPairSync, sign } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createDb, instanceSettings } from "@paperclipai/db";
import {
applyCloudRuntimeIdentityAssertion,
CLOUD_RUNTIME_IDENTITY_AUDIENCE,
CLOUD_RUNTIME_IDENTITY_ISSUER,
CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
getCloudRuntimeIdentity,
initializeCloudRuntimeIdentity,
resetCloudRuntimeIdentityForTests,
runtimePublicOrigin,
} from "../services/cloud-runtime-identity.js";
import { routineWebhookUrl } from "../services/routines.js";
import { paperclipCloudConnectorEnrollmentStatus } from "../services/paperclip-cloud-connector-enrollment.js";
import { cloudRuntimeIdentityMiddleware } from "../middleware/cloud-runtime-identity.js";
import { healthRoutes } from "../routes/health.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const STACK_ID = "stack-pool-123";
const POOL_ORIGIN = "https://pool-123.staging.paperclip.app";
const CANONICAL_ORIGIN = "https://gonzo.staging.paperclip.app";
const NOW = new Date("2099-01-01T00:00:00.000Z");
const pair = generateKeyPairSync("ed25519");
const publicJwk = {
...pair.publicKey.export({ format: "jwk" }),
kid: "runtime-identity-test-key",
use: "sig",
alg: "EdDSA",
};
function encodeJson(value: Record<string, unknown>) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function assertion(input: {
claims?: Record<string, unknown>;
header?: Record<string, unknown>;
signingKey?: typeof pair.privateKey;
} = {}) {
const iat = Math.floor(NOW.getTime() / 1000);
const header = encodeJson({
alg: "EdDSA",
typ: CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
kid: publicJwk.kid,
...input.header,
});
const payload = encodeJson({
v: 1,
iss: CLOUD_RUNTIME_IDENTITY_ISSUER,
aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE,
sub: STACK_ID,
claimId: "pool-entry-123",
previousOrigin: POOL_ORIGIN,
canonicalOrigin: CANONICAL_ORIGIN,
stackSlug: "gonzo",
iat,
exp: iat + 300,
...input.claims,
});
const signature = sign(
null,
Buffer.from(`${header}.${payload}`, "ascii"),
input.signingKey ?? pair.privateKey,
).toString("base64url");
return `${header}.${payload}.${signature}`;
}
describeEmbeddedPostgres("Cloud runtime identity", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const originalEnv = { ...process.env };
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-runtime-identity-");
db = createDb(tempDb.connectionString);
}, 20_000);
beforeEach(async () => {
await db.delete(instanceSettings);
resetCloudRuntimeIdentityForTests();
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-tenant-server-token";
process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID;
process.env.PAPERCLIP_CLOUD_API_ORIGIN = "https://my-staging.paperclip.app";
process.env.PAPERCLIP_PUBLIC_URL = POOL_ORIGIN;
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = POOL_ORIGIN;
process.env.PAPERCLIP_API_URL = POOL_ORIGIN;
process.env.PAPERCLIP_PRIMARY_HOST = "pool-123.staging.paperclip.app";
process.env.PAPERCLIP_STACK_SLUG = "pool-123";
process.env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS = JSON.stringify({ keys: [publicJwk] });
process.env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID = "managed-instance";
process.env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY = "managed-signing-key";
process.env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY = "managed-sealing-key";
process.env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT = "staging";
process.env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL = "https://my-staging.paperclip.app";
await initializeCloudRuntimeIdentity(db);
});
afterEach(() => {
for (const key of [
"PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN",
"PAPERCLIP_CLOUD_STACK_ID",
"PAPERCLIP_CLOUD_API_ORIGIN",
"PAPERCLIP_PUBLIC_URL",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"PAPERCLIP_API_URL",
"PAPERCLIP_PRIMARY_HOST",
"PAPERCLIP_STACK_SLUG",
"PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS",
"PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID",
"PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY",
"PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY",
"PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT",
"PAPERCLIP_CLOUD_CONNECTOR_BASE_URL",
"PAPERCLIP_RUNTIME_API_CANDIDATES_JSON",
]) {
const original = originalEnv[key];
if (original === undefined) delete process.env[key];
else process.env[key] = original;
}
resetCloudRuntimeIdentityForTests();
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("persists and immediately applies a valid one-time claim", async () => {
const applied = await applyCloudRuntimeIdentityAssertion({
db,
compactJws: assertion(),
now: NOW,
});
expect(applied.canonicalOrigin).toBe(CANONICAL_ORIGIN);
expect(getCloudRuntimeIdentity()?.stackSlug).toBe("gonzo");
expect(runtimePublicOrigin()).toBe(CANONICAL_ORIGIN);
expect(process.env.PAPERCLIP_PUBLIC_URL).toBe(CANONICAL_ORIGIN);
expect(process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL).toBe(CANONICAL_ORIGIN);
expect(process.env.PAPERCLIP_API_URL).toBe(CANONICAL_ORIGIN);
expect(process.env.PAPERCLIP_PRIMARY_HOST).toBe("gonzo.staging.paperclip.app");
expect(process.env.PAPERCLIP_STACK_SLUG).toBe("gonzo");
expect(routineWebhookUrl("hook-1")).toBe(
"https://gonzo.staging.paperclip.app/api/routine-triggers/public/hook-1/fire",
);
expect(paperclipCloudConnectorEnrollmentStatus()).toMatchObject({
configured: true,
status: "active",
origins: [CANONICAL_ORIGIN],
});
});
it("applies the assertion on the existing health request and acknowledges the exact origin", async () => {
const requestTime = Math.floor(Date.now() / 1000);
const app = express();
app.use(cloudRuntimeIdentityMiddleware(db));
app.use("/api/health", healthRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
companyDeletionEnabled: false,
}));
const response = await request(app)
.get("/api/health")
.set("x-paperclip-cloud-runtime-identity", assertion({
claims: { iat: requestTime, exp: requestTime + 300 },
}));
expect(response.status).toBe(200);
expect(response.body.cloud.runtimeIdentity).toEqual({
canonicalOrigin: CANONICAL_ORIGIN,
stackSlug: "gonzo",
});
});
it("restores the canonical identity before consumers read stale startup variables", async () => {
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW });
resetCloudRuntimeIdentityForTests();
process.env.PAPERCLIP_PUBLIC_URL = POOL_ORIGIN;
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = POOL_ORIGIN;
process.env.PAPERCLIP_API_URL = POOL_ORIGIN;
await initializeCloudRuntimeIdentity(db);
expect(runtimePublicOrigin()).toBe(CANONICAL_ORIGIN);
expect(process.env.PAPERCLIP_API_URL).toBe(CANONICAL_ORIGIN);
});
it("accepts the identical claim after a restart with already-aligned provider variables", async () => {
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW });
resetCloudRuntimeIdentityForTests();
process.env.PAPERCLIP_PUBLIC_URL = CANONICAL_ORIGIN;
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = CANONICAL_ORIGIN;
process.env.PAPERCLIP_API_URL = CANONICAL_ORIGIN;
await initializeCloudRuntimeIdentity(db);
await expect(applyCloudRuntimeIdentityAssertion({
db,
compactJws: assertion(),
now: NOW,
})).resolves.toMatchObject({ canonicalOrigin: CANONICAL_ORIGIN });
});
it("accepts an identical replay but rejects a different claim or destination", async () => {
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW });
await expect(applyCloudRuntimeIdentityAssertion({
db,
compactJws: assertion(),
now: NOW,
})).resolves.toMatchObject({ canonicalOrigin: CANONICAL_ORIGIN });
await expect(applyCloudRuntimeIdentityAssertion({
db,
compactJws: assertion({ claims: { claimId: "another-claim" } }),
now: NOW,
})).rejects.toThrow("already claimed");
});
it.each([
["expired", { exp: Math.floor(NOW.getTime() / 1000) - 1 }],
["cross-stack", { sub: "stack-someone-else" }],
["wrong previous origin", { previousOrigin: "https://another.staging.paperclip.app" }],
["path-bearing destination", { canonicalOrigin: `${CANONICAL_ORIGIN}/GON` }],
["slug mismatch", { stackSlug: "kermit" }],
])("rejects %s assertions", async (_label, claims) => {
await expect(applyCloudRuntimeIdentityAssertion({
db,
compactJws: assertion({ claims }),
now: NOW,
})).rejects.toThrow();
});
it("rejects unknown keys and altered signed destinations", async () => {
await expect(applyCloudRuntimeIdentityAssertion({
db,
compactJws: assertion({ header: { kid: "unknown" } }),
now: NOW,
})).rejects.toThrow("unknown signing key");
const valid = assertion();
const [header, payload, signature] = valid.split(".");
const altered = encodeJson({
...JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")),
canonicalOrigin: "https://attacker.example.com",
});
await expect(applyCloudRuntimeIdentityAssertion({
db,
compactJws: `${header}.${altered}.${signature}`,
now: NOW,
})).rejects.toThrow("signature is invalid");
});
});

View File

@ -2146,6 +2146,18 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
expect(JSON.stringify(response.body)).not.toContain(company.id);
});
it("uses the configured auth origin for self-hosted OAuth callbacks", async () => {
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://public.paperclip.example");
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://auth.paperclip.example");
const app = createRouteApp(db);
const response = await request(app).get("/api/tools/oauth/client-metadata").expect(200);
expect(response.body.redirect_uris).toEqual([
"https://auth.paperclip.example/api/tools/oauth/callback",
]);
});
it("uses the managed runtime origin when no explicit callback origin is configured", async () => {
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "");

View File

@ -39,6 +39,7 @@ import { secretService } from "../services/secrets.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
const originalPaperclipApiUrlEnv = process.env.PAPERCLIP_API_URL;
if (!embeddedPostgresSupport.supported) {
console.warn(
@ -51,6 +52,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
process.env.PAPERCLIP_API_URL = "http://localhost:3100";
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
@ -86,6 +88,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
afterAll(async () => {
await tempDb?.cleanup();
if (originalPaperclipApiUrlEnv === undefined) {
delete process.env.PAPERCLIP_API_URL;
} else {
process.env.PAPERCLIP_API_URL = originalPaperclipApiUrlEnv;
}
});
async function seedFixture(opts?: {

View File

@ -19,6 +19,7 @@ import {
} from "./services/company-import-transfers.js";
import { companyTransferRunService } from "./services/company-transfer-runs.js";
import { healthRoutes } from "./routes/health.js";
import { cloudRuntimeIdentityMiddleware } from "./middleware/cloud-runtime-identity.js";
import { cloudRoutes } from "./routes/cloud.js";
import { companyRoutes } from "./routes/companies.js";
import { companySkillRoutes } from "./routes/company-skills.js";
@ -364,6 +365,7 @@ export async function createApp(
bindHost: opts.bindHost,
}),
);
app.use(cloudRuntimeIdentityMiddleware(db));
// Connection-intent tools carry their own short-lived, run-bound bearer and
// must be reachable by remote adapters that intentionally do not receive an
// agent API key. Every request revalidates the active heartbeat row.

View File

@ -101,6 +101,7 @@ import {
finalizeServerShutdown,
loadWithoutCoordinatedShutdownSignalHooks,
} from "./shutdown.js";
import { initializeCloudRuntimeIdentity } from "./services/cloud-runtime-identity.js";
import { systemdNotify } from "./services/systemd-notify.js";
import { flushInFlightRunLogMirrors } from "./services/run-log-store.js";
import {
@ -562,6 +563,12 @@ export async function startServer(): Promise<StartedServer> {
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
}
// A claimed warm-pool stack may restart while its provider environment still
// names the pool host. Restore the signed, durable identity before Better
// Auth, routes, or child-runtime configuration capture any public URL.
const restoredCloudRuntimeIdentity = await initializeCloudRuntimeIdentity(db as any);
if (restoredCloudRuntimeIdentity) config = loadConfig();
if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) {
throw new Error(
`local_trusted mode requires loopback host binding (received: ${config.host}). ` +

View File

@ -0,0 +1,33 @@
import type { RequestHandler } from "express";
import type { Db } from "@paperclipai/db";
import { logger } from "./logger.js";
import {
applyCloudRuntimeIdentityAssertion,
CLOUD_RUNTIME_IDENTITY_HEADER,
} from "../services/cloud-runtime-identity.js";
/**
* Accepts Cloud's signed identity only on the existing bootstrap health call.
* The JWS is sufficient authorization; the browser-facing proxy strips this
* header, and possession of the shared tenant-session token cannot mint it.
*/
export function cloudRuntimeIdentityMiddleware(db: Db): RequestHandler {
return async (req, res, next) => {
const assertion = req.get(CLOUD_RUNTIME_IDENTITY_HEADER)?.trim();
if (!assertion) {
next();
return;
}
if (req.method !== "GET" || req.path !== "/api/health") {
res.status(400).json({ error: "cloud_runtime_identity_wrong_endpoint" });
return;
}
try {
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion });
next();
} catch (error) {
logger.warn({ err: error }, "Rejected Cloud runtime identity assertion");
res.status(401).json({ error: "invalid_cloud_runtime_identity" });
}
};
}

View File

@ -57,6 +57,7 @@ import {
tooManyRequests
} from "../errors.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js";
/**
* Floor: when the hosting operator hides the Instance Access surface
@ -153,6 +154,8 @@ function requestBaseUrl(req: Request) {
}
function resolveBaseUrl(req: Request, authPublicBaseUrl?: string): string {
const runtimeOrigin = runtimeCanonicalOrigin();
if (runtimeOrigin) return runtimeOrigin;
if (authPublicBaseUrl) return authPublicBaseUrl.replace(/\/+$/, "");
return requestBaseUrl(req);
}

View File

@ -12,6 +12,7 @@ import {
isCloudManagedInstance,
type CloudInstanceEnv,
} from "../services/cloud-instance.js";
import { getCloudRuntimeIdentity } from "../services/cloud-runtime-identity.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import {
inspectDatabaseBackupHealth,
@ -88,12 +89,19 @@ function redactedDatabaseBackupHealth(databaseBackup: DatabaseBackupHealthStatus
function getCloudHealthStatus(env: CloudInstanceEnv) {
const context = getCloudStackContext(env);
if (!context) return undefined;
const runtimeIdentity = env === process.env ? getCloudRuntimeIdentity() : null;
return {
managed: true as const,
managedBy: "paperclip-cloud" as const,
stackSlug: context.stackSlug,
cloudBaseUrl: context.cloudOrigin,
...(runtimeIdentity ? {
runtimeIdentity: {
canonicalOrigin: runtimeIdentity.canonicalOrigin,
stackSlug: runtimeIdentity.stackSlug,
},
} : {}),
};
}

View File

@ -60,6 +60,7 @@ import {
type PaperclipCloudConnector,
paperclipCloudConnectorCapabilitiesFromEnv,
} from "../services/paperclip-cloud-connector.js";
import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js";
import {
completePaperclipCloudConnectorEnrollment,
loadPaperclipCloudConnectorIdentity,
@ -298,12 +299,14 @@ export function toolAccessRoutes(
}
function configuredPublicBaseUrl() {
const runtimeOrigin = runtimeCanonicalOrigin();
if (runtimeOrigin) return runtimeOrigin;
const raw = (
process.env.PAPERCLIP_PUBLIC_URL?.trim()
|| process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim()
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim()
|| process.env.BETTER_AUTH_URL?.trim()
|| process.env.BETTER_AUTH_BASE_URL?.trim()
|| options.authPublicBaseUrl?.trim()
|| process.env.PAPERCLIP_PUBLIC_URL?.trim()
|| process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim()
);
if (!raw) return null;

View File

@ -0,0 +1,123 @@
import { generateKeyPairSync, sign, type KeyObject } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
CLOUD_RUNTIME_IDENTITY_AUDIENCE,
CLOUD_RUNTIME_IDENTITY_ISSUER,
CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
runtimeCanonicalOrigin,
runtimePublicOrigin,
verifyCloudRuntimeIdentityAssertion,
} from "./cloud-runtime-identity.js";
const STACK_ID = "stack-pool-123";
const POOL_ORIGIN = "https://pool-123.staging.paperclip.app";
const CANONICAL_ORIGIN = "https://gonzo.staging.paperclip.app";
const NOW = new Date("2099-01-01T00:00:00.000Z");
const pair = generateKeyPairSync("ed25519");
const publicJwk = {
...pair.publicKey.export({ format: "jwk" }),
kid: "runtime-identity-test-key",
use: "sig",
alg: "EdDSA",
};
const env = {
PAPERCLIP_CLOUD_STACK_ID: STACK_ID,
PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: JSON.stringify({ keys: [publicJwk] }),
} as NodeJS.ProcessEnv;
function encodeJson(value: Record<string, unknown>) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function assertion(input: {
claims?: Record<string, unknown>;
header?: Record<string, unknown>;
signingKey?: KeyObject;
} = {}) {
const iat = Math.floor(NOW.getTime() / 1000);
const header = encodeJson({
alg: "EdDSA",
typ: CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
kid: publicJwk.kid,
...input.header,
});
const payload = encodeJson({
v: 1,
iss: CLOUD_RUNTIME_IDENTITY_ISSUER,
aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE,
sub: STACK_ID,
claimId: "pool-entry-123",
previousOrigin: POOL_ORIGIN,
canonicalOrigin: CANONICAL_ORIGIN,
stackSlug: "gonzo",
iat,
exp: iat + 300,
...input.claims,
});
const signature = sign(
null,
Buffer.from(`${header}.${payload}`, "ascii"),
input.signingKey ?? pair.privateKey,
).toString("base64url");
return `${header}.${payload}.${signature}`;
}
function verifyAssertion(compactJws: string, overrides: Partial<NodeJS.ProcessEnv> = {}) {
return verifyCloudRuntimeIdentityAssertion({
compactJws,
env: { ...env, ...overrides },
now: NOW,
expectedPreviousOrigin: POOL_ORIGIN,
});
}
describe("verifyCloudRuntimeIdentityAssertion", () => {
it("preserves distinct self-hosted public and authentication origins", () => {
const selfHostedEnv = {
PAPERCLIP_PUBLIC_URL: "https://app.example.test",
PAPERCLIP_AUTH_PUBLIC_BASE_URL: "https://auth.example.test",
PAPERCLIP_API_URL: "https://api.example.test",
} as NodeJS.ProcessEnv;
expect(runtimePublicOrigin(selfHostedEnv)).toBe("https://app.example.test");
expect(runtimeCanonicalOrigin()).toBeNull();
});
it("accepts a Cloud-signed claim for this exact stack and pool origin", () => {
expect(verifyAssertion(assertion())).toMatchObject({
sub: STACK_ID,
claimId: "pool-entry-123",
canonicalOrigin: CANONICAL_ORIGIN,
stackSlug: "gonzo",
});
});
it("rejects forged signatures and unknown signing keys", () => {
const attacker = generateKeyPairSync("ed25519");
expect(() => verifyAssertion(assertion({ signingKey: attacker.privateKey }))).toThrow("signature is invalid");
expect(() => verifyAssertion(assertion({ header: { kid: "unknown" } }))).toThrow("unknown signing key");
});
it.each([
["expired", { exp: Math.floor(NOW.getTime() / 1000) - 1 }],
["wrong audience", { aud: "someone-else" }],
["cross-stack", { sub: "stack-someone-else" }],
["wrong pool origin", { previousOrigin: "https://someone-else.staging.paperclip.app" }],
["non-HTTPS destination", { canonicalOrigin: "http://gonzo.staging.paperclip.app" }],
["path-bearing destination", { canonicalOrigin: `${CANONICAL_ORIGIN}/GON` }],
["slug mismatch", { stackSlug: "kermit" }],
["partial claims", { claimId: undefined }],
])("rejects %s assertions", (_label, claims) => {
expect(() => verifyAssertion(assertion({ claims }))).toThrow();
});
it("rejects an altered signed destination", () => {
const valid = assertion();
const [header, payload, signature] = valid.split(".");
const altered = encodeJson({
...JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")),
canonicalOrigin: "https://attacker.example.com",
});
expect(() => verifyAssertion(`${header}.${altered}.${signature}`)).toThrow("signature is invalid");
});
});

View File

@ -0,0 +1,429 @@
import { createPublicKey, timingSafeEqual, verify, type JsonWebKey } from "node:crypto";
import { eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { instanceSettings } from "@paperclipai/db";
export const CLOUD_RUNTIME_IDENTITY_HEADER = "x-paperclip-cloud-runtime-identity";
export const CLOUD_RUNTIME_IDENTITY_AUDIENCE = "paperclip-runtime-identity/v1";
export const CLOUD_RUNTIME_IDENTITY_ISSUER = "paperclip-cloud";
export const CLOUD_RUNTIME_IDENTITY_JWS_TYPE = "paperclip-cloud-runtime-identity+jwt";
const SINGLETON_KEY = "cloud-runtime-identity/v1";
const MAX_ASSERTION_LIFETIME_SECONDS = 10 * 60;
const MAX_CLOCK_SKEW_SECONDS = 30;
const STACK_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
type PersistedRuntimeIdentity = {
stackId: string;
claimId: string;
previousOrigin: string;
canonicalOrigin: string;
stackSlug: string;
appliedAt: Date;
};
type RuntimeIdentityDb = Pick<Db, "select" | "insert">;
export type CloudRuntimeIdentitySnapshot = {
stackId: string;
claimId: string;
previousOrigin: string;
canonicalOrigin: string;
stackSlug: string;
appliedAt: Date;
};
export type RuntimeIdentityClaims = {
v: 1;
iss: typeof CLOUD_RUNTIME_IDENTITY_ISSUER;
aud: typeof CLOUD_RUNTIME_IDENTITY_AUDIENCE;
sub: string;
claimId: string;
previousOrigin: string;
canonicalOrigin: string;
stackSlug: string;
iat: number;
exp: number;
};
let initialized = false;
let startupOrigin: string | null = null;
let currentIdentity: CloudRuntimeIdentitySnapshot | null = null;
function nonEmpty(value: string | undefined): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
function exactHttpsOrigin(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
try {
const parsed = new URL(value);
if (
parsed.protocol !== "https:"
|| parsed.username
|| parsed.password
|| parsed.pathname !== "/"
|| parsed.search
|| parsed.hash
|| parsed.origin !== value
) {
return null;
}
return parsed.origin;
} catch {
return null;
}
}
function configuredStartupOrigin(env: NodeJS.ProcessEnv): string | null {
const candidate = nonEmpty(env.PAPERCLIP_PUBLIC_URL)
?? nonEmpty(env.PAPERCLIP_AUTH_PUBLIC_BASE_URL)
?? nonEmpty(env.PAPERCLIP_API_URL);
return candidate ? exactHttpsOrigin(candidate) : null;
}
function snapshot(row: PersistedRuntimeIdentity): CloudRuntimeIdentitySnapshot {
return {
stackId: row.stackId,
claimId: row.claimId,
previousOrigin: row.previousOrigin,
canonicalOrigin: row.canonicalOrigin,
stackSlug: row.stackSlug,
appliedAt: row.appliedAt,
};
}
function parsePersistedIdentity(row: {
general: Record<string, unknown>;
createdAt: Date;
}): PersistedRuntimeIdentity {
const value = row.general;
if (
value.v !== 1
|| typeof value.stackId !== "string"
|| typeof value.claimId !== "string"
|| typeof value.previousOrigin !== "string"
|| typeof value.canonicalOrigin !== "string"
|| typeof value.stackSlug !== "string"
) {
throw new Error("Persisted Cloud runtime identity is malformed");
}
return {
stackId: value.stackId,
claimId: value.claimId,
previousOrigin: value.previousOrigin,
canonicalOrigin: value.canonicalOrigin,
stackSlug: value.stackSlug,
appliedAt: row.createdAt,
};
}
async function readPersistedIdentity(db: RuntimeIdentityDb): Promise<PersistedRuntimeIdentity | null> {
const row = await db
.select({
general: instanceSettings.general,
createdAt: instanceSettings.createdAt,
})
.from(instanceSettings)
.where(eq(instanceSettings.singletonKey, SINGLETON_KEY))
.limit(1)
.then((rows) => rows[0] ?? null);
return row ? parsePersistedIdentity(row) : null;
}
function applyCompatibilityEnvironment(identity: CloudRuntimeIdentitySnapshot, env: NodeJS.ProcessEnv) {
const hostname = new URL(identity.canonicalOrigin).hostname;
env.PAPERCLIP_PUBLIC_URL = identity.canonicalOrigin;
env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = identity.canonicalOrigin;
env.PAPERCLIP_API_URL = identity.canonicalOrigin;
env.PAPERCLIP_PRIMARY_HOST = hostname;
env.PAPERCLIP_STACK_SLUG = identity.stackSlug;
const existingCandidates = (() => {
try {
const parsed = JSON.parse(env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]");
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
} catch {
return [];
}
})();
env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([
identity.canonicalOrigin,
...existingCandidates.filter((candidate) => candidate !== identity.canonicalOrigin),
]);
}
function assertPersistedIdentityMatchesStack(row: PersistedRuntimeIdentity, env: NodeJS.ProcessEnv) {
const configuredStackId = nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID);
if (!configuredStackId || configuredStackId !== row.stackId) {
throw new Error("Persisted Cloud runtime identity does not match PAPERCLIP_CLOUD_STACK_ID");
}
if (!exactHttpsOrigin(row.previousOrigin) || !exactHttpsOrigin(row.canonicalOrigin)) {
throw new Error("Persisted Cloud runtime identity contains an invalid origin");
}
if (!STACK_SLUG_PATTERN.test(row.stackSlug) || new URL(row.canonicalOrigin).hostname.split(".")[0] !== row.stackSlug) {
throw new Error("Persisted Cloud runtime identity contains an invalid stack slug");
}
}
/** Load the durable claim before auth, routes, and child-runtime configuration. */
export async function initializeCloudRuntimeIdentity(
db: Db,
env: NodeJS.ProcessEnv = process.env,
): Promise<CloudRuntimeIdentitySnapshot | null> {
startupOrigin = configuredStartupOrigin(env);
// Self-hosted servers have no Cloud stack identity to restore. Avoid touching
// the singleton table on that path; besides keeping the feature inert, this
// preserves lightweight startup/test database seams that intentionally do
// not construct a database client.
if (!nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID)) {
initialized = true;
currentIdentity = null;
return null;
}
const row = await readPersistedIdentity(db);
initialized = true;
if (!row) {
currentIdentity = null;
return null;
}
assertPersistedIdentityMatchesStack(row, env);
currentIdentity = snapshot(row);
applyCompatibilityEnvironment(currentIdentity, env);
return currentIdentity;
}
export function getCloudRuntimeIdentity(): CloudRuntimeIdentitySnapshot | null {
return currentIdentity ? { ...currentIdentity } : null;
}
/** The live canonical origin, falling back to startup configuration off Cloud. */
export function runtimePublicOrigin(env: NodeJS.ProcessEnv = process.env): string | null {
if (env === process.env && currentIdentity) return currentIdentity.canonicalOrigin;
const candidate = nonEmpty(env.PAPERCLIP_PUBLIC_URL)
?? nonEmpty(env.PAPERCLIP_AUTH_PUBLIC_BASE_URL)
?? nonEmpty(env.PAPERCLIP_API_URL);
if (!candidate) return null;
try {
return new URL(candidate).origin;
} catch {
return null;
}
}
/** The asserted Cloud origin only. Callers retain their non-Cloud precedence. */
export function runtimeCanonicalOrigin(): string | null {
return currentIdentity?.canonicalOrigin ?? null;
}
function decodeJsonPart(part: string, label: string): Record<string, unknown> {
if (!/^[A-Za-z0-9_-]+$/.test(part)) throw new Error(`Cloud runtime identity has an invalid ${label}`);
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
} catch {
throw new Error(`Cloud runtime identity has an invalid ${label}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Cloud runtime identity has an invalid ${label}`);
}
return parsed as Record<string, unknown>;
}
function publicKeyForKid(env: NodeJS.ProcessEnv, kid: string) {
const raw = nonEmpty(env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS);
if (!raw) throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is not configured");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is invalid");
}
const keys = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as { keys?: unknown }).keys
: undefined;
if (!Array.isArray(keys)) throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is invalid");
const matches = keys.filter((candidate): candidate is JsonWebKey & { kid: string } => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
const key = candidate as JsonWebKey & { kid?: unknown };
return key.kid === kid;
});
if (matches.length !== 1) throw new Error("Cloud runtime identity uses an unknown signing key");
const jwk = matches[0];
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || jwk.use !== "sig" || jwk.alg !== "EdDSA" || !jwk.x || jwk.d) {
throw new Error("Cloud runtime identity signing key is invalid");
}
return createPublicKey({ key: jwk, format: "jwk" });
}
function verifyClaims(input: {
compactJws: string;
env: NodeJS.ProcessEnv;
now: Date;
}): RuntimeIdentityClaims {
const parts = input.compactJws.split(".");
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
throw new Error("Cloud runtime identity assertion is not a compact JWS");
}
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = decodeJsonPart(encodedHeader, "protected header");
if (
header.alg !== "EdDSA"
|| header.typ !== CLOUD_RUNTIME_IDENTITY_JWS_TYPE
|| typeof header.kid !== "string"
|| !header.kid
) {
throw new Error("Cloud runtime identity protected header is invalid");
}
const key = publicKeyForKid(input.env, header.kid);
const signature = Buffer.from(encodedSignature, "base64url");
const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii");
if (!verify(null, signingInput, key, signature)) {
throw new Error("Cloud runtime identity signature is invalid");
}
const payload = decodeJsonPart(encodedPayload, "payload");
const nowSeconds = Math.floor(input.now.getTime() / 1000);
if (
payload.v !== 1
|| payload.iss !== CLOUD_RUNTIME_IDENTITY_ISSUER
|| payload.aud !== CLOUD_RUNTIME_IDENTITY_AUDIENCE
|| typeof payload.sub !== "string"
|| typeof payload.claimId !== "string"
|| typeof payload.previousOrigin !== "string"
|| typeof payload.canonicalOrigin !== "string"
|| typeof payload.stackSlug !== "string"
|| typeof payload.iat !== "number"
|| !Number.isInteger(payload.iat)
|| typeof payload.exp !== "number"
|| !Number.isInteger(payload.exp)
) {
throw new Error("Cloud runtime identity claims are incomplete");
}
if (
payload.exp <= nowSeconds
|| payload.iat > nowSeconds + MAX_CLOCK_SKEW_SECONDS
|| payload.exp <= payload.iat
|| payload.exp - payload.iat > MAX_ASSERTION_LIFETIME_SECONDS
) {
throw new Error("Cloud runtime identity assertion is expired or has an invalid lifetime");
}
return payload as RuntimeIdentityClaims;
}
/** Verify that an assertion is signed for this exact, still-unclaimed instance. */
export function verifyCloudRuntimeIdentityAssertion(input: {
compactJws: string;
env?: NodeJS.ProcessEnv;
now?: Date;
expectedPreviousOrigin: string | null;
}): RuntimeIdentityClaims {
const env = input.env ?? process.env;
const claims = verifyClaims({ compactJws: input.compactJws, env, now: input.now ?? new Date() });
const configuredStackId = nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID);
if (!configuredStackId || claims.sub !== configuredStackId) {
throw new Error("Cloud runtime identity stack does not match this instance");
}
const previousOrigin = exactHttpsOrigin(claims.previousOrigin);
const canonicalOrigin = exactHttpsOrigin(claims.canonicalOrigin);
if (!previousOrigin || !canonicalOrigin || previousOrigin !== input.expectedPreviousOrigin) {
throw new Error("Cloud runtime identity previous or canonical origin is invalid");
}
if (
!STACK_SLUG_PATTERN.test(claims.stackSlug)
|| new URL(canonicalOrigin).hostname.split(".")[0] !== claims.stackSlug
|| claims.claimId.length > 256
|| claims.claimId.trim() !== claims.claimId
|| !claims.claimId
) {
throw new Error("Cloud runtime identity destination is invalid");
}
return claims;
}
function assertionsEqual(row: PersistedRuntimeIdentity, claims: RuntimeIdentityClaims): boolean {
const left = Buffer.from(JSON.stringify([
row.stackId,
row.claimId,
row.previousOrigin,
row.canonicalOrigin,
row.stackSlug,
]));
const right = Buffer.from(JSON.stringify([
claims.sub,
claims.claimId,
claims.previousOrigin,
claims.canonicalOrigin,
claims.stackSlug,
]));
return left.length === right.length && timingSafeEqual(left, right);
}
/** Verify and durably apply the one-time Cloud claim assertion. */
export async function applyCloudRuntimeIdentityAssertion(input: {
db: Db;
compactJws: string;
env?: NodeJS.ProcessEnv;
now?: Date;
}): Promise<CloudRuntimeIdentitySnapshot> {
const env = input.env ?? process.env;
if (!initialized) throw new Error("Cloud runtime identity provider is not initialized");
const claims = verifyCloudRuntimeIdentityAssertion({
compactJws: input.compactJws,
env,
now: input.now,
// After a natural restart the provider env may already be canonical, but
// an identical retry of the original claim is still safe and idempotent.
// The durable row preserves the pool origin that assertion had to match
// on first application.
expectedPreviousOrigin: currentIdentity?.previousOrigin ?? startupOrigin,
});
const previousOrigin = claims.previousOrigin;
const canonicalOrigin = claims.canonicalOrigin;
const row = await input.db.transaction(async (tx) => {
const existing = await readPersistedIdentity(tx);
if (existing) {
if (!assertionsEqual(existing, claims)) {
throw new Error("Cloud runtime identity is already claimed by another assertion");
}
return existing;
}
const now = input.now ?? new Date();
await tx
.insert(instanceSettings)
.values({
singletonKey: SINGLETON_KEY,
general: {
v: 1,
stackId: claims.sub,
claimId: claims.claimId,
previousOrigin,
canonicalOrigin,
stackSlug: claims.stackSlug,
},
experimental: {},
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing({ target: instanceSettings.singletonKey });
const durable = await readPersistedIdentity(tx);
if (!durable || !assertionsEqual(durable, claims)) {
throw new Error("Cloud runtime identity is already claimed by another assertion");
}
return durable;
});
currentIdentity = snapshot(row);
applyCompatibilityEnvironment(currentIdentity, env);
return { ...currentIdentity };
}
/** Test seam for modules that intentionally share a process. */
export function resetCloudRuntimeIdentityForTests() {
initialized = false;
startupOrigin = null;
currentIdentity = null;
}

View File

@ -11,6 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
import path from "node:path";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { runtimePublicOrigin } from "./cloud-runtime-identity.js";
const IDENTITY_VERSION = 1;
const ENROLLMENT_FILE = "paperclip-cloud-connector.json";
@ -90,7 +91,8 @@ export function paperclipCloudConnectorEnrollmentStatus(
origins: [],
};
}
const publicOrigin = env.PAPERCLIP_PUBLIC_URL ? normalizeInstanceOrigin(env.PAPERCLIP_PUBLIC_URL) : undefined;
const resolvedOrigin = runtimePublicOrigin(env);
const publicOrigin = resolvedOrigin ? normalizeInstanceOrigin(resolvedOrigin) : undefined;
return {
configured: true,
status: "active",

View File

@ -77,6 +77,7 @@ import {
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
import { logActivity } from "./activity-log.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { runtimePublicOrigin } from "./cloud-runtime-identity.js";
const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"];
const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"];
@ -102,6 +103,12 @@ const WEEKDAY_INDEX: Record<string, number> = {
Sat: 6,
};
export function routineWebhookUrl(publicId: string): string {
const baseUrl = runtimePublicOrigin() ?? process.env.PAPERCLIP_API_URL?.trim();
if (!baseUrl) throw new Error("PAPERCLIP_API_URL is required to create a routine webhook");
return `${baseUrl.replace(/\/+$/, "")}/api/routine-triggers/public/${publicId}/fire`;
}
type ExecutionIssueTransientFailureStatus = (typeof EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES)[number];
function executionIssueTransientFailureReason(status: ExecutionIssueTransientFailureStatus) {
@ -2438,7 +2445,7 @@ export function routineService(
const created = await createWebhookSecret(routine.companyId, routine.id, actor);
secretId = created.secret.id;
secretMaterial = {
webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${publicId}/fire`,
webhookUrl: routineWebhookUrl(publicId),
webhookSecret: created.secretValue,
};
}
@ -2621,7 +2628,7 @@ export function routineService(
return {
trigger: trigger as RoutineTrigger,
secretMaterial: {
webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${existing.publicId}/fire`,
webhookUrl: routineWebhookUrl(existing.publicId),
webhookSecret: secretValue,
},
revision,
@ -2701,7 +2708,7 @@ export function routineService(
secretId: created.secret.id,
secretMaterial: {
triggerId: trigger.id,
webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${publicId}/fire`,
webhookUrl: routineWebhookUrl(publicId),
webhookSecret: created.secretValue,
},
});