mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
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>
107 lines
11 KiB
TypeScript
107 lines
11 KiB
TypeScript
import express, { Router, type Request } from "express";
|
|
import { z } from "zod";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { MUSE_PUBLIC_ROUTES, MUSE_MAX_BODY_BYTES, MUSE_OAUTH_SCOPES, MUSE_RESOURCE_PATH, musePairSchema, museRefreshSchema, museCommandSchema, museQuerySchema, museCleanupSchema, museDetectorCleanupSchema, museStopBoundarySchema, museDetectorCleanupAttestationSchema } from "@paperclipai/shared";
|
|
import { museExternalAgents } from "../modules/external-agents/index.js";
|
|
import { museIdentity } from "../services/muse-identity.js";
|
|
import { museOAuth } from "../services/muse-oauth.js";
|
|
import { McpOAuthError } from "../services/public-mcp/oauth.js";
|
|
import { museReceiver } from "../services/muse-receiver.js";
|
|
import { museReceiverAssets, musePublicOrigin } from "../services/muse-setup.js";
|
|
import { museInvitationService } from "../services/muse-invitations.js";
|
|
import { agentService } from "../services/agents.js";
|
|
import { canConfigureAgentConnection } from "../modules/agent-lifecycle/index.js";
|
|
import { authorizationService } from "../services/authorization.js";
|
|
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
|
import { HttpError, badRequest, forbidden, notFound, unauthorized, payloadTooLarge } from "../errors.js";
|
|
function bearer(req:Request) {const header=req.get("authorization")??"";if(!/^Bearer [A-Za-z0-9_-]{20,200}$/.test(header))throw unauthorized("A Muse bearer credential is required.");return header.slice(7);}
|
|
/** Exact manifest is registered here and consumed by Cloud. No adjacent namespace has machine authority. */
|
|
export function museTransportRoutes(db:Db,publicOrigin?:string,options:{allowLegacyCredentials?:boolean}={}) {
|
|
const router=Router(),identity=museIdentity(db),broker=museExternalAgents(db,{publicOrigin}),receiver=museReceiver(db);
|
|
router.use((req,res,next)=>{
|
|
if(!req.originalUrl.startsWith("/api/muse"))return next();
|
|
const rawPath=req.originalUrl.split("?")[0];
|
|
if(!MUSE_PUBLIC_ROUTES.some(route=>route.method===req.method&&route.path===rawPath)||(req.originalUrl.includes("?")&&rawPath!=="/api/muse/v1/oauth/authorize"))return res.status(404).json({error:"Muse protocol route not found"});
|
|
res.set("Cache-Control","no-store").set("Referrer-Policy","no-referrer");return next();
|
|
});
|
|
router.use("/api/muse/v1",express.json({limit:MUSE_MAX_BODY_BYTES,strict:true}));
|
|
router.use("/api/muse/v1/oauth",express.urlencoded({extended:false,limit:"16kb"}));
|
|
async function authenticateWorker(req:Request,kind:"access"|"cleanup"|"detector_cleanup"="access",stopControl=false) {
|
|
const value=bearer(req);
|
|
try {
|
|
return await (options.allowLegacyCredentials&&!value.startsWith("pcmcp_at_") ? identity.authenticate(value,kind,{stopControl}) : identity.authenticateOAuthAccess(value,publicOrigin,{stopControl}));
|
|
}catch(error){
|
|
// Worker transport uses REST errors. OAuth protocol errors retain their
|
|
// retryable status without leaking token or provider exception details.
|
|
if(error instanceof McpOAuthError)throw new HttpError(error.status,error.status===401?"Connect Muse again.":"Muse authentication is temporarily unavailable.");
|
|
throw error;
|
|
}
|
|
}
|
|
function bound(req:Request) {if(Buffer.byteLength(JSON.stringify(req.body??{}))>MUSE_MAX_BODY_BYTES)throw payloadTooLarge("Muse body exceeds protocol limit.");}
|
|
for(const route of MUSE_PUBLIC_ROUTES) {
|
|
const handler=async(req:Request,res:express.Response)=>{
|
|
bound(req);
|
|
const asset = /^\/api\/muse\/v1\/assets\/([123456789])\/(manifest\.json|client\.py|detector\.sh|instructions\.md)$/.exec(route.path);
|
|
if (asset) {
|
|
const assets = await museReceiverAssets(asset[1] === "1" ? 1 : asset[1] === "2" ? 2 : asset[1] === "3" ? 3 : asset[1] === "4" ? 4 : asset[1] === "5" ? 5 : asset[1] === "6" ? 6 : asset[1] === "7" ? 7 : asset[1] === "8" ? 8 : 9);
|
|
res.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
if (asset[2] === "manifest.json") res.json(assets.manifest);
|
|
else res.type("text/plain").send(asset[2] === "client.py" ? assets.client : asset[2] === "detector.sh" ? assets.detector : assets.instructions);
|
|
return;
|
|
}
|
|
if(route.path.startsWith("/api/muse/v1/oauth/")) {
|
|
if(Buffer.byteLength(JSON.stringify(req.body??{}))>16_384)throw payloadTooLarge("OAuth body exceeds protocol limit.");
|
|
const oauth=museOAuth(db,publicOrigin),source=req.ip??req.socket.remoteAddress??"unknown";
|
|
try {
|
|
if(route.path.endsWith("/register")){res.status(201).json(await oauth.register(req.body,source));return;}
|
|
if(route.path.endsWith("/authorize")){res.redirect(303,await oauth.authorize(req.query,source));return;}
|
|
if(route.path.endsWith("/token")){res.json(await oauth.token(req.body??{}));return;}
|
|
if(route.path.endsWith("/revoke")){
|
|
const value=z.object({token:z.string().min(1).max(200),client_id:z.string().min(1).max(2048),resource:z.literal(oauth.config.resource).optional()}).strip().safeParse(req.body);
|
|
if(!value.success)throw new McpOAuthError("invalid_request","A token, client_id and matching resource are required.");
|
|
await oauth.revokeToken(value.data.token,value.data.client_id);res.status(200).end();return;
|
|
}
|
|
await oauth.assertEnabled();
|
|
res.json({issuer:oauth.issuer,authorization_endpoint:oauth.issuer+"/authorize",token_endpoint:oauth.issuer+"/token",registration_endpoint:oauth.issuer+"/register",revocation_endpoint:oauth.issuer+"/revoke",
|
|
resource:oauth.config.resource,scopes_supported:MUSE_OAUTH_SCOPES,grant_types_supported:["authorization_code","refresh_token"],response_types_supported:["code"],token_endpoint_auth_methods_supported:["none"],code_challenge_methods_supported:["S256"],authorization_response_iss_parameter_supported:true});return;
|
|
}catch(error){if(error instanceof McpOAuthError){res.status(error.status).json({error:error.code,error_description:error.message});return;}throw error;}
|
|
}
|
|
if(route.path.endsWith("/pair")) {if(!options.allowLegacyCredentials)throw notFound("Legacy test pairing is unavailable.");const value=musePairSchema.parse(req.body);res.json(await identity.pair(value.ticket,value.clientVersion));return;}
|
|
if(route.path.endsWith("/refresh")) {if(!options.allowLegacyCredentials)throw notFound("Legacy test refresh is unavailable.");const value=museRefreshSchema.parse(req.body);res.json(await identity.refresh(value.refreshToken));return;}
|
|
if(route.path.endsWith("/signal")) {const bindingId=z.uuid().parse(req.get("X-Paperclip-Muse-Binding"));res.json(await receiver.signal(bindingId,musePublicOrigin(publicOrigin)+MUSE_RESOURCE_PATH));return;}
|
|
if(route.path.endsWith("/cleanup")) {const value=museCleanupSchema.parse(req.body);const subject=await authenticateWorker(req,"cleanup",true);res.json(await broker.cleanup(subject,value));return;}
|
|
if(route.path.endsWith("/detector-cleanup")) {const value=museDetectorCleanupSchema.parse(req.body),subject=await authenticateWorker(req,"detector_cleanup");if(value.bindingId!==subject.bindingId||value.generation!==subject.generation)throw forbidden("Detector cleanup identity mismatch.");res.json(await broker.detectorCleanup(subject,"detectorRemoved" in value));return;}
|
|
const query=route.path.endsWith("/queries")?museQuerySchema.safeParse(req.body):null;
|
|
// Only a validated, content-free mailbox read may use disabled stop auth.
|
|
const subject=await authenticateWorker(req,"access",!!query?.success&&query.data.query==="mailbox");
|
|
if(route.path.endsWith("/commands"))res.json(await broker.act(subject,museCommandSchema.parse(req.body)));
|
|
else res.json(await broker.inspect(subject,museQuerySchema.parse(req.body)));
|
|
};
|
|
if(route.method==="GET")router.get(route.path,handler);else router.post(route.path,handler);
|
|
}
|
|
return router;
|
|
}
|
|
export function museBoardRoutes(db:Db,publicOrigin?:string) {
|
|
const router=Router(),broker=museExternalAgents(db,{publicOrigin}),invitations=museInvitationService(db);
|
|
async function scope(req:Request,write=false) {
|
|
const companyId=z.uuid().parse(req.params.companyId);assertBoard(req);assertCompanyAccess(req,companyId);
|
|
const operatorId=req.actor.userId;
|
|
if(!operatorId||!["session","cloud_tenant","board_key"].includes(req.actor.source??"")||req.actor.memberships?.find(m=>m.companyId===companyId)?.membershipRole==="viewer")throw forbidden("Sign in as a company operator to configure Muse.");
|
|
const agentId=req.params.agentId?z.uuid().parse(req.params.agentId):undefined;
|
|
if(write) {const decision=await authorizationService(db).decide({actor:req.actor,action:agentId?"agent_config:update":"agents:create",resource:agentId?{type:"agent",companyId,agentId}:{type:"company",companyId},scope:{requiresChangeGrant:true}});if(!decision.allowed)throw forbidden(decision.explanation);}
|
|
if(agentId){const agent=await agentService(db).getById(agentId);if(!agent||agent.companyId!==companyId)throw notFound("Agent not found.");}
|
|
return {companyId,agentId,operatorId};
|
|
}
|
|
const invite="/companies/:companyId/muse-invitations",path="/companies/:companyId/agents/:agentId/muse-binding";
|
|
const identitySchema=z.object({bindingId:z.uuid(),generation:z.number().int().positive(),expectedRevision:z.number().int().positive()}).strict();
|
|
router.get(invite,async(req,res)=>{const s=await scope(req,true);res.set("Cache-Control","no-store").json(await invitations.resume(s.companyId,s.operatorId));});
|
|
router.post(invite,async(req,res)=>{const s=await scope(req,true),input=z.object({name:z.string().trim().min(1).max(100),role:z.string().trim().min(1).max(100)}).strict().parse(req.body);res.set("Cache-Control","no-store").json(await invitations.create(s.companyId,s.operatorId,input));});
|
|
router.get(path,async(req,res)=>{const s=await scope(req),agent=(await agentService(db).getById(s.agentId!))!;let origin:string|null=null;try{origin=musePublicOrigin(publicOrigin);}catch{/* setup diagnostic remains visible */}res.set("Cache-Control","no-store").json({enabled:await broker.enabled(),publicOrigin:origin,agentStatus:agent.status,agentLifecycleState:agent.lifecycleState,canConfigureConnection:canConfigureAgentConnection(agent),binding:await broker.bindingForAgent(s.companyId,s.agentId!),usage:null,cost:null});});
|
|
router.post(path,async(req,res)=>{const s=await scope(req,true),input=z.object({replaceBindingId:z.uuid().optional(),expectedRevision:z.number().int().positive().optional()}).strict().parse(req.body??{});res.set("Cache-Control","no-store").status(201).json(await broker.createPairing({...s,agentId:s.agentId!,...input}));});
|
|
router.post(path+"/verify",async(req,res)=>{const s=await scope(req,true);res.json(await broker.verify(s.companyId,s.agentId!,identitySchema.parse(req.body),s.operatorId));});
|
|
router.post(path+"/revoke",async(req,res)=>{const s=await scope(req,true);await broker.revoke(s.companyId,s.agentId!,s.operatorId,identitySchema.parse(req.body));res.status(204).end();});
|
|
router.post(path+"/attest-detector-cleanup",async(req,res)=>{const s=await scope(req,true);res.set("Cache-Control","no-store").json(await broker.attestDetectorCleanup(s.companyId,s.agentId!,s.operatorId,museDetectorCleanupAttestationSchema.parse(req.body),req.actor));});
|
|
router.post(path+"/attest-stop",async(req,res)=>{const s=await scope(req,true),input=z.object({boundary:museStopBoundarySchema,expectedRevision:z.number().int().positive(),workerStopped:z.literal(true)}).strict().parse(req.body);res.json(await broker.attestStop(s.companyId,s.agentId!,s.operatorId,input));});
|
|
return router;
|
|
}
|