Files
PaperClipAI/server/src/services/muse-receiver.ts
T
DottaandPaperclip e6dcc6b095 Connect personal Muse through secure native execution and Vault OAuth (#16448)
Add company-scoped Muse native control, OAuth, wake receiver and recovery.

Recheck current authority after waits and before effects or receipt replay. Preserve task-read scope, assignment expiry, independent holds, nonblocking reassignment and truthful cleanup provenance.

Retain source trust on progress comments and verify current Muse V8 authority through real signed-JWT HTTP requests. Preserve the upstream trimmed ordinary authentication lookup.

Refs #16448.

Preserve captured canonical queue recovery and concurrent environment preparation while retaining the scoped Muse V8 exclusion. Normal migration 0354 and existing Muse SQL remain unchanged. Fresh current-head Greptile review is 5/5 with the project-write finding addressed. Required CI and independent technical review gate the merge; maintainer override is reserved for formal approval rules.

Recheck current Muse project-write authority after all receipt/task/run/admission waits, before creation or replay. Real PostgreSQL regressions reproduce four late effects/replays on the old route; the repaired three-file treatment passes 354 cases with no skips, and the scoped server typecheck passes. Ordinary/Dot behavior is preserved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-17 22:44:36 -05:00

114 lines
8.6 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import { museAgentBindings as bindings, museMailboxItems as mailbox, museRunnerAssignments as assignments, museReceiverContactBuckets as buckets, externalAgentHolds as holds, type Db } from "@paperclipai/db";
import { assertMuseReceiverAuthority, assertMuseCurrentAuthority, inspectMuseConnectionCredential, museIdentity, museStopControlBoundaryIds, hasMuseStopControlAccess } from "./muse-identity.js";
import { canConfigureAgentConnection } from "../modules/agent-lifecycle/index.js";
import { HttpError } from "../errors.js";
const PERSISTENCE_LAG_MS=30_000;
const instances=new WeakMap<Db,ReturnType<typeof createReceiver>>();
export function museReceiver(db:Db) {let receiver=instances.get(db);if(!receiver){receiver=createReceiver(db);instances.set(db,receiver);}return receiver;}
function createReceiver(db:Db) {
const replicaId=randomUUID();
const pending=new Map<string,{companyId:string;bindingId:string;bindingGeneration:number;bucketAt:Date;firstAt:Date;lastAt:Date;contacts:number;gapsAtMostSevenSeconds:number;maxGapMs:number;timestamps:string[];incomplete:boolean}>();
const previous=new Map<string,number>();
let flushPromise:Promise<void>|undefined;
async function flush() {
if(flushPromise)return flushPromise;
flushPromise=(async()=>{
const batch=[...pending.entries()];pending.clear();
for(const [key,bucket]of batch) {
try {
await db.transaction(async tx=>{
await tx.insert(buckets).values({...bucket,replicaId}).onConflictDoUpdate({target:[buckets.bindingId,buckets.bindingGeneration,buckets.replicaId,buckets.bucketAt],set:{lastAt:bucket.lastAt,contacts:sql`${buckets.contacts}+${bucket.contacts}`,gapsAtMostSevenSeconds:sql`${buckets.gapsAtMostSevenSeconds}+${bucket.gapsAtMostSevenSeconds}`,maxGapMs:sql`greatest(${buckets.maxGapMs},${bucket.maxGapMs})`,timestamps:sql`${buckets.timestamps} || ${JSON.stringify(bucket.timestamps)}::jsonb`,incomplete:sql`${buckets.incomplete} or ${bucket.incomplete}`}});
await tx.update(bindings).set({receiverContactAt:sql`greatest(${bindings.receiverContactAt},${bucket.lastAt.toISOString()}::timestamptz)`}).where(and(eq(bindings.id,bucket.bindingId),eq(bindings.generation,bucket.bindingGeneration)));
});
const [current]=await db.select().from(bindings).where(and(eq(bindings.id,bucket.bindingId),eq(bindings.generation,bucket.bindingGeneration),isNull(bindings.revokedAt)));
if(current?.pairedAt&&!current.verifiedReplyAt&&!current.challengeHash) {
const {museRunnerBroker}=await import("./muse-runner-broker.js");
await museRunnerBroker(db).verify(current.companyId,current.agentId,{bindingId:current.id,generation:current.generation,expectedRevision:current.revision}).catch(()=>{});
}
} catch {
const newer=pending.get(key);
if(newer){newer.firstAt=bucket.firstAt;newer.contacts+=bucket.contacts;newer.timestamps=[...bucket.timestamps,...newer.timestamps].slice(0,60);newer.incomplete=true;}
else pending.set(key,{...bucket,incomplete:true});
}
}
})().finally(()=>{flushPromise=undefined;});return flushPromise;
}
function recordPublicContact(b: typeof bindings.$inferSelect, now: number) {
const key = `${b.id}:${b.generation}:${Math.floor(now / PERSISTENCE_LAG_MS)}`;
const gap = previous.has(b.id) ? now - previous.get(b.id)! : 0;
previous.set(b.id, now);
let bucket = pending.get(key);
if (!bucket) {
bucket = { companyId: b.companyId, bindingId: b.id, bindingGeneration: b.generation,
bucketAt: new Date(Math.floor(now / PERSISTENCE_LAG_MS) * PERSISTENCE_LAG_MS),
firstAt: new Date(now), lastAt: new Date(now), contacts: 0,
gapsAtMostSevenSeconds: 0, maxGapMs: 0, timestamps: [], incomplete: false };
pending.set(key, bucket);
}
bucket.contacts++; bucket.lastAt = new Date(now);
if (gap > 0 && gap <= 7000) bucket.gapsAtMostSevenSeconds++;
bucket.maxGapMs = Math.max(bucket.maxGapMs, gap);
if (bucket.timestamps.length < 60) bucket.timestamps.push(new Date(now).toISOString());
else bucket.incomplete = true;
}
/** A public binding reference is presence evidence, never worker authority. */
async function signal(bindingId: string, expectedResource?:string) {
const idle = { version: 1 as const, signal: null };
const unavailable = () => new HttpError(410, "Muse receiver is unavailable.");
const [candidate] = await db.select().from(bindings).where(eq(bindings.id, bindingId));
if (!candidate || candidate.revokedAt || !candidate.pairedAt) throw unavailable();
if (candidate.qualificationExpiresAt && candidate.qualificationExpiresAt <= new Date()) {
const { museRunnerBroker } = await import("./muse-runner-broker.js");
await museRunnerBroker(db).endQualification({ companyId: candidate.companyId, agentId: candidate.agentId,
bindingId: candidate.id, qualificationId: candidate.qualificationId!, operatorId: candidate.operatorId });
throw unavailable();
}
let contacted: typeof bindings.$inferSelect | undefined;
const result = await db.transaction(async tx => {
const [current] = await tx.select().from(bindings).where(and(eq(bindings.id, candidate.id),
eq(bindings.generation, candidate.generation), isNull(bindings.revokedAt))).for("update");
if (!current) throw unavailable();
let agent;
try {
const credential=await inspectMuseConnectionCredential(tx,current,expectedResource);
if(credential.state==="pending"){
await assertMuseCurrentAuthority(tx,current,true,true);contacted=current;return idle;
}
agent = await assertMuseReceiverAuthority(tx, current,expectedResource);
}
catch (error) {
// Infrastructure failures must not permanently uninstall a healthy hook.
if (error instanceof HttpError && [401, 403, 404, 410].includes(error.status)) throw unavailable();
throw error;
}
contacted = current;
// Pausing suppresses ordinary work, while the worker still needs its
// exact stop boundary to quiesce and report through authenticated control.
const disabled=!await museIdentity(db).enabled();
if(disabled&&!await hasMuseStopControlAccess(tx,current))return idle;
const stopOnly = disabled || (agent.status === "paused" && !canConfigureAgentConnection(agent));
const boundaries=tx.select({boundary:holds.stopBoundary}).from(holds).where(inArray(holds.id,museStopControlBoundaryIds(tx,current)));
const [item] = await tx.select({ item: mailbox }).from(mailbox).leftJoin(assignments, eq(assignments.id, mailbox.assignmentId))
.where(and(eq(mailbox.bindingId, current.id), eq(mailbox.bindingGeneration, current.generation),
stopOnly ? and(eq(mailbox.kind,"authority_revoked"),sql`${mailbox.id} > ${current.workerCursor}`,inArray(sql`${mailbox.references}->'boundary'`,boundaries))
: sql`((${mailbox.kind} = 'assignment' and ${assignments.claimedAt} is null and ${assignments.status} = 'offered') or (${mailbox.kind} = 'readiness_challenge' and ${!!current.challengeHash && !!current.challengeExpiresAt && current.challengeExpiresAt > new Date()} and ${mailbox.id} > ${current.workerCursor} and encode(sha256(convert_to(${mailbox.references}->>'nonce','UTF8')),'hex') = ${current.challengeHash}) or (${mailbox.kind} not in ('assignment','readiness_challenge') and ${mailbox.id} > ${current.workerCursor}))`))
.orderBy(asc(mailbox.id)).limit(1);
if (!item) return idle;
const now = Date.now(), rearm = !item.item.signalNotifiedAt || item.item.signalNotifiedAt.getTime() <= now - 30_000;
const attempt = item.item.signalAttempt + (rearm ? 1 : 0);
if (rearm) await tx.update(mailbox).set({ signalAttempt: attempt, signalNotifiedAt: new Date(now) }).where(eq(mailbox.id, item.item.id));
// UUID-bearing event identity prevents enumeration of global mailbox IDs.
const reference = "sha256:" + createHash("sha256").update(`${current.id}:${current.generation}:${item.item.sourceEventId}:${attempt}`).digest("hex");
return { version: 1 as const, signal: { reference } };
});
if (contacted) recordPublicContact(contacted, Date.now());
return result;
}
let timer:NodeJS.Timeout|undefined;
function start() {if(timer)return;timer=setInterval(()=>void flush().catch(()=>{}),PERSISTENCE_LAG_MS);timer.unref();}
async function stop() {if(timer)clearInterval(timer);timer=undefined;await flush();}
return {signal,flush,start,stop,persistenceLagMs:PERSISTENCE_LAG_MS};
}