Merge reviewed ingress API wake admission parent

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-02 13:33:15 -06:00
commit 28ea563d71
10 files changed
+299 -26

No files matched your search

@@ -1,3 +1,4 @@
import { matchesApiWakeAdmission } from "../services/idle-api-wake-admission.js";
import { generateKeyPairSync, randomUUID, sign } from "node:crypto";
import express from "express";
import request from "supertest";
@@ -81,7 +82,8 @@ const capability: IdleIncomingWakeCapability = {
version: 1,
ownerId,
generation: "a".repeat(64),
routes: ["tenant-api-http-v1"],
apiKeyProof: { version: 1 as const, challenge: "c".repeat(64) },
routes: ["tenant-api-http-v2"],
};
function verified(incomingWake = capability): VerifiedIdleCapabilities {
const pair = generateKeyPairSync("ed25519"),
@@ -135,6 +137,7 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities {
db = createDb(url ?? database!.connectionString, { maxConnections: 4 });
await db.execute(sql`SELECT 1 FROM agent_api_keys LIMIT 1`); // Coordinator migrates the fresh fixture; no embedded fallback.
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-only");
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: null });
});
afterAll(async () => {
@@ -238,6 +241,17 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities {
backgroundWork: "none",
incomingWake: capability,
});
const receipt = (await read(verified())).apiWakeAdmission!;
expect(receipt.keys).toHaveLength(2);
expect(matchesApiWakeAdmission(receipt, { stackId: "fixture-stack", token: f.board.token, now })).toBe(true);
expect(matchesApiWakeAdmission(receipt, { stackId: "fixture-stack", token: f.agent.token, now })).toBe(true);
const serialized = JSON.stringify(receipt);
expect(serialized).not.toContain(f.board.token);
expect(serialized).not.toContain(f.agent.token);
await db.update(boardApiKeys).set({ revokedAt: new Date() }).where(eq(boardApiKeys.id, f.board.id));
const changedReceipt = (await read(verified())).apiWakeAdmission!;
expect(changedReceipt.generation).not.toBe(receipt.generation);
expect(matchesApiWakeAdmission(changedReceipt, { stackId: "fixture-stack", token: f.board.token, now })).toBe(false);
expect(
(await read(verified({ ...capability, ownerId: randomUUID() })))
.backgroundWork,
@@ -456,7 +470,7 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities {
const stop = registerConnectionEventWake(db, () => {});
const botCap: IdleIncomingWakeCapability = {
...capability,
routes: ["github-broker-events-v1", "tenant-api-http-v1"],
routes: ["github-broker-events-v1", "tenant-api-http-v2"],
githubBroker: {
instanceId: "fixture-instance",
bindingsFingerprint: incomingWakeFingerprint([botTuple]),
@@ -534,7 +548,7 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities {
vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_BASE_URL", "http://127.0.0.1");
const brokerCap: IdleIncomingWakeCapability = {
...capability,
routes: ["github-broker-events-v1", "tenant-api-http-v1"],
routes: ["github-broker-events-v1", "tenant-api-http-v2"],
githubBroker: {
instanceId: "fixture-instance",
bindingsFingerprint: incomingWakeFingerprint([
@@ -1,3 +1,4 @@
import { generateKeyPairSync, randomUUID, sign } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -81,21 +82,24 @@ function localAdminActor() {
describe("instance settings routes", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
const [{ errorHandler }, { instanceSettingsRoutes }, { cloudControlMiddleware }, controlIdentity] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
vi.importActual<typeof import("../middleware/cloud-control.js")>("../middleware/cloud-control.js"),
vi.importActual<typeof import("../services/cloud-runtime-identity.js")>("../services/cloud-runtime-identity.js"),
]);
return { errorHandler, instanceSettingsRoutes };
return { errorHandler, instanceSettingsRoutes, cloudControlMiddleware, controlIdentity };
});
function createApp(actor: any, pluginWorkers?: PluginWorkerManager, prepareBackup?: () => Promise<boolean>) {
const { errorHandler, instanceSettingsRoutes } = routeModules.value;
function createApp(actor: any, pluginWorkers?: PluginWorkerManager, prepareBackup?: () => Promise<boolean>, verifyControl = false) {
const { errorHandler, instanceSettingsRoutes, cloudControlMiddleware } = routeModules.value;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
if (verifyControl) app.use(cloudControlMiddleware());
app.use("/api", instanceSettingsRoutes(mockDb as any, pluginWorkers, prepareBackup));
app.use(errorHandler);
return app;
@@ -1101,7 +1105,7 @@ describe("instance settings routes", () => {
await request(createApp(adminActor)).post("/api/instance/task-drain").send({ purpose: "idle", ttlMs: 60_000 }).expect(200);
expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ purpose: "idle", ttlMs: 60_000 });
await request(createApp(adminActor)).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined, false, false);
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined, false, false, { incomingWake: undefined, deadlineCapability: undefined });
});
it.each([{}, { ttlMs: null }, { ttlMs: 1 }, { ttlMs: 300_001 }])("rejects an unbounded or invalid idle TTL: %j", async body => {
@@ -1128,7 +1132,7 @@ describe("instance settings routes", () => {
const res = await request(createApp(adminActor)).get("/api/instance/task-drain?idleSleepSafety=1");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ...idleStatus, idleSleepSafety: report });
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined, false, false);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined, false, false, { incomingWake: undefined, deadlineCapability: undefined });
expect(mockReadIdleSleepSafety.mock.calls[0][1]()).toEqual(idleStatus);
});
@@ -1142,7 +1146,7 @@ describe("instance settings routes", () => {
.get("/api/instance/task-drain?idleSleepSafety=1&idleSleepDiagnostics=1&ownerId=fixture-owner").expect(200);
expect(response.body.idleSleepSafety.diagnostics.checks).toEqual([{ reason: "agent_api_keys", state: "present" }]);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
"fixture-owner", expect.any(Function), undefined, undefined, false, false, { collectDiagnostics: true });
"fixture-owner", expect.any(Function), undefined, undefined, false, false, { incomingWake: undefined, deadlineCapability: undefined, collectDiagnostics: true });
mockReadIdleSleepSafety.mockClear();
for (const actor of [nonAdminActor, { type: "agent", agentId: "agent-1", companyId: "company-1", source: "api_key" }]) {
await request(createApp(actor))
@@ -1164,7 +1168,104 @@ describe("instance settings routes", () => {
await request(createApp({ ...adminActor, source: "cloud_control" }))
.get(`/api/instance/task-drain?idleSleepSafety=1&scheduledWake=${version}&ownerId=fixture-owner`).expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
"fixture-owner", expect.any(Function), undefined, undefined, true, version === "2");
"fixture-owner", expect.any(Function), undefined, undefined, true, version === "2", { incomingWake: undefined, deadlineCapability: undefined });
});
describe("signed incoming capability handoff", () => {
const createControlApp = (actor: any) => createApp(actor, undefined, undefined, true);
const keyPair = generateKeyPairSync("ed25519");
const publicJwk = { ...keyPair.publicKey.export({ format: "jwk" }), kid: "idle-route-test", alg: "EdDSA", use: "sig" };
const ownerId = "d0b833f4-4098-42de-8420-1907f3aa4895";
const catalog = {
incomingWake: { version: 1, ownerId, generation: "a".repeat(64), apiKeyProof: { version: 1, challenge: "c".repeat(64) }, routes: ["tenant-api-http-v2"] },
deadlineCatalog: "domain_deadlines_v1",
};
const path = `/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`;
function assertion(claims: Record<string, unknown> = {}) {
const identity = routeModules.value.controlIdentity;
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const iat = Math.floor(Date.now() / 1000);
const header = encode({ alg: "EdDSA", typ: identity.CLOUD_CONTROL_JWS_TYPE, kid: publicJwk.kid });
const payload = encode({ v: 1, iss: identity.CLOUD_RUNTIME_IDENTITY_ISSUER, aud: identity.CLOUD_CONTROL_AUDIENCE,
sub: "stack-idle-route-test", action: "task-drain:read", requestId: randomUUID(), iat, exp: iat + 60,
idleCapabilities: catalog, ...claims });
return `${header}.${payload}.${sign(null, Buffer.from(`${header}.${payload}`, "ascii"), keyPair.privateKey).toString("base64url")}`;
}
beforeEach(() => {
vi.stubEnv("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS", JSON.stringify({ keys: [publicJwk] }));
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "stack-idle-route-test");
routeModules.value.controlIdentity.resetCloudControlReplayFenceForTests();
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
mockReadIdleSleepSafety.mockResolvedValue({ version: 3, backgroundWork: "none" });
});
afterEach(() => vi.unstubAllEnvs());
it("passes only the immutable signed catalog through real middleware and the production route", async () => {
const token = assertion();
const response = await request(createControlApp({ type: "none" }))
.get(`${path}&incomingWake=${encodeURIComponent(JSON.stringify({ ...catalog.incomingWake, generation: "b".repeat(64) }))}`)
.set("x-paperclip-cloud-control", token)
.send({ idleCapabilities: { incomingWake: { ...catalog.incomingWake, ownerId: randomUUID() } } }).expect(200);
expect(response.body.idleSleepSafety).toEqual({ version: 3, backgroundWork: "none" });
expect(mockReadIdleSleepSafety).toHaveBeenCalledExactlyOnceWith(mockDb, expect.any(Function), Date.now,
ownerId, expect.any(Function), undefined, undefined, false, false,
{ incomingWake: catalog, deadlineCapability: undefined });
const options = mockReadIdleSleepSafety.mock.calls[0][9];
expect(options.deadlineCapability).toBeUndefined();
expect(Object.isFrozen(options.incomingWake)).toBe(true);
expect(Object.isFrozen(options.incomingWake.incomingWake)).toBe(true);
expect(Object.isFrozen(options.incomingWake.incomingWake.routes)).toBe(true);
await request(createControlApp({ type: "none" })).get(path).set("x-paperclip-cloud-control", token).expect(401);
expect(mockReadIdleSleepSafety).toHaveBeenCalledTimes(1);
});
it("passes the signed deadline catalog only for its explicit named query", async () => {
await request(createControlApp({ type: "none" })).get(`${path}&scheduledWake=domain-deadlines-v1`)
.set("x-paperclip-cloud-control", assertion()).expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenCalledExactlyOnceWith(mockDb, expect.any(Function), Date.now,
ownerId, expect.any(Function), undefined, undefined, true, true,
{ incomingWake: catalog, deadlineCapability: catalog });
const options = mockReadIdleSleepSafety.mock.calls[0][9];
expect(options.incomingWake).toBe(options.deadlineCapability);
expect(Object.isFrozen(options.deadlineCapability)).toBe(true);
});
it.each([
["wrong stack", () => ({ sub: "other-stack" })],
["wrong action", () => ({ action: "task-drain:start" })],
["expired", () => ({ iat: Math.floor(Date.now() / 1000) - 600, exp: Math.floor(Date.now() / 1000) - 300 })],
["unsupported catalog", () => ({ idleCapabilities: { incomingWake: { ...catalog.incomingWake, routes: ["unverified-path"] } } })],
])("rejects %s before safety inspection", async (_name, claims) => {
await request(createControlApp(adminActor)).get(path).set("x-paperclip-cloud-control", assertion(claims())).expect(401);
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
});
it("rejects a tampered catalog signature before safety inspection", async () => {
const [header, payload, signature] = assertion().split(".");
const changed = JSON.parse(Buffer.from(payload!, "base64url").toString());
changed.idleCapabilities.incomingWake.generation = "b".repeat(64);
const tampered = `${header}.${Buffer.from(JSON.stringify(changed)).toString("base64url")}.${signature}`;
await request(createControlApp(adminActor)).get(path).set("x-paperclip-cloud-control", tampered).expect(401);
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
});
it("leaves legacy signed reads without a catalog conservative", async () => {
await request(createControlApp({ type: "none" })).get(path)
.set("x-paperclip-cloud-control", assertion({ idleCapabilities: undefined })).expect(200);
expect(mockReadIdleSleepSafety.mock.calls[0][9]).toEqual({ incomingWake: undefined, deadlineCapability: undefined });
});
it("cannot install capabilities from tenant, actor, query or body values", async () => {
for (const source of ["session", "cloud_tenant", "local_implicit", "cloud_control"]) {
const actor = { ...adminActor, source, idleCapabilities: catalog, incomingWake: catalog };
await request(createControlApp(actor)).get(`${path}&incomingWake=${encodeURIComponent(JSON.stringify(catalog))}`)
.send({ idleCapabilities: catalog }).expect(200);
expect(mockReadIdleSleepSafety.mock.lastCall?.[9]).toEqual({ incomingWake: undefined, deadlineCapability: undefined });
}
mockReadIdleSleepSafety.mockClear();
await request(createControlApp(nonAdminActor)).get(path).send({ idleCapabilities: catalog }).expect(403);
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
});
});
it("uses the live worker manager for inspection and releases only the owned hold", async () => {
@@ -1199,7 +1300,7 @@ describe("instance settings routes", () => {
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
await request(app).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
"fixture-owner", expect.any(Function), undefined, backup, false, false);
"fixture-owner", expect.any(Function), undefined, backup, false, false, { incomingWake: undefined, deadlineCapability: undefined });
mockReadIdleSleepSafety.mockClear();
await request(createApp(nonAdminActor, undefined, backup)).get("/api/instance/task-drain?idleSleepSafety=1").expect(403);
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
@@ -1221,7 +1322,7 @@ describe("instance settings routes", () => {
const control = createApp({ ...adminActor, source: "cloud_control" }, undefined, backup);
await request(control).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
"fixture-owner", expect.any(Function), undefined, backup, false, false);
"fixture-owner", expect.any(Function), undefined, backup, false, false, { incomingWake: undefined, deadlineCapability: undefined });
} finally {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
}
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { apiWakeFingerprint, createApiWakeAdmission, parseApiWakeAdmission, matchesApiWakeAdmission } from "./idle-api-wake-admission.js";
const token = "api-wake-vector-token";
const keyHash = createHash("sha256").update(token).digest("hex");
const input = { stackId: "vector-stack", ownerId: "00000000-0000-4000-8000-000000000001", capabilityGeneration: "a".repeat(64), challenge: "c".repeat(64) };
const fingerprint = apiWakeFingerprint({ ...input, keyHash });
const receipt = createApiWakeAdmission({ ...input, keys: [{ fingerprint, expiresAt: null }] });
describe("API wake receipt wire vectors", () => {
it("uses exact domain-separated vectors and exports neither token nor database hash", () => {
expect(fingerprint).toBe("6478041b90f4665eeca8403186fefedc456bf1322f38dab9fb89212eecaafd43");
expect(JSON.stringify(receipt)).not.toContain(token);
expect(JSON.stringify(receipt)).not.toContain(keyHash);
expect(matchesApiWakeAdmission(receipt, { stackId: input.stackId, token, now: new Date("2099-01-01").getTime() })).toBe(true);
expect(matchesApiWakeAdmission(receipt, { stackId: "other", token, now: 0 })).toBe(false);
expect(matchesApiWakeAdmission(receipt, { stackId: input.stackId, token: "unknown", now: 0 })).toBe(false);
});
it("enforces board expiry and canonical unique bounded complete receipts", () => {
const changedChallenge = createApiWakeAdmission({ ...input, challenge: "b".repeat(64), keys: receipt.keys });
expect(matchesApiWakeAdmission(changedChallenge, { stackId: input.stackId, token, now: 0 })).toBe(false);
const expiring = createApiWakeAdmission({ ...input, keys: [{ fingerprint, expiresAt: new Date(1000).toISOString() }] });
expect(matchesApiWakeAdmission(expiring, { stackId: input.stackId, token, now: 999 })).toBe(true);
expect(matchesApiWakeAdmission(expiring, { stackId: input.stackId, token, now: 1000 })).toBe(false);
for (const invalid of [
{ ...receipt, generation: "b".repeat(64) }, { ...receipt, challenge: "b".repeat(64) },
{ ...receipt, extra: true }, { ...receipt, keys: [...receipt.keys, ...receipt.keys] },
{ ...receipt, keys: Array.from({ length: 1001 }, () => receipt.keys[0]) },
{ ...receipt, keys: [{ fingerprint, expiresAt: "2099-01-01" }] },
{ ...receipt, keys: [{ fingerprint: "d".repeat(64), expiresAt: null }, { fingerprint: "a".repeat(64), expiresAt: null }] },
]) expect(() => parseApiWakeAdmission(invalid)).toThrow();
});
});
@@ -0,0 +1,56 @@
import { createHash } from "node:crypto";
export type ApiWakeClaim = { generation: string; fingerprint: string };
export interface ApiWakeAdmission {
version: 1;
stackId: string;
ownerId: string;
capabilityGeneration: string;
challenge: string;
generation: string;
keys: { fingerprint: string; expiresAt: string | null }[];
}
const digest = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
const object = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value));
const exact = (value: Record<string, unknown>, keys: string[]) => Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key));
const generation = (value: Omit<ApiWakeAdmission, "generation">) => createHash("sha256").update(JSON.stringify([
"paperclip-api-wake-receipt-v1", value.version, value.stackId, value.ownerId, value.capabilityGeneration, value.challenge,
value.keys.map(key => [key.fingerprint, key.expiresAt]),
])).digest("hex");
export function apiWakeFingerprint(input: { stackId: string; ownerId: string; challenge: string; keyHash: string }): string {
if (!digest(input.keyHash) || !digest(input.challenge)) throw new Error("Invalid API wake fingerprint input");
return createHash("sha256").update(JSON.stringify(["paperclip-api-wake-v1", input.stackId, input.ownerId, input.challenge, input.keyHash])).digest("hex");
}
/** Strict bounded canonical wire contract. Parsing never grants tenant permissions. */
export function parseApiWakeAdmission(value: unknown): ApiWakeAdmission {
if (!object(value) || !exact(value, ["version", "stackId", "ownerId", "capabilityGeneration", "challenge", "generation", "keys"])
|| value.version !== 1 || typeof value.stackId !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(value.stackId)
|| typeof value.ownerId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.ownerId)
|| !digest(value.capabilityGeneration) || !digest(value.challenge) || !digest(value.generation)
|| !Array.isArray(value.keys) || value.keys.length > 1000) throw new Error("Invalid API wake receipt");
let previous = "";
for (const key of value.keys) {
if (!object(key) || !exact(key, ["fingerprint", "expiresAt"]) || !digest(key.fingerprint) || key.fingerprint <= previous
|| !(key.expiresAt === null || typeof key.expiresAt === "string" && key.expiresAt.length <= 30 && Number.isFinite(Date.parse(key.expiresAt))
&& new Date(key.expiresAt).toISOString() === key.expiresAt)) throw new Error("Invalid API wake receipt key");
previous = key.fingerprint;
}
const parsed = value as unknown as ApiWakeAdmission;
if (generation(parsed) !== parsed.generation) throw new Error("API wake receipt generation changed");
return { version: 1, stackId: parsed.stackId, ownerId: parsed.ownerId, capabilityGeneration: parsed.capabilityGeneration,
challenge: parsed.challenge, generation: parsed.generation,
keys: parsed.keys.map(key => ({ fingerprint: key.fingerprint, expiresAt: key.expiresAt })) };
}
export function createApiWakeAdmission(input: Omit<ApiWakeAdmission, "version" | "generation">): ApiWakeAdmission {
const canonical = { version: 1 as const, ...input, keys: [...input.keys].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)) };
return parseApiWakeAdmission({ ...canonical, generation: generation(canonical) });
}
export function authorizeApiWakeAdmission(receipt: ApiWakeAdmission, input: { stackId: string; token: string; now: number }): ApiWakeClaim | undefined {
if (receipt.stackId !== input.stackId || input.token.length < 1 || input.token.length > 8192 || /\s/.test(input.token)) return;
const fingerprint = apiWakeFingerprint({ ...receipt, keyHash: createHash("sha256").update(input.token).digest("hex") });
return receipt.keys.some(key => key.fingerprint === fingerprint && (key.expiresAt === null || Date.parse(key.expiresAt) > input.now))
? { generation: receipt.generation, fingerprint } : undefined;
}
export function matchesApiWakeAdmission(receipt: ApiWakeAdmission, input: { stackId: string; token: string; now: number }): boolean {
return authorizeApiWakeAdmission(receipt, input) !== undefined;
}
@@ -12,7 +12,8 @@ const incoming = {
version: 1 as const,
ownerId: "00000000-0000-4000-8000-000000000001",
generation: "a".repeat(64),
routes: ["tenant-api-http-v1" as const],
apiKeyProof: { version: 1 as const, challenge: "c".repeat(64) },
routes: ["tenant-api-http-v2" as const],
};
const invalid: unknown[] = [
null,
@@ -24,7 +25,7 @@ const invalid: unknown[] = [
{
incomingWake: {
...incoming,
routes: ["tenant-api-http-v1", "tenant-api-http-v1"],
routes: ["tenant-api-http-v2", "tenant-api-http-v2"],
},
},
{ incomingWake: { ...incoming, ownerId: "other" } },
@@ -52,7 +53,7 @@ describe("signed idle capabilities", () => {
};
const parsed = parseIdleCapabilities(source);
source.incomingWake.routes.length = 0;
expect(parsed.incomingWake?.routes).toEqual(["tenant-api-http-v1"]);
expect(parsed.incomingWake?.routes).toEqual(["tenant-api-http-v2"]);
expect(Object.isFrozen(parsed.incomingWake?.routes)).toBe(true);
});
it("binds capability to signed stack, action and replay fence", () => {
@@ -3,7 +3,8 @@ export interface IdleIncomingWakeCapability {
version: 1;
ownerId: string;
generation: string;
routes: ("tenant-api-http-v1" | "github-broker-events-v1")[];
routes: ("tenant-api-http-v2" | "github-broker-events-v1")[];
apiKeyProof?: { version: 1; challenge: string };
githubBroker?: { instanceId: string; bindingsFingerprint: string };
}
export interface IdleCapabilities {
@@ -38,6 +39,7 @@ export function parseIdleCapabilities(value: unknown): IdleCapabilities {
"generation",
"routes",
"githubBroker",
"apiKeyProof",
]) ||
v.version !== 1 ||
typeof v.ownerId !== "string" ||
@@ -49,12 +51,17 @@ export function parseIdleCapabilities(value: unknown): IdleCapabilities {
v.routes.length < 1 ||
v.routes.length > 2 ||
v.routes.some(
(r) => !["tenant-api-http-v1", "github-broker-events-v1"].includes(r),
(r) => !["tenant-api-http-v2", "github-broker-events-v1"].includes(r),
) ||
new Set(v.routes).size !== v.routes.length ||
[...v.routes].sort().join() !== v.routes.join()
)
throw new Error("Invalid incoming wake capability");
if (v.routes.includes("tenant-api-http-v2")) {
const proof = v.apiKeyProof;
if (!object(proof) || Object.keys(proof).length !== 2 || !exact(proof, ["version", "challenge"])
|| proof.version !== 1 || !digest(proof.challenge)) throw new Error("Invalid API wake challenge");
} else if (v.apiKeyProof !== undefined) throw new Error("Unexpected API wake challenge");
if (v.routes.includes("github-broker-events-v1")) {
const b = v.githubBroker;
if (
@@ -72,6 +79,7 @@ export function parseIdleCapabilities(value: unknown): IdleCapabilities {
const parsed = JSON.parse(JSON.stringify(value)) as IdleCapabilities;
if (parsed.incomingWake) {
Object.freeze(parsed.incomingWake.routes);
if (parsed.incomingWake.apiKeyProof) Object.freeze(parsed.incomingWake.apiKeyProof);
if (parsed.incomingWake.githubBroker)
Object.freeze(parsed.incomingWake.githubBroker);
Object.freeze(parsed.incomingWake);
@@ -156,3 +156,29 @@ describe("bounded incoming wake owner projection", () => {
expect(sql.select).toHaveBeenCalledTimes(2);
});
});
describe("bounded API admission projection", () => {
const api = { incomingWake: { version: 1, ownerId, generation: "a".repeat(64), routes: ["tenant-api-http-v2"],
apiKeyProof: { version: 1, challenge: "c".repeat(64) } } } as VerifiedIdleCapabilities;
it.each(["overflow", "duplicate", "malformed"])("refuses complete eligibility for %s key state", async kind => {
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
const row = { keyHash: "a".repeat(64), expiresAt: null };
const rows = kind === "overflow" ? Array.from({ length: 1001 }, () => row)
: kind === "duplicate" ? [row, row] : [{ keyHash: "not-a-digest", expiresAt: null }];
const execute = vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(rows);
await expect(readIdleIncomingWakeProof({ execute } as unknown as Pick<Db, "select" | "execute">,
{ ownerId, receiverOwner: {}, capabilities: api })).rejects.toThrow();
expect(execute).toHaveBeenCalledTimes(2);
});
it("has an explicit complete empty-key receipt and shares the inspection time budget", async () => {
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
const execute = vi.fn(async () => []);
const proof = await readIdleIncomingWakeProof({ execute } as unknown as Pick<Db, "select" | "execute">,
{ ownerId, receiverOwner: {}, capabilities: api });
expect(proof?.apiWakeAdmission?.keys).toEqual([]);
let clock = 0;
const slow = vi.fn(async () => { clock += 1100; return []; });
await expect(readIdleIncomingWakeProof({ execute: slow } as unknown as Pick<Db, "select" | "execute">,
{ ownerId, receiverOwner: {}, capabilities: api, now: () => clock })).rejects.toThrow(/budget exhausted/);
});
});
+26 -3
View File
@@ -1,3 +1,4 @@
import { apiWakeFingerprint, createApiWakeAdmission, type ApiWakeAdmission } from "./idle-api-wake-admission.js";
import { createHash } from "node:crypto";
import { and, eq, inArray, sql, type SQL } from "drizzle-orm";
import {
@@ -17,6 +18,7 @@ import { hasConnectionEventWakeReceiver } from "./connection-event-wake.js";
export interface IdleIncomingWakeProof {
capability: IdleIncomingWakeCapability;
apiWakeAdmission?: ApiWakeAdmission;
connectionIds: string[];
endpointIds: string[];
/** A broker enrollment cannot discharge these credential continuity obligations. */
@@ -59,7 +61,6 @@ export async function readIdleIncomingWakeProof(
endpointIds: [],
requiredContinuityConnectionIds: [],
};
if (!capability.routes.includes("github-broker-events-v1")) return proof;
const monotonic = input.now ?? (() => performance.now());
const started = monotonic();
const remaining = () => {
@@ -75,6 +76,28 @@ export async function readIdleIncomingWakeProof(
);
remaining();
};
if (capability.routes.includes("tenant-api-http-v2")) {
const stackId = process.env.PAPERCLIP_CLOUD_STACK_ID;
if (!stackId || !capability.apiKeyProof) throw new Error("API wake challenge is missing");
await beforeRead();
const rows = await tx.execute<{ keyHash: string; expiresAt: Date | null }>(sql`
SELECT key_hash AS "keyHash", NULL::timestamptz AS "expiresAt" FROM agent_api_keys WHERE revoked_at IS NULL
UNION ALL SELECT key_hash AS "keyHash", expires_at AS "expiresAt" FROM board_api_keys
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) LIMIT 1001`);
remaining();
if (rows.length > 1000) throw new Error("Too many API keys to certify wake admission");
proof.apiWakeAdmission = createApiWakeAdmission({ stackId, ownerId: input.ownerId,
capabilityGeneration: capability.generation, challenge: capability.apiKeyProof.challenge,
keys: rows.map(row => ({ fingerprint: apiWakeFingerprint({ stackId, ownerId: input.ownerId,
challenge: capability.apiKeyProof!.challenge, keyHash: row.keyHash }),
expiresAt: row.expiresAt === null ? null : new Date(row.expiresAt).toISOString() })),
});
}
if (!capability.routes.includes("github-broker-events-v1")) {
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
remaining();
return proof;
}
const broker = capability.githubBroker;
const config = paperclipCloudConnectorConfigFromEnv();
if (
@@ -264,11 +287,11 @@ export function incomingWakeWorkQuery(
) ?? []),
];
if (reason === "agent_api_keys")
return proof?.capability.routes.includes("tenant-api-http-v1")
return proof?.apiWakeAdmission !== undefined
? sql`SELECT 1 WHERE false`
: sql`SELECT 1 FROM agent_api_keys WHERE revoked_at IS NULL`;
if (reason === "board_api_keys")
return proof?.capability.routes.includes("tenant-api-http-v1")
return proof?.apiWakeAdmission !== undefined
? sql`SELECT 1 WHERE false`
: sql`SELECT 1 FROM board_api_keys WHERE revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > now())`;
+4 -1
View File
@@ -1,6 +1,7 @@
import { readIdleDeadlineProof, deadlineWorkReplacement, type IdleDeadlineProof } from "./idle-deadline-proof.js";
import type { DomainScheduledWake, IdleDeadlineReceipt } from "./idle-deadline-types.js";
import type { IdlePluginCatalogEntry } from "./plugin-idle-catalog.js";
import type { ApiWakeAdmission } from "./idle-api-wake-admission.js";
import { readIdleIncomingWakeProof, incomingWakeWorkQuery, type IdleIncomingWakeProof } from "./idle-incoming-wake.js";
import type { VerifiedIdleCapabilities, IdleIncomingWakeCapability } from "./idle-incoming-wake-capability.js";
import { idleWorkSnapshot } from "./task-admission.js";
@@ -32,6 +33,7 @@ export interface IdleSleepSafety {
diagnostics?: IdleSleepDiagnostics;
incomingWake?: IdleIncomingWakeCapability;
deadlineProof?: IdleDeadlineReceipt;
apiWakeAdmission?: ApiWakeAdmission;
}
export type InspectIdlePlugins = (hold: { ownerId: string; expiresAt: number }) => Promise<{
@@ -311,7 +313,8 @@ export async function readIdleSleepSafety(
if (backgroundWork === "none" && deadlines && (!deadlineProof || deadlineProof.blocked || !deadlineProof.generation)) backgroundWork = "unknown";
const wake = deadlineProof ? deadlineProof.scheduledWake : scheduledWake;
return { version: backgroundWork === "none" && (incomingProof || deadlineProof) ? 3 : backgroundWork === "none" && wake ? 2 : 1, backgroundWork,
...(backgroundWork === "none" && incomingProof ? { incomingWake: incomingProof.capability } : {}),
...(backgroundWork === "none" && incomingProof ? { incomingWake: incomingProof.capability,
...(incomingProof.apiWakeAdmission ? { apiWakeAdmission: incomingProof.apiWakeAdmission } : {}) } : {}),
...(backgroundWork === "none" && deadlineProof?.generation ? { deadlineProof: {
catalog: "domain_deadlines_v1" as const, generation: deadlineProof.generation } } : {}),
...(backgroundWork === "none" && wake ? { scheduledWake: wake } : {}),