mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Merge reviewed ingress API wake admission parent
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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/);
|
||||
});
|
||||
});
|
||||
@@ -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())`;
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in new issue
Block a user