diff --git a/doc/public-mcp.md b/doc/public-mcp.md index fe266ebe0a4..622f408e3c6 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -315,6 +315,51 @@ remove expired requests and enforce 10 pending-consent requests per client and 1 instance-wide, independently of further client registrations. Existing grants remain usable when anonymous registration or authorization is throttled. +## Private Dot original-token authority + +`POST /mcp/runner/oauth/cloud-principal` is a machine-only Cloud-to-tenant +identity check for the existing `assistant-connections/client-use` boundary. +It is not an agent-facing REST/MCP tool, a consent endpoint, or a board-control +operation. Normal hostname, runtime-claim and unclaimed-standby checks run +before principal resolution. The route uses the same normally constructed Dot OAuth owner; an +absent owner or current namespace fails closed. + +The caller needs an existing Cloud signature for the closed +`mcp-dot:principal` action and the original Dot access token. The existing +signature/nonce verifier applies, with at most a 60-second action lifetime. +The request ID binds a UUID and the SHA-256 digest of this ordered, UTF-8 JSON +field tuple: + +```text +["paperclip-dot-original-principal-v1", "mcp-dot:principal", expectedInstanceId, + canonicalVerifiedResource, upstreamClientId, selectingUserId, + requestedCompanyId ?? null, accessToken] +``` + +This covers every accepted body field, independent of JSON property order. +The raw request is limited to 16 KiB; compressed bodies, invalid UTF-8, malformed +JSON and unknown fields are rejected. The resource must be the current origin's +`/mcp/runner`. The stack, claim, instance, origin and connector identity must +remain current before and after the OAuth call. OAuth authenticates the original +token once and retains its client, selected user, company, purpose, scope, +membership and feature checks. The reply contains only the checked user, +company, grant, client, resource and agent-purpose identity. It creates no board +actor, grant, agent binding or execution authority. + +The original token and signed request remain credentials. Request/response +headers, bodies, raw buffers, query data and reflected errors must not enter +logs on accepted or refused paths. The application uses a closed log projection +for this reserved path, including malformed variants; operators must keep the +same restriction at proxies. The Cloud caller requires the already verified +current instance and HTTPS tenant route, refuses redirects, and checks the +bounded reply and current namespace again. + +This private boundary does not advertise or enable a hosted Dot profile, grant +anonymous wake authority, or change Personal MCP. Hosted discovery, wake, +capability, operation, event, retry/reactivation and revocation lifecycles and +Muse integration remain separate requirements. A controlled local HTTP proof +does not establish durable Cloud server bootstrap or a deployed provider path. + ## Task monitoring with MCP Events The same authenticated endpoint now supports MCP 2.0 (`2026-07-28`) alongside diff --git a/maps/agent-access-map/assistant-connections.md b/maps/agent-access-map/assistant-connections.md index 1e5d2aa6e64..ccc8db27d33 100644 --- a/maps/agent-access-map/assistant-connections.md +++ b/maps/agent-access-map/assistant-connections.md @@ -70,11 +70,11 @@ Capability ID: `assistant-connections/client-use`. Review: `baseline`. -API: `present` — POST /mcp/paperclip for MCP tools/list and tools/call. Supported modern request transport also includes server/discover and events/list, events/subscribe, and events/unsubscribe; these are protocol operations, not arbitrary REST endpoints. +API: `present` — POST /mcp/paperclip for MCP tools/list and tools/call. Supported modern request transport also includes server/discover and events/list, events/subscribe, and events/unsubscribe; these are protocol operations, not arbitrary REST endpoints. The private POST /mcp/runner/oauth/cloud-principal identity check is machine-only Cloud control, not an agent entry point. Agent access: `available` — a configured external MCP client or the supported CLI proxy acts through the authorizing human’s grant. Each Paperclip native/remote run still needs its own actual admitted client/binding; a personal assistant connection is not automatically an agent tool or credential. -Policy: Every remote call rechecks the experimental gate, resource/client/grant/token status, expiration/revocation, human/company/membership status, scopes, and underlying domain permissions for exactly one company. The public paperclip_call_api tool permits only enumerated capabilities. An MCP bearer cannot authenticate as an ordinary REST board user or agent, and the grant confers no instance-administrator authority. The proxy does not automatically retry writes. +Policy: Every remote call rechecks the experimental gate, resource/client/grant/token status, expiration/revocation, human/company/membership status, scopes, and underlying domain permissions for exactly one company. The public paperclip_call_api tool permits only enumerated capabilities. An MCP bearer cannot authenticate as an ordinary REST board user or agent, and the grant confers no instance-administrator authority. The proxy does not automatically retry writes. The private original-token check requires the existing signed Cloud action, current namespace and the original Dot OAuth restrictions; it creates no board actor or operation permission. See the [private authority contract](../../doc/public-mcp.md#private-dot-original-token-authority). Evidence: `source-only` — reviewed at `9ef434fe79c57d976b8b03b14c5242db270b8320`. [MCP capabilities](../../server/src/services/public-mcp/capabilities.ts), [per-call authorization](../../server/src/services/public-mcp/oauth.ts), [public MCP routes](../../server/src/routes/public-mcp.ts), and [stdio proxy](../../cli/src/commands/mcp.ts). diff --git a/server/src/__tests__/cloud-principal-normal-app.postgres.test.ts b/server/src/__tests__/cloud-principal-normal-app.postgres.test.ts new file mode 100644 index 00000000000..88c02676a90 --- /dev/null +++ b/server/src/__tests__/cloud-principal-normal-app.postgres.test.ts @@ -0,0 +1,153 @@ +import { createHash, generateKeyPairSync, randomBytes, randomUUID } from "node:crypto"; +import { createServer, request as nodeRequest, type Server, type IncomingMessage, type ServerResponse } from "node:http"; +import { once } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { applyPendingMigrations, createDb, closeRegisteredClients, authUsers, companies, mcpOauthGrants } from "@paperclipai/db"; +import { resolveExternalTestDatabaseUrl } from "../lib/dedicated-test-database-url.js"; +import { accessService } from "../services/access.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { migrateCloudConfiguration } from "../services/cloud-configuration-migration.js"; +import { initializeAdapterSettings } from "../services/adapter-settings.js"; +import { initializeCloudRuntimeConfig } from "../services/cloud-runtime-config.js"; +import { setCloudRuntimeConfigSnapshot } from "../services/cloud-runtime-config-snapshot.js"; +import { createStorageService } from "../storage/service.js"; +import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js"; +import { createCloudWarmStandby } from "../services/cloud-warm-standby.js"; +import { initializeCloudRuntimeIdentity, getCloudRuntimeIdentity, resetCloudRuntimeIdentityForTests, resetCloudControlReplayFenceForTests, CLOUD_RUNTIME_IDENTITY_HEADER } from "../services/cloud-runtime-identity.js"; +import * as oauthModule from "../services/public-mcp/oauth.js"; +import { removeTestHome } from "./remove-test-home.js"; + +type Tenant={stackId:string;companyId?:string;slug:string;publicOrigin:string;upstreamOrigin:string;instanceId:string}; +type Projection={userId:string;companyId:string;grantId:string;clientId:string;resource:string;purpose:"agent"}; +type Control={stackId:string;action:"mcp-dot:principal";requestId:string}; +type PrincipalInput={tenant:Tenant;accessToken:string;upstreamClientId:string;selectingUserId:string;canonicalVerifiedResource:string;requestedCompanyId?:string}; +type Namespace={instanceId:string}; +type Instance={id:string;status:string;kind:string;companyStackId?:string;activeOrigin?:string;origins:string[]}; +type Stack={id:string;primaryHost:string;stackSlug:string}; +type Registry={updateStackWakeState:(id:string,input:unknown)=>Promise;createFleetReleaseArtifact:(input:unknown)=>Promise;importFeatureCatalog:(input:unknown)=>Promise;getAssistantMcpStack:(id:string)=>Promise;getGlobalUser:(id:string)=>Promise;hasActiveStackMembership:(stack:string,user:string)=>Promise;mcpClientMirrorStore:()=>{readCurrent:(stack:string,origin:string)=>Promise;getClient:(binding:{stackId:string;companyId:string;origin:string;instanceId:string},id:string,receipt:Namespace)=>Promise}}; +type Instances={putCloudStackInstance:(input:unknown)=>Promise;getInstance:(id:string)=>Promise;setCloudStackOrigins:(input:{id:string;companyStackId:string;origins:string[];activeOrigin:string;now:Date})=>Promise}; +type Router=new(input:{registry:{findStackByHost:(host:string)=>Promise;findStackBySlug:(slug:string)=>Promise};baseDomain:string})=>{resolveRoute:(input:{trustedHost:string})=>Promise<{kind:string;target?:{stackId:string;primaryHost:string;stackSlug:string;activeAppOrigin?:string}}>}; + +const url=resolveExternalTestDatabaseUrl("PAPERCLIP_DOT_PRINCIPAL_HOP_TEST_DATABASE_URL"); +const origin="https://principal-hop.paperclip.app",cloudOrigin="https://cloud.principal-hop.example.test",stackId="principal-hop-stack",slug="principal-hop"; +const resource=origin+"/mcp/runner",redirect="https://chatgpt.com/connector_platform/oauth/callback"; +const listen=async(server:Server)=>{server.listen(0,"127.0.0.1");await once(server,"listening");const address=server.address();if(!address||typeof address==="string")throw new Error("Missing owned principal listener");return `http://127.0.0.1:${address.port}`;}; +const close=async(server:Server|undefined)=>{if(!server?.listening)return;await new Promise((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}; +describe.skipIf(!url)("normal App signed original-principal HTTP hop with actual OAuth and Cloud signer",()=>{ + let db:ReturnType,app:express.Express|undefined,home:string,cloudServer:Server|undefined,appServer:Server|undefined; + let cloudService:{handler:(req:IncomingMessage,res:ServerResponse)=>unknown;stopBackgroundTasks:()=>void}|undefined,restoreOAuth:(()=>void)|undefined; + let signer:{publicJwks:string;signClaim:(input:{stackId:string;claimId:string;previousOrigin:string;canonicalOrigin:string;stackSlug:string})=>string;signControlAssertion:(input:{stackId:string;action:"mcp-dot:principal";requestId:string})=>string}; + let makeClient:(input:{signControlAssertion:(input:Control)=>string;refreshTenant:(user:string,stack:string)=>Promise;fetchImplementation:typeof fetch})=>(input:PrincipalInput)=>Promise; + let projectTenant:(tenant:Omit,binding:{stackId:string;origin:string;instanceId:string})=>Tenant|undefined; + let registry:Registry,connectorStore:Instances,CloudStackRouter:Router,metadataCompany:(id:string)=>string; + let cloudLoopback="",appLoopback="",upstreamOrigin="",sessionCalls=0,authenticateCalls=0,unexpectedFetches=0,casePassed=false; + const userId=randomUUID(),companyId=randomUUID(),otherCompanyId=randomUUID(),instanceId="inst_"+randomUUID(); + type TransportRecord={direction:"outbound"|"inbound";surface:"cloud"|"tenant";path:"mirror"|"revocation-index"|"principal"|"other";method:"GET"|"POST"|"other";hostMatch?:boolean;status?:number;error?:"ECONNRESET"|"ECONNREFUSED"|"EPIPE"|"ABORT_ERR"|"ETIMEDOUT"|"other"}; + const transportRecords:TransportRecord[]=[];let transportOverflow=0; + const record=(value:TransportRecord)=>{if(transportRecords.length<32)transportRecords.push(value);else transportOverflow++;return value;}; + const pathKind=(value:string):TransportRecord["path"]=>value==="/v1/connector/mcp-client-mirror"?"mirror":value==="/v1/connector/mcp-revocation-index"?"revocation-index":value==="/mcp/runner/oauth/cloud-principal"?"principal":"other"; + const methodKind=(value:string|undefined):TransportRecord["method"]=>value==="GET"||value==="POST"?value:"other"; + const observeInbound=(surface:"cloud"|"tenant",req:IncomingMessage,res:ServerResponse)=>{ + const value=record({direction:"inbound",surface,path:pathKind((req.url??"").split("?",1)[0]??""),method:methodKind(req.method),hostMatch:req.headers.host===new URL(surface==="cloud"?cloudOrigin:origin).host}); + res.once("finish",()=>{value.status=res.statusCode;}); + }; + const mapped:typeof fetch=async(input,init)=>{ + const original=new Request(input,init),target=new URL(original.url); + const surface=target.origin===cloudOrigin?"cloud":target.origin===origin||target.origin===upstreamOrigin?"tenant":undefined; + if(!surface){unexpectedFetches++;throw new Error("Unexpected off-host principal fixture request");} + const base=surface==="cloud"?cloudLoopback:appLoopback; + const headers=new Headers(original.headers);headers.set("host",new URL(surface==="cloud"?cloudOrigin:origin).host); + const body=original.body?Buffer.from(await original.arrayBuffer()):undefined;original.signal.throwIfAborted(); + const value=record({direction:"outbound",surface,path:pathKind(target.pathname),method:methodKind(original.method)}); + return new Promise((resolve,reject)=>{ + const sent=nodeRequest(new URL(target.pathname+target.search,base),{method:original.method,headers:Object.fromEntries(headers),signal:original.signal},res=>{ + const chunks:Buffer[]=[];let length=0; + res.on("data",(part:Buffer)=>{length+=part.length;if(length>1_048_576){res.destroy(new Error("Canonical mapped response exceeds transport bound"));return;}chunks.push(part);}); + res.on("error",reject);res.on("end",()=>{value.status=res.statusCode;const responseHeaders=new Headers();for(const [name,header] of Object.entries(res.headers))if(header!==undefined)for(const item of Array.isArray(header)?header:[header])responseHeaders.append(name,item); + const status=res.statusCode!;resolve(new Response([204,205,304].includes(status)?null:Buffer.concat(chunks),{status,headers:responseHeaders}));}); + }); + sent.on("error",error=>{const code="code" in error?error.code:undefined;value.error=code==="ECONNRESET"||code==="ECONNREFUSED"||code==="EPIPE"||code==="ABORT_ERR"||code==="ETIMEDOUT"?code:"other";reject(error);});sent.end(body); + }); + }; + beforeAll(async()=>{ + const checkout=process.env.PAPERCLIP_DOT_PRINCIPAL_CLOUD_CHECKOUT;if(!checkout||!path.isAbsolute(checkout))throw new Error("Explicit own compiled current Cloud checkout required"); + const load=(name:string)=>import(pathToFileURL(path.join(checkout,"dist",name)).href); + const [runtime,principal,resolver,memory,provisioner,fake,stores,secretStore,providers,service,routing,identity,assistantStore]=await Promise.all([ + load("runtime/runtime-identity-assertion.js"),load("assistant-mcp/dot-principal.js"),load("assistant-mcp/client-resolver.js"),load("provisioner/memory.js"),load("provisioner/provisioner.js"),load("providers/fake.js"),load("connections/store.js"),load("providers/neon.js"),load("connections/providers.js"),load("service/app.js"),load("routing/router.js"),load("identity/tenant-company.js"),load("assistant-mcp/store.js"), + ]); + makeClient=principal.createDotPrincipalClient;projectTenant=resolver.dotPrincipalTenantFromBinding;CloudStackRouter=routing.CloudStackRouter;metadataCompany=identity.tenantPaperclipCompanyIdForStack; + home=await mkdtemp(path.join(tmpdir(),"dot-principal-hop-"));vi.stubEnv("PAPERCLIP_HOME",home);vi.stubEnv("PAPERCLIP_CONFIG",path.join(home,"runtime-config.json"));vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY",randomBytes(32).toString("base64")); + const control=generateKeyPairSync("ed25519"),sign=generateKeyPairSync("ed25519"),seal=generateKeyPairSync("x25519"),kid="principal-hop-current"; + const publicJwks=JSON.stringify({keys:[{...control.publicKey.export({format:"jwk"}),kid,alg:"EdDSA",use:"sig"}]}); + signer=runtime.createRuntimeIdentitySignerFromEnv({CLOUD_HARNESS_RUNTIME_IDENTITY_SIGNING_JWK:JSON.stringify({...control.privateKey.export({format:"jwk"}),kid,alg:"EdDSA",use:"sig"}),CLOUD_HARNESS_RUNTIME_IDENTITY_JWKS:publicJwks},{required:true}); + registry=new memory.InMemoryCloudHarnessRegistry();const provider=new fake.FakeCloudStackProvider();connectorStore=new stores.InMemoryConnectorStore(); + await registry.createFleetReleaseArtifact({id:"principal-hop-release",appVersion:"principal-hop"});await registry.importFeatureCatalog({releaseArtifactId:"principal-hop-release",catalogVersion:"principal-hop",features:{"cloud.feature-masking":{tier:"managed"}}}); + await new provisioner.CloudStackProvisioner({registry,provider}).provisionDevelopmentStack({idempotencyKey:stackId,accountGroup:{id:"principal-hop-account",slug,displayName:"Principal hop"},owner:{id:userId,email:userId+"@example.test"},stack:{stackId,accountGroupId:"principal-hop-account",stackSlug:slug,displayName:"Principal hop",kind:"demo",primaryHost:new URL(origin).hostname,region:"us-east-1"},versionPlan:{appVersion:"principal-hop",schemaVersion:"fixture",rolloutTrack:"stable"},sleepPolicy:{desiredState:"awake"},primaryCompany:{paperclipCompanyId:otherCompanyId,displayName:"Primary not consent",defaultBudgetCents:0}}); + await registry.updateStackWakeState(stackId,{providerRefs:[{provider:"railway",resourceType:"service_domain:web",externalId:"principal-hop-domain",name:"tenant",metadata:{upstreamOrigin:"https://principal-hop-provider.example.test"}}],lifecycleState:"active",sleepState:"awake"}); + await connectorStore.putCloudStackInstance({id:instanceId,kind:"cloud_stack",companyStackId:stackId,environment:"test",signPublicKey:sign.publicKey.export({format:"jwk"}).x!,sealPublicKey:seal.publicKey.export({format:"jwk"}).x!,origins:[origin],activeOrigin:origin,label:"Principal hop actual enrolled namespace",status:"active",createdAt:new Date(),updatedAt:new Date()}); + vi.stubGlobal("fetch",mapped); + cloudService=service.createCloudHarnessService({registry,provider,baseDomain:"paperclip.app",customerAppOrigin:cloudOrigin,runtimeIdentity:signer,incomingMcpWakeEnabled:true,tenantSessionSigningKey:"principal-hop-tenant-session-".repeat(3),customerSessionSigningKey:"principal-hop-customer-session-".repeat(3),appVersion:"principal-hop",schemaVersion:"fixture",assistantMcp:{store:new assistantStore.InMemoryAssistantMcpStore(),encryptionKey:Buffer.alloc(32,9)},connector:{environment:"test",providers:providers.providerRegistryFromEnvironment({}),fetchImplementation:mapped,secrets:{resolve:async()=>{throw new Error("No external provider");}},store:connectorStore,stackEnrollment:{secrets:new secretStore.InMemoryProviderSecretStore(),secretRefPrefix:"secret://principal-hop"}}}); + cloudServer=createServer((req,res)=>{observeInbound("cloud",req,res);const current=cloudService;if(!current)throw new Error("Missing normal Cloud handler");current.handler(req,res);});cloudLoopback=await listen(cloudServer); + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID",stackId);vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN","principal-hop-tenant-token");vi.stubEnv("PAPERCLIP_CLOUD_WARM_STANDBY","1"); + vi.stubEnv("PAPERCLIP_PUBLIC_URL",origin);vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL",origin);vi.stubEnv("PAPERCLIP_API_URL",origin);vi.stubEnv("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS",publicJwks); + vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID",instanceId);vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT","test");vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_BASE_URL",cloudOrigin);vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY",sign.privateKey.export({format:"jwk"}).d!);vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY",seal.privateKey.export({format:"jwk"}).d!); + await applyPendingMigrations(url!);db=createDb(url!,{maxConnections:2,applicationName:"dot-principal-normal-app"});await migrateCloudConfiguration(db);await initializeAdapterSettings(db);await initializeCloudRuntimeConfig(db);resetCloudRuntimeIdentityForTests();resetCloudControlReplayFenceForTests();await initializeCloudRuntimeIdentity(db); + const standby=await createCloudWarmStandby(db);expect(standby()).toBe(true); + const createOAuth=oauthModule.createPublicMcpOAuth;const spy=vi.spyOn(oauthModule,"createPublicMcpOAuth").mockImplementation((...args)=>{const owner=createOAuth(...args);if(owner.mode==="openai_dot_agent"){const original=owner.authenticate;owner.authenticate=token=>{authenticateCalls++;return original.call(owner,token);};}return owner;});restoreOAuth=()=>spy.mockRestore(); + const {createApp}=await import("../app.js"); + app=await createApp(db,{uiMode:"none",serverPort:3100,storageService:createStorageService(createLocalDiskStorageProvider(path.join(home,"objects"))),deploymentMode:"authenticated",deploymentExposure:"private",bindHost:"127.0.0.1",allowedHostnames:[new URL(origin).hostname],authPublicBaseUrl:origin,authReady:true,companyDeletionEnabled:false,managedPluginAutoInstall:[],cloudWarmStandby:standby,decisionServiceOptions:{wakeOriginAgent:async()=>null},resolveSession:async req=>{sessionCalls++;if(req.get("x-selected-operator")!==userId)return null;const [user]=await db.select().from(authUsers).where(eq(authUsers.id,userId));return user?{session:{id:"principal-hop-session",userId},user}:null;}}); + await app.locals.bundledPluginsStartup;const actualApp=app;appServer=createServer((req,res)=>{observeInbound("tenant",req,res);actualApp(req,res);});appLoopback=await listen(appServer); + },90_000); + afterAll(async()=>{ + const errors:unknown[]=[]; + try{await app?.locals.paperclipShutdown?.();}catch(error){errors.push(error);} + try{await close(appServer);}catch(error){errors.push(error);} + try{cloudService?.stopBackgroundTasks();await close(cloudServer);}catch(error){errors.push(error);} + try{if(db)await db.$client.end({timeout:1});if(url)await closeRegisteredClients(url,{scope:"database"});}catch(error){errors.push(error);} + restoreOAuth?.();setCloudRuntimeConfigSnapshot(null);resetCloudRuntimeIdentityForTests();resetCloudControlReplayFenceForTests();vi.unstubAllEnvs();vi.unstubAllGlobals(); + process.stdout.write(JSON.stringify({scope:"credential-free actual principal fixture transport",records:transportRecords,overflow:transportOverflow})+"\n"); + if(!errors.length&&casePassed&&home)removeTestHome(home); + if(errors.length)throw new AggregateError(errors,"Principal normal App owned cleanup failed"); + },30_000); + it("keeps normal host/runtime/standby/actor order and projects real OAuth through the real Cloud signer/client",async()=>{ + if(!app)throw new Error("Missing normal App");const host=new URL(origin).host,path="/mcp/runner/oauth/cloud-principal",wire={accessToken:"unissued-preclaim-canary",upstreamClientId:"unissued-client",selectingUserId:userId,canonicalVerifiedResource:resource,expectedInstanceId:instanceId}; + const before=sessionCalls; + expect((await request(app).post(path).set("Host","foreign.example.test").send(wire)).status).toBe(403); + expect((await request(app).post(path).set("Host",host).set(CLOUD_RUNTIME_IDENTITY_HEADER,"wrong-endpoint").send(wire)).status).toBe(400); + expect((await request(app).post(path).set("Host",host).send(wire)).status).toBe(503);expect(getCloudRuntimeIdentity()).toBeNull();expect(authenticateCalls).toBe(0);expect(sessionCalls).toBe(before); + const claim=signer.signClaim({stackId,claimId:randomUUID(),previousOrigin:origin,canonicalOrigin:origin,stackSlug:slug}); + const health=await request(app).get("/api/health").set("Host",host).set(CLOUD_RUNTIME_IDENTITY_HEADER,claim);expect(health.status,JSON.stringify(health.body)).toBe(200);expect(getCloudRuntimeIdentity()).toMatchObject({stackId,canonicalOrigin:origin}); + await db.insert(companies).values([{id:companyId,name:"Grant company",issuePrefix:"HPG"},{id:otherCompanyId,name:"Primary not consent",issuePrefix:"HPP"}]);await db.insert(authUsers).values({id:userId,name:"Selected user",email:userId+"@example.test",createdAt:new Date(),updatedAt:new Date()});await accessService(db).ensureMembership(companyId,"user",userId,"owner","active");await instanceSettingsService(db).updateExperimental({enablePublicMcp:true,enableOpenAiDot:true}); + const registration=await request(app).post("/mcp/runner/oauth/register").set("Host",host).send({client_name:"Actual signed principal",redirect_uris:[redirect],grant_types:["authorization_code","refresh_token"],response_types:["code"],token_endpoint_auth_method:"none"});expect(registration.status,JSON.stringify(registration.body)).toBe(201); + const clientId=registration.body.client_id,verifier=randomBytes(48).toString("base64url");const start=await request(app).get("/mcp/runner/oauth/authorize").set("Host",host).query({client_id:clientId,redirect_uri:redirect,response_type:"code",resource,scope:"paperclip:agent offline_access",code_challenge:createHash("sha256").update(verifier).digest("base64url"),code_challenge_method:"S256"});expect(start.status).toBe(303); + const id=new URL(start.headers.location,origin).pathname.split("/").at(-1)!;const consent=await request(app).post(`/api/mcp/requests/${id}/consent`).set("Host",host).set("Origin",origin).set("x-selected-operator",userId).send({decision:"approve",companyId,allowWrites:false});expect(consent.status,JSON.stringify(consent.body)).toBe(200); + const tokens=await request(app).post("/mcp/runner/oauth/token").set("Host",host).send({grant_type:"authorization_code",client_id:clientId,redirect_uri:redirect,resource,code:new URL(consent.body.redirectUrl).searchParams.get("code"),code_verifier:verifier});expect(tokens.status,JSON.stringify(tokens.body)).toBe(200); + const [grant]=await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId,clientId));expect(grant).toMatchObject({companyId,userId,clientId,purpose:"agent",resource,agentId:null,revokedAt:null}); + const stack=await registry.getAssistantMcpStack(stackId);if(!stack)throw new Error("Missing normally provisioned stack");const route=await new CloudStackRouter({registry:{findStackByHost:async(value:string)=>value===stack.primaryHost?stack:undefined,findStackBySlug:async(value:string)=>value===stack.stackSlug?stack:undefined},baseDomain:"paperclip.app"}).resolveRoute({trustedHost:stack.primaryHost});expect(route.kind).toBe("tenant");if(!route.target?.activeAppOrigin)throw new Error("Missing normal verified tenant route");upstreamOrigin=route.target.activeAppOrigin; + await vi.waitFor(async()=>{const receipt=await registry.mcpClientMirrorStore().readCurrent(stackId,origin);expect(receipt).toBeDefined();if(!receipt)throw new Error("No actual published namespace");expect(await registry.mcpClientMirrorStore().getClient({stackId,companyId:metadataCompany(stackId),origin,instanceId},clientId,receipt)).toBeDefined();},{timeout:10_000,interval:50}); + const currentTenant=async(selected:string,targetStack:string)=>{ + if(selected!==userId||targetStack!==stackId||!await registry.getGlobalUser(selected)||!await registry.hasActiveStackMembership(stackId,selected))return undefined; + const freshStack=await registry.getAssistantMcpStack(stackId);if(!freshStack)return undefined; + const freshRoute=await new CloudStackRouter({registry:{findStackByHost:async(value:string)=>value===freshStack.primaryHost?freshStack:undefined,findStackBySlug:async(value:string)=>value===freshStack.stackSlug?freshStack:undefined},baseDomain:"paperclip.app"}).resolveRoute({trustedHost:freshStack.primaryHost}); + if(freshRoute.kind!=="tenant"||freshRoute.target?.stackId!==stackId||!freshRoute.target.activeAppOrigin)return undefined; + const receipt=await registry.mcpClientMirrorStore().readCurrent(stackId,origin);const actual=receipt?await connectorStore.getInstance(receipt.instanceId):undefined; + if(actual?.status!=="active"||actual.kind!=="cloud_stack"||actual.companyStackId!==stackId||actual.activeOrigin!==origin||!actual.origins.includes(origin))return undefined; + return projectTenant({stackId,companyId:metadataCompany(stackId),slug:freshRoute.target.stackSlug,publicOrigin:`https://${freshRoute.target.primaryHost}`,upstreamOrigin:freshRoute.target.activeAppOrigin},{stackId,origin,instanceId:actual.id}); + }; + const tenant=await currentTenant(userId,stackId);expect(tenant?.instanceId).toBe(instanceId);if(!tenant)throw new Error("No actual current tenant namespace"); + const project=makeClient({signControlAssertion:(input:{stackId:string;action:"mcp-dot:principal";requestId:string})=>signer.signControlAssertion(input),refreshTenant:currentTenant,fetchImplementation:mapped}); + const input={tenant,accessToken:tokens.body.access_token,upstreamClientId:clientId,selectingUserId:userId,canonicalVerifiedResource:resource,requestedCompanyId:companyId}; + const beforeAuth=authenticateCalls,beforeSession=sessionCalls;expect(await project(input)).toEqual({userId,companyId,grantId:grant.id,clientId,resource,purpose:"agent"});expect(authenticateCalls).toBe(beforeAuth+1);expect(sessionCalls).toBe(beforeSession); + const refresh=await request(app).post("/mcp/runner/oauth/token").set("Host",host).send({grant_type:"refresh_token",client_id:clientId,resource,refresh_token:tokens.body.refresh_token});expect(refresh.status).toBe(200);expect((await project({...input,accessToken:refresh.body.access_token})).grantId).toBe(grant.id); + await expect(project({...input,upstreamClientId:"foreign-client"})).rejects.toThrow();await expect(project({...input,selectingUserId:"foreign-user"})).rejects.toThrow();await expect(project({...input,requestedCompanyId:otherCompanyId})).rejects.toThrow(); + await connectorStore.setCloudStackOrigins({id:instanceId,companyStackId:stackId,origins:["https://replacement.paperclip.app"],activeOrigin:"https://replacement.paperclip.app",now:new Date()});await expect(project(input)).rejects.toThrow();await connectorStore.setCloudStackOrigins({id:instanceId,companyStackId:stackId,origins:[origin],activeOrigin:origin,now:new Date()}); + const revoked=await request(app).post("/mcp/runner/oauth/revoke").set("Host",host).send({client_id:clientId,resource,token:refresh.body.refresh_token,token_type_hint:"refresh_token"});expect(revoked.status).toBe(200);await expect(project({...input,accessToken:refresh.body.access_token})).rejects.toThrow();expect((await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.id,grant.id)))[0].revokedAt).not.toBeNull();expect(unexpectedFetches).toBe(0);casePassed=true; + },90_000); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 1d6558cbeda..dba13b431fa 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -34,6 +34,7 @@ import { registerBrowserUseCleanup } from "./services/browser-use-work.js"; import { browserUseService } from "./services/browser-use.js"; import { slackToolRoutes } from "./routes/slack-tools.js"; import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js"; +import { createDotPrincipalHttp } from "./modules/external-agents/index.js"; import { createDotRunnerMcpTools, dotRunnerBroker } from "./services/dot-runner-broker.js"; import { agentProfileAvatarRoutes } from "./routes/agent-profile-avatar.js"; import { museTransportRoutes, museBoardRoutes } from "./routes/muse.js"; @@ -520,9 +521,12 @@ export async function createApp( createChatWebhookDiagnostics(), chatWebhookBodyParser, ); + let dotPrincipalOAuth: ReturnType | null = null; + const dotPrincipalHttp = createDotPrincipalHttp(() => dotPrincipalOAuth); + app.use(dotPrincipalHttp.bodyParser); const jsonBodyParser = express.json({ limit: DEFAULT_JSON_BODY_LIMIT, verify: captureRawBody }); // File tickets authenticate before parsing bytes; JSON attachments must remain bytes too. - app.use((req, res, next) => ["/mcp/files/upload", "/mcp/runner/files/upload"].includes(req.path) ? next() : jsonBodyParser(req, res, next)); + app.use((req, res, next) => (dotPrincipalHttp.matchesPost(req) || ["/mcp/files/upload", "/mcp/runner/files/upload"].includes(req.path)) ? next() : jsonBodyParser(req, res, next)); app.use("/api", apiCompression()); app.use(httpLogger); const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({ @@ -545,6 +549,7 @@ export async function createApp( catch { logger.warn("Assistant connections require an HTTPS public URL (HTTP loopback is allowed for development)."); } const publicMcpOAuth = mcpConfig ? createPublicMcpOAuth(db, mcpConfig) : null; const publicMcpIngress = Router(); + publicMcpIngress.use(dotPrincipalHttp.router); app.use(cloudRuntimeIdentityMiddleware(db)); // Routine probes stop before session/actor resolution, even after claim. @@ -1033,6 +1038,7 @@ export async function createApp( publicMcpIngress.use(transfers.router); publicMcpIngress.use(publicMcpIngressRoutes(publicMcpOAuth, createPublicMcpExecutor(db, publicMcpOAuth, dispatch, transfers), publicMcpEvents)); const dotOAuth = createPublicMcpOAuth(db, { ...publicMcpOAuth.config, resource: publicMcpOAuth.config.origin + "/mcp/runner" }); + dotPrincipalOAuth = dotOAuth; dotMcpEvents = createPublicMcpEvents(db, dotOAuth, dispatch, { enableDotRunner: true, isBackgroundWorkEnabled: () => !isWarmStandby() && !isIdleTaskDrainActive() }); const grokTransfers = createPublicMcpTransfers(db, dotOAuth, dispatch, opts.storageService, { pathPrefix: "/mcp/runner/files", agentSession: { authorize: (principal, sessionId, taskId, write, database) => dotRunnerBroker(database ?? db).authorizeFileSession(principal, sessionId, taskId, write) } }); diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index b9d2098ee7f..40f97425a94 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -115,6 +115,24 @@ export function isPrivateWebhookHttpRequest( return /^\/api\/(?:chat-webhooks|voice-webhooks|routine-triggers\/public)(?:\/|$)/i.test(pathname); } +export function isPrivateDotPrincipalHttpRequest(url: string | undefined): boolean { + if (!url) return false; + let pathname = normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""); + for (let depth = 0; depth < 4; depth++) { + if (/^\/mcp\/runner\/oauth\/cloud-principal(?:[/%\\]|$)/i.test(pathname)) { + return true; + } + try { + const decoded = decodeURIComponent(pathname); + if (decoded === pathname) break; + pathname = decoded; + } catch { + break; + } + } + return false; +} + /** * These routes accept or create one-time connector credentials. A provider or * validation error can echo credential material in its message even after the @@ -137,6 +155,7 @@ export function secretSensitiveRouteFamily( if (url && /^\/api\/instance\/ephemeral-enrollment(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return "ephemeral_enrollment"; if (isPrivateWebhookHttpRequest(method, url)) return "provider_webhook"; if (isPrivateMuseHttpRequest(url)) return "muse"; + if (isPrivateDotPrincipalHttpRequest(url)) return "mcp_runner"; if (url && normalizePath(url).startsWith("/mcp/files/")) return "mcp_files"; if (isPrivateAgentCommentaryHttpRequest(url)) return "agent_commentary"; // Custom OTLP header values are arbitrary credentials, including on rejected diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index 21b8981757d..dbbc8f2202b 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -9,6 +9,7 @@ import { isPrivateAgentCommentaryHttpRequest, isPrivateAgentObservabilityHttpRequest, isPrivateMuseHttpRequest, + isPrivateDotPrincipalHttpRequest, shouldSilenceHttpSuccessLog, } from "./http-log-policy.js"; import { @@ -77,6 +78,7 @@ function requestLogUrl(req: { originalUrl?: unknown; url?: unknown; }) { + if(isPrivateDotPrincipalHttpRequest(requestClassificationUrl(req)))return "/mcp/runner/oauth/cloud-principal"; if(isPrivateMuseHttpRequest(requestClassificationUrl(req)))return "/api/muse/:protocol"; if (isPrivateAgentObservabilityHttpRequest(requestClassificationUrl(req))) return "/api/companies/:companyId/observability"; return isPrivateWebhook(req) @@ -97,6 +99,7 @@ export function createHttpLogger(baseLogger: Logger) { if (isPrivateAgentObservabilityHttpRequest(typeof req.url === "string" ? req.url : undefined)) { return { id: req.id, method: req.method, url: "/api/companies/:companyId/observability" }; } + if(isPrivateDotPrincipalHttpRequest(typeof req.url === "string" ? req.url : undefined))return {id:req.id,method:req.method,url:"/mcp/runner/oauth/cloud-principal"}; if(isPrivateMuseHttpRequest(typeof req.url === "string" ? req.url : undefined))return {id:req.id,method:req.method,url:"/api/muse/:protocol"}; if (isPrivateAgentCommentaryHttpRequest(typeof req.url === "string" ? req.url : undefined)) { return { id: req.id, method: req.method, url: stripSecretBearingUrlParts(req.url as string) }; @@ -137,7 +140,7 @@ export function createHttpLogger(baseLogger: Logger) { ) { // A provider error may also be reflected in response headers. Keep the // same content-free contract on both sides of a webhook request. - return res.raw?.req && (isPrivateWebhook(res.raw.req) || isPrivateMuseHttpRequest(requestClassificationUrl(res.raw.req)) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(res.raw.req)) || isPrivateAgentObservabilityHttpRequest(requestClassificationUrl(res.raw.req))) + return res.raw?.req && (isPrivateWebhook(res.raw.req) || isPrivateMuseHttpRequest(requestClassificationUrl(res.raw.req)) || isPrivateDotPrincipalHttpRequest(requestClassificationUrl(res.raw.req)) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(res.raw.req)) || isPrivateAgentObservabilityHttpRequest(requestClassificationUrl(res.raw.req))) ? { statusCode: res.statusCode } : res; }, @@ -174,6 +177,7 @@ export function createHttpLogger(baseLogger: Logger) { if (isPrivateAgentObservabilityHttpRequest(requestClassificationUrl(req))) { return { ...value, err: { type: "Error", message: "Agent observability request failed" } }; } + if(isPrivateDotPrincipalHttpRequest(requestClassificationUrl(req)))return {...value,err:{type:"Error",message:"Dot principal request failed"}}; if (isPrivateMuseHttpRequest(requestClassificationUrl(req)))return {...value,err:{type:"Error",message:"External agent request failed"}}; if (isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(req))) { return { ...value, err: { type: "Error", message: "Feedback request failed" } }; @@ -193,7 +197,7 @@ export function createHttpLogger(baseLogger: Logger) { if (/^\/mcp\/(?:oauth|paperclip)(?:\/|$)/.test(requestClassificationUrl(req) ?? "")) { return { reqBody: "[REDACTED]", ...(ctx ? { errorContext: { name: "Error" } } : {}) }; } - if (isPrivateWebhook(req) || isPrivateMuseHttpRequest(requestClassificationUrl(req)) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(req)) || isPrivateAgentObservabilityHttpRequest(requestClassificationUrl(req))) { + if (isPrivateWebhook(req) || isPrivateMuseHttpRequest(requestClassificationUrl(req)) || isPrivateDotPrincipalHttpRequest(requestClassificationUrl(req)) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(req)) || isPrivateAgentObservabilityHttpRequest(requestClassificationUrl(req))) { // Omit, rather than recursively redact, the entire provider payload. // This applies equally before/after parsing and with/without context. return { diff --git a/server/src/modules/external-agents/cloud-principal-authority.ts b/server/src/modules/external-agents/cloud-principal-authority.ts new file mode 100644 index 00000000000..d4d445ff38e --- /dev/null +++ b/server/src/modules/external-agents/cloud-principal-authority.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { + getCloudRuntimeIdentity, + verifyCloudControlAssertion, +} from "../../services/cloud-runtime-identity.js"; +import { paperclipCloudConnectorConfigFromEnv } from "../../services/paperclip-cloud-connector.js"; + +export const dotPrincipalControlPath = "/mcp/runner/oauth/cloud-principal"; +export const dotPrincipalControlBodySchema = z + .object({ + accessToken: z.string().min(1).max(256), + upstreamClientId: z.string().min(1).max(2048), + selectingUserId: z.string().min(1).max(200), + canonicalVerifiedResource: z.string().url().max(2048), + expectedInstanceId: z.string().min(1).max(200), + requestedCompanyId: z.uuid().optional(), + }) + .strict(); +export type DotPrincipalControlBody = z.infer< + typeof dotPrincipalControlBodySchema +>; +export function dotPrincipalControlDigest(body: DotPrincipalControlBody) { + return createHash("sha256") + .update( + JSON.stringify([ + "paperclip-dot-original-principal-v1", + "mcp-dot:principal", + body.expectedInstanceId, + body.canonicalVerifiedResource, + body.upstreamClientId, + body.selectingUserId, + body.requestedCompanyId ?? null, + body.accessToken, + ]), + ) + .digest("hex"); +} +export function verifyDotPrincipalControl( + compactJws: string, + body: DotPrincipalControlBody, + now: Date, +) { + if (compactJws.length > 8192) + throw new Error("Invalid Dot principal assertion"); + const parts = compactJws.split("."); + if (parts.length !== 3) throw new Error("Invalid Dot principal assertion"); + const untrusted: unknown = JSON.parse( + Buffer.from(parts[1]!, "base64url").toString("utf8"), + ); + if ( + !untrusted || + typeof untrusted !== "object" || + Array.isArray(untrusted) || + !("requestId" in untrusted) || + typeof untrusted.requestId !== "string" || + !/^([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\.[a-f0-9]{64}$/.test( + untrusted.requestId, + ) || + untrusted.requestId.slice(37) !== dotPrincipalControlDigest(body) || + !("iat" in untrusted) || !("exp" in untrusted) || + typeof untrusted.iat !== "number" || typeof untrusted.exp !== "number" || + !Number.isSafeInteger(untrusted.iat) || + !Number.isSafeInteger(untrusted.exp) || + Number(untrusted.exp) <= Number(untrusted.iat) || + Number(untrusted.exp) - Number(untrusted.iat) > 60 + ) + throw new Error("Invalid Dot principal assertion binding"); + return verifyCloudControlAssertion({ + compactJws, + expectedAction: "mcp-dot:principal", + now, + }); +} +export function readDotPrincipalControlAuthority() { + const identity = getCloudRuntimeIdentity(), + connector = paperclipCloudConnectorConfigFromEnv(); + return identity && connector + ? { + stackId: identity.stackId, + claimId: identity.claimId, + canonicalOrigin: identity.canonicalOrigin, + instanceId: connector.instanceId, + environment: connector.environment, + baseUrl: connector.baseUrl, + signPrivateKey: connector.signPrivateKey, + sealPrivateKey: connector.sealPrivateKey, + } + : null; +} +export type DotPrincipalControlAuthority = NonNullable< + ReturnType +>; +export function sameDotPrincipalControlAuthority( + left: DotPrincipalControlAuthority, + right: DotPrincipalControlAuthority | null, +) { + return ( + right !== null && + left.stackId === right.stackId && + left.claimId === right.claimId && + left.canonicalOrigin === right.canonicalOrigin && + left.instanceId === right.instanceId && + left.environment === right.environment && + left.baseUrl === right.baseUrl && + left.signPrivateKey === right.signPrivateKey && + left.sealPrivateKey === right.sealPrivateKey + ); +} diff --git a/server/src/modules/external-agents/cloud-principal-http.test.ts b/server/src/modules/external-agents/cloud-principal-http.test.ts new file mode 100644 index 00000000000..7838fca954e --- /dev/null +++ b/server/src/modules/external-agents/cloud-principal-http.test.ts @@ -0,0 +1,456 @@ +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CLOUD_CONTROL_AUDIENCE, + CLOUD_CONTROL_HEADER, + CLOUD_CONTROL_JWS_TYPE, + CLOUD_RUNTIME_IDENTITY_ISSUER, + resetCloudControlReplayFenceForTests, +} from "../../services/cloud-runtime-identity.js"; +import { + dotPrincipalControlDigest, + dotPrincipalControlPath, + type DotPrincipalControlBody, + type DotPrincipalControlAuthority, +} from "./cloud-principal-authority.js"; +import { + McpOAuthError, + type McpPrincipal, + type PublicMcpOAuth, +} from "../../services/public-mcp/oauth.js"; +import { + dotPrincipalControlBodyParser, + dotPrincipalControlRoutes, + isDotPrincipalControlPost, +} from "./cloud-principal-http.js"; + +const pair = generateKeyPairSync("ed25519"), + foreign = generateKeyPairSync("ed25519"), + kid = "dot-control-unit"; +const origin = "https://unit-dot.example.test", + companyId = "11111111-1111-4111-8111-111111111111", + grantId = "22222222-2222-4222-8222-222222222222"; +const body: DotPrincipalControlBody = { + accessToken: "original-unit-access", + upstreamClientId: "upstream-client", + selectingUserId: "selecting-user", + canonicalVerifiedResource: origin + "/mcp/runner", + expectedInstanceId: "instance", +}; +const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); +function fixture(ownerPresent = true) { + let now = Date.now(), + authority: DotPrincipalControlAuthority | null = { + stackId: "stack", + claimId: "claim", + canonicalOrigin: origin, + instanceId: "instance", + environment: "test", + baseUrl: "https://cloud.example.test", + signPrivateKey: "private-unit-namespace-a", + sealPrivateKey: "private-unit-namespace-b", + }; + const principal: McpPrincipal = { + grant: { + id: grantId, + companyId, + userId: body.selectingUserId, + clientId: body.upstreamClientId, + resource: body.canonicalVerifiedResource, + purpose: "agent", + agentId: null, + scopes: ["paperclip:agent"], + revokedAt: null, + revocationRevision: 0, + revocationIndexReceipt: null, + createdAt: new Date(0), + }, + actor: { type: "none", source: "mcp_oauth", companyId }, + company: { + id: companyId, + name: "Unit", + issuePrefix: "UNIT", + status: "active", + }, + }; + const authenticate = vi.fn(async (_token: string) => principal), + readAuthority = vi.fn(() => authority); + const oauth: Pick = { + mode: "openai_dot_agent", + config: { origin, resource: body.canonicalVerifiedResource }, + authenticate, + }; + let downstreamActorCalls = 0; + const actors: unknown[] = [], + app = express(); + app.use((req, res, next) => { + req.actor = { type: "none", source: "none" }; + res.on("finish", () => actors.push(req.actor.type)); + next(); + }); + app.use(dotPrincipalControlBodyParser()); + const json = express.json(); + app.use((req, res, next) => + isDotPrincipalControlPost(req) ? next() : json(req, res, next), + ); + app.use( + dotPrincipalControlRoutes(() => (ownerPresent ? oauth : null), { + readAuthority, + now: () => new Date(now), + }), + ); + app.use((_req, res) => { + downstreamActorCalls++; + res.status(599).end(); + }); + function assertion( + value = body, + changes: Record = {}, + key = pair.privateKey, + ) { + const header = encode({ alg: "EdDSA", typ: CLOUD_CONTROL_JWS_TYPE, kid }), + iat = Math.floor(now / 1000), + payload = encode({ + v: 1, + iss: CLOUD_RUNTIME_IDENTITY_ISSUER, + aud: CLOUD_CONTROL_AUDIENCE, + sub: "stack", + action: "mcp-dot:principal", + requestId: randomUUID() + "." + dotPrincipalControlDigest(value), + iat, + exp: iat + 60, + ...changes, + }); + return `${header}.${payload}.${sign(null, Buffer.from(header + "." + payload), key).toString("base64url")}`; + } + return { + app, + actors, + authenticate, + readAuthority, + principal, + assertion, + downstream: () => downstreamActorCalls, + change: (value: Partial) => { + authority = { ...authority!, ...value }; + }, + expire: () => { + now += 61_000; + }, + remove: () => { + authority = null; + }, + }; +} +beforeEach(() => { + resetCloudControlReplayFenceForTests(); + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "stack"); + vi.stubEnv( + "PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS", + JSON.stringify({ + keys: [ + { + ...pair.publicKey.export({ format: "jwk" }), + kid, + alg: "EdDSA", + use: "sig", + }, + ], + }), + ); +}); +afterEach(() => { + vi.unstubAllEnvs(); + resetCloudControlReplayFenceForTests(); +}); +describe("Dot private principal HTTP+real crypto UNIT; controlled OAuth/identity owners, no real PG", () => { + it("reserves the actual HTTP path without an OAuth owner and never enters downstream actor work", async () => { + const f = fixture(false); + expect((await request(f.app).get(dotPrincipalControlPath)).status).toBe( + 405, + ); + expect( + (await request(f.app).post(dotPrincipalControlPath).send(body)).status, + ).toBe(401); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set("Content-Type", "text/plain") + .send("private") + ).status, + ).toBe(415); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .set("Content-Type", "application/json") + .send("{") + ).status, + ).toBe(400); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath.toUpperCase() + "/") + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .send(body) + ).status, + ).toBe(503); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .send({ ...body, unknown: true }) + ).status, + ).toBe(400); + expect(f.downstream()).toBe(0); + expect(f.authenticate).not.toHaveBeenCalled(); + expect(f.readAuthority).not.toHaveBeenCalled(); + }); + it("keeps the closed request tuple digest stable", () => { + expect(dotPrincipalControlDigest(body)).toBe( + "2c5172a91664f7df7d35b734cf8f355c51d2dde70eddcf15f4225673af3b88f5", + ); + }); + it("returns minimal unpaired projection once, with unchanged none actor and no bearer echo", async () => { + const f = fixture(), + response = await request(f.app) + .post(dotPrincipalControlPath + "/") + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .send(body); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + version: 1, + stackId: "stack", + instanceId: "instance", + canonicalOrigin: origin, + principal: { + userId: body.selectingUserId, + companyId, + grantId, + clientId: body.upstreamClientId, + resource: body.canonicalVerifiedResource, + purpose: "agent", + }, + }); + expect(JSON.stringify(response.body)).not.toContain(body.accessToken); + expect(f.authenticate).toHaveBeenCalledExactlyOnceWith(body.accessToken); + expect(f.readAuthority).toHaveBeenCalledTimes(2); + expect(f.actors).toEqual(["none"]); + }); + it("altered body cannot burn a valid request, and authentic replay is rejected", async () => { + const f = fixture(), + jws = f.assertion(); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, jws) + .send({ ...body, accessToken: "altered" }) + ).status, + ).toBe(401); + expect(f.authenticate).not.toHaveBeenCalled(); + expect(f.readAuthority).not.toHaveBeenCalled(); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, jws) + .send(body) + ).status, + ).toBe(200); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, jws) + .send(body) + ).status, + ).toBe(401); + expect(f.authenticate).toHaveBeenCalledTimes(1); + }); + it.each([ + { action: "mcp-device:mint" }, + { aud: "other" }, + { sub: "foreign" }, + { exp: 0 }, + ])( + "rejects signed claim mismatch without OAuth or authority reads: %j", + async (changes) => { + const f = fixture(); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion(body, changes)) + .send(body) + ).status, + ).toBe(401); + expect(f.authenticate).not.toHaveBeenCalled(); + expect(f.readAuthority).not.toHaveBeenCalled(); + }, + ); + it("rejects action lifetime over sixty seconds without consuming authority", async () => { + const f = fixture(), + iat = Math.floor(Date.now() / 1000); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion(body, { iat, exp: iat + 120 })) + .send(body) + ).status, + ).toBe(401); + expect(f.authenticate).not.toHaveBeenCalled(); + expect(f.readAuthority).not.toHaveBeenCalled(); + }); + it("retains requested-company restriction and does not grant other HTTP methods", async () => { + const f = fixture(), + restricted = { + ...body, + requestedCompanyId: "33333333-3333-4333-8333-333333333333", + }; + const result = await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion(restricted)) + .send(restricted); + expect(result.status).toBe(400); + expect(result.body).toEqual({ error: "invalid_grant" }); + const methods = fixture(); + expect( + ( + await request(methods.app) + .get(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, methods.assertion()) + ).status, + ).toBe(405); + expect(methods.authenticate).not.toHaveBeenCalled(); + expect(methods.readAuthority).not.toHaveBeenCalled(); + }); + it("rejects absent/foreign signatures before identity/OAuth", async () => { + const f = fixture(); + for (const jws of [ + undefined, + "invalid", + f.assertion(body, {}, foreign.privateKey), + ]) { + let sent = request(f.app).post(dotPrincipalControlPath); + if (jws) sent = sent.set(CLOUD_CONTROL_HEADER, jws); + expect((await sent.send(body)).status).toBe(401); + } + expect(f.authenticate).not.toHaveBeenCalled(); + expect(f.readAuthority).not.toHaveBeenCalled(); + }); + it("enforces raw wire/type/strict-body bounds before owner calls", async () => { + const f = fixture(), + jws = f.assertion(); + for (const value of [ + { ...body, unknown: true }, + "{", + { ...body, accessToken: "x".repeat(17_000) }, + ]) { + const response = await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, jws) + .set("Content-Type", "application/json") + .send(typeof value === "string" ? value : JSON.stringify(value)); + expect([400, 413]).toContain(response.status); + } + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, jws) + .set("Content-Type", "text/plain") + .send("body") + ).status, + ).toBe(415); + expect((await request(f.app).post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, jws).set("Content-Type", "application/json") + .send(Buffer.from([0xff]))).status).toBe(400); + expect((await request(f.app).post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, jws).set("Content-Type", "application/json") + .set("Content-Encoding", "gzip").send(gzipSync(JSON.stringify(body)))).status).toBe(400); + expect(f.authenticate).not.toHaveBeenCalled(); + expect(f.readAuthority).not.toHaveBeenCalled(); + }); + it.each([ + { instanceId: "foreign" }, + { canonicalOrigin: "https://foreign.example.test" }, + ])("rejects current namespace mismatch before OAuth: %j", async (value) => { + const f = fixture(); + f.change(value); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .send(body) + ).status, + ).toBe(503); + expect(f.authenticate).not.toHaveBeenCalled(); + }); + it.each(["clientId", "userId", "companyId"] as const)( + "preserves current OAuth principal %s restriction", + async (field) => { + const f = fixture(); + f.principal.grant[field] = "foreign"; + const response = await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .send(body); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "invalid_grant" }); + expect(f.authenticate).toHaveBeenCalledTimes(1); + }, + ); + it.each(["claimId", "instanceId", "signPrivateKey"] as const)( + "fences %s rotation during actual helper await", + async (field) => { + const f = fixture(); + f.authenticate.mockImplementationOnce(async () => { + f.change({ [field]: "replacement" }); + return f.principal; + }); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .send(body) + ).status, + ).toBe(503); + expect(f.authenticate).toHaveBeenCalledTimes(1); + }, + ); + it("fences expiry after OAuth and preserves original owner rejection safely", async () => { + const f = fixture(); + f.authenticate.mockImplementationOnce(async () => { + f.expire(); + return f.principal; + }); + expect( + ( + await request(f.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, f.assertion()) + .send(body) + ).status, + ).toBe(503); + const other = fixture(); + other.authenticate.mockRejectedValueOnce( + new McpOAuthError("invalid_grant", "private-detail"), + ); + const response = await request(other.app) + .post(dotPrincipalControlPath) + .set(CLOUD_CONTROL_HEADER, other.assertion()) + .send(body); + expect(response.body).toEqual({ error: "invalid_grant" }); + }); +}); diff --git a/server/src/modules/external-agents/cloud-principal-http.ts b/server/src/modules/external-agents/cloud-principal-http.ts new file mode 100644 index 00000000000..5c283be0b66 --- /dev/null +++ b/server/src/modules/external-agents/cloud-principal-http.ts @@ -0,0 +1,135 @@ +import express, { Router, type RequestHandler } from "express"; +import { CLOUD_CONTROL_HEADER } from "../../services/cloud-runtime-identity.js"; +import { + McpOAuthError, + type PublicMcpOAuth, +} from "../../services/public-mcp/oauth.js"; +import { readDotOriginalTokenPrincipal } from "./original-token-principal.js"; +import { + dotPrincipalControlBodySchema, + dotPrincipalControlPath, + readDotPrincipalControlAuthority, + sameDotPrincipalControlAuthority, + verifyDotPrincipalControl, +} from "./cloud-principal-authority.js"; + +const normalized = (path: string) => + (path.endsWith("/") ? path.slice(0, -1) : path).toLowerCase(); +export function isDotPrincipalControlPost(req: { + method: string; + path: string; +}) { + return ( + req.method === "POST" && normalized(req.path) === dotPrincipalControlPath + ); +} +export function dotPrincipalControlBodyParser(): RequestHandler { + const raw = express.raw({ + type: "application/json", + limit: "16kb", + inflate: false, + }); + return (req, res, next) => { + if (!isDotPrincipalControlPost(req)) return next(); + if (!req.is("application/json")) { + res.status(415).json({ error: "invalid_request" }); + return; + } + raw(req, res, (error) => { + if (error) { + res + .status(error && typeof error === "object" && "status" in error && error.status === 413 ? 413 : 400) + .json({ error: "invalid_request" }); + return; + } + next(); + }); + }; +} +type DotPrincipalOAuth = Pick< + PublicMcpOAuth, + "mode" | "config" | "authenticate" +>; +export function dotPrincipalControlRoutes( + oauth: DotPrincipalOAuth | null | (() => DotPrincipalOAuth | null), + options: { + readAuthority?: typeof readDotPrincipalControlAuthority; + now?: () => Date; + } = {}, +) { + const router = Router(), + readAuthority = options.readAuthority ?? readDotPrincipalControlAuthority, + now = options.now ?? (() => new Date()); + router.use(async (req, res, next) => { + if (normalized(req.path) !== dotPrincipalControlPath) return next(); + res.set("Cache-Control", "no-store"); + res.set("Referrer-Policy", "no-referrer"); + if (req.method !== "POST") { + res + .set("Allow", "POST") + .status(405) + .json({ error: "method_not_allowed" }); + return; + } + const compact = req.get(CLOUD_CONTROL_HEADER)?.trim(); + if (!compact) { + res.status(401).json({ error: "invalid_cloud_control_assertion" }); + return; + } + let body: ReturnType; + try { + if (!Buffer.isBuffer(req.body) || req.body.length > 16_384) + throw new Error(); + body = dotPrincipalControlBodySchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(req.body)), + ); + } catch { + res.status(400).json({ error: "invalid_request" }); + return; + } + let claims: ReturnType; + try { + claims = verifyDotPrincipalControl(compact, body, now()); + } catch { + res.status(401).json({ error: "invalid_cloud_control_assertion" }); + return; + } + const owner = typeof oauth === "function" ? oauth() : oauth; + if (!owner) { + res.status(503).json({ error: "temporarily_unavailable" }); + return; + } + try { + const before = readAuthority(); + if ( + !before || + before.stackId !== claims.sub || + before.instanceId !== body.expectedInstanceId || + body.canonicalVerifiedResource !== + before.canonicalOrigin + "/mcp/runner" + ) + throw new Error(); + const principal = await readDotOriginalTokenPrincipal(owner, body); + if ( + !sameDotPrincipalControlAuthority(before, readAuthority()) || + claims.exp * 1000 <= now().getTime() + ) + throw new Error(); + res.json({ + version: 1, + requestId: claims.requestId, + stackId: before.stackId, + instanceId: before.instanceId, + canonicalOrigin: before.canonicalOrigin, + principal, + }); + } catch (error) { + if (error instanceof McpOAuthError) { + res.status(error.status).json({ error: error.code }); + return; + } + res.status(503).json({ error: "temporarily_unavailable" }); + } + }); + return router; +} diff --git a/server/src/modules/external-agents/cloud-principal-log.test.ts b/server/src/modules/external-agents/cloud-principal-log.test.ts new file mode 100644 index 00000000000..92f1d3fed81 --- /dev/null +++ b/server/src/modules/external-agents/cloud-principal-log.test.ts @@ -0,0 +1,200 @@ +import { createServer, request as httpRequest } from "node:http"; +import { Writable } from "node:stream"; +import express from "express"; +import pino from "pino"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; +import { createHttpLogger } from "../../middleware/logger.js"; +import { HTTP_LOG_REDACT_PATHS } from "../../middleware/http-log-redaction.js"; +import { isPrivateDotPrincipalHttpRequest } from "../../middleware/http-log-policy.js"; + +const bearer = "pcmcp_at_private_dot_log_canary"; +const assertion = "private-dot-jws-log-canary"; +const wire = Buffer.from( + JSON.stringify({ accessToken: bearer, unexpected: assertion }), +); + +function hasByteCopy(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + if (Array.isArray(value)) { + return ( + (value.length > 0 && value.every((item) => typeof item === "number")) || + value.some(hasByteCopy) + ); + } + const object = value as Record; + return ( + object.type === "Buffer" || + Object.keys(object).some((key) => /^\d+$/.test(key)) || + Object.values(object).some(hasByteCopy) + ); +} + +const cases = [ + { method: "POST", path: "/mcp/runner/oauth/cloud-principal", status: 200, mode: "plain" }, + { + method: "POST", + path: "/mcp/runner/oauth/cloud-principal", + status: 401, + mode: "plain", + }, + { + method: "POST", + path: "/mcp/runner/oauth/cloud-principal/", + status: 400, + mode: "context", + }, + { + method: "GET", + path: "/MCP/RUNNER/OAUTH/CLOUD-PRINCIPAL", + status: 503, + mode: "sdk", + }, + { + method: "DELETE", + path: + "http://upstream.invalid/mcp/runner/oauth/cloud-principal?token=" + + bearer, + status: 401, + mode: "plain", + }, + { + method: "PUT", + path: "/mcp/runner/oauth/cloud-principal%XX", + status: 400, + mode: "sdk", + }, + { + method: "PATCH", + path: "/mcp/runner/oauth/%63loud-principal/", + status: 503, + mode: "context", + }, +] as const; + +describe("Dot private principal actual HTTP logger boundary", () => { + it.each(cases)( + "keeps $method $path $mode status$status content-free", + async (entry) => { + expect(isPrivateDotPrincipalHttpRequest(entry.path)).toBe(true); + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const app = express(); + app.use(express.raw({ type: "*/*" })); + app.use( + createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)), + ); + app.use((req, res) => { + const sdkError = Object.assign(new Error(`${bearer} ${assertion}`), { + name: assertion, + request: { body: req.body, headers: { assertion } }, + response: { data: bearer }, + }); + if (entry.mode === "context") { + Object.assign(res, { + __errorContext: { + error: sdkError, + reqBody: req.body, + reqParams: { private: bearer }, + }, + }); + } + if (entry.mode !== "plain") Object.assign(res, { err: sdkError }); + res.setHeader("x-sdk-private", assertion); + res.status(entry.status).end(); + }); + const server = createServer(app); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + try { + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Missing owned logger listener"); + await new Promise((resolve, reject) => { + const client = httpRequest( + { + hostname: "127.0.0.1", + port: address.port, + method: entry.method, + path: entry.path, + headers: { + "content-type": "application/json", + "content-length": String(wire.length), + "x-paperclip-cloud-control": assertion, + "x-sdk-private": bearer, + }, + }, + (response) => { + expect(response.statusCode).toBe(entry.status); + response.resume(); + response.on("end", resolve); + response.on("error", reject); + }, + ); + client.on("error", reject); + client.end(wire); + }); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + const output = chunks.join(""); + expect(output).not.toContain(bearer); + expect(output).not.toContain(assertion); + const logs = output + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + expect(logs).toHaveLength(1); + const log = logs[0]; + if (entry.status >= 400) expect(log.reqBody).toBe("[REDACTED]"); + else expect(log.reqBody).toBeUndefined(); + expect(log.req.url).toBe("/mcp/runner/oauth/cloud-principal"); + expect(log.req.body).toBeUndefined(); + expect(log.req.headers).toBeUndefined(); + expect(log.req.params).toBeUndefined(); + expect(log.req.query).toBeUndefined(); + expect(log.res.headers).toBeUndefined(); + expect(hasByteCopy(log)).toBe(false); + if (entry.mode !== "plain") { + expect(log.err.message).toBe("Dot principal request failed"); + expect(log.err.request).toBeUndefined(); + } + }, + ); + + it.each(["/mcp/oauth/token", "/mcp/paperclip"])( + "preserves existing MCP raw failure redaction at %s", + async (path) => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const app = express(); + app.use(express.raw({ type: "*/*" })); + app.use( + createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)), + ); + app.use((_req, res) => res.status(400).end()); + await request(app) + .post(path) + .set("Content-Type", "application/json") + .send(wire); + const output = chunks.join(""); + expect(output).not.toContain(bearer); + expect(JSON.parse(output.trim()).reqBody).toBe("[REDACTED]"); + expect(hasByteCopy(JSON.parse(output.trim()))).toBe(false); + }, + ); +}); diff --git a/server/src/modules/external-agents/index.ts b/server/src/modules/external-agents/index.ts index d8669f2115d..97c76af8105 100644 --- a/server/src/modules/external-agents/index.ts +++ b/server/src/modules/external-agents/index.ts @@ -1,3 +1,6 @@ +import type { RequestHandler, Router } from "express"; +import { dotPrincipalControlBodyParser, dotPrincipalControlRoutes, isDotPrincipalControlPost } from "./cloud-principal-http.js"; + export { museExternalAgents } from "./muse.js"; export { withExternalAdmissionGuard, withExternalExecutionAdmission, assertNoExternalOverlap, type ExternalAdmissionTransaction } from "./admission.js"; @@ -8,3 +11,11 @@ export { notifyExternalAgentSettlement, pendingMuseTerminalBindings, type Extern export { idleExternalAgentHistoryChecks } from "./idle-history-proof.js"; export { idleExternalAgentIncomingWorkQuery } from "./idle-incoming-proof.js"; + +export function createDotPrincipalHttp(oauth: Parameters[0]): { + bodyParser: RequestHandler; + matchesPost: (request: { method: string; path: string }) => boolean; + router: Router; +} { + return { bodyParser: dotPrincipalControlBodyParser(), matchesPost: isDotPrincipalControlPost, router: dotPrincipalControlRoutes(oauth) }; +} diff --git a/server/src/modules/external-agents/original-token-principal.postgres.test.ts b/server/src/modules/external-agents/original-token-principal.postgres.test.ts new file mode 100644 index 00000000000..5f94bbe2764 --- /dev/null +++ b/server/src/modules/external-agents/original-token-principal.postgres.test.ts @@ -0,0 +1,78 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { applyPendingMigrations, createDb, closeRegisteredClients, companies, authUsers, mcpOauthGrants } from "@paperclipai/db"; +import { resolveExternalTestDatabaseUrl } from "../../lib/dedicated-test-database-url.js"; +import { accessService } from "../../services/access.js"; +import { instanceSettingsService } from "../../services/instance-settings.js"; +import { createMcpApiDispatch, createPublicMcpExecutor } from "../../services/public-mcp/capabilities.js"; +import { createPublicMcpOAuth } from "../../services/public-mcp/oauth.js"; +import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../../routes/public-mcp.js"; +import { errorHandler } from "../../middleware/error-handler.js"; +import { readDotOriginalTokenPrincipal } from "./original-token-principal.js"; + +const url=resolveExternalTestDatabaseUrl("PAPERCLIP_DOT_PRINCIPAL_LEAF_TEST_DATABASE_URL"); +const origin="https://dot-principal-leaf.example.test",resource=origin+"/mcp/runner"; +describe.skipIf(!url)("actual OAuth producer and original-token principal leaf; no Cloud transport",()=>{ + let db:ReturnType,app:express.Express,oauth:ReturnType; + let companyId:string,otherCompanyId:string,userId:string,number=0,calls=0; + beforeAll(async()=>{vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY",randomBytes(32).toString("base64"));await applyPendingMigrations(url!);},90_000); + afterAll(()=>{vi.unstubAllEnvs();}); + beforeEach(async()=>{ + db=createDb(url!,{maxConnections:2,applicationName:"dot-principal-current-leaf"}); + companyId=randomUUID();otherCompanyId=randomUUID();userId=randomUUID();number++;calls=0; + await db.insert(companies).values([{id:companyId,name:"Dot principal leaf",issuePrefix:`DPL${number}`},{id:otherCompanyId,name:"Other principal leaf",issuePrefix:`DPO${number}`}]); + await db.insert(authUsers).values({id:userId,name:"Selected user",email:userId+"@example.test",createdAt:new Date(),updatedAt:new Date()}); + await accessService(db).ensureMembership(companyId,"user",userId,"owner","active"); + await instanceSettingsService(db).updateExperimental({enablePublicMcp:true,enableOpenAiDot:true}); + oauth=createPublicMcpOAuth(db,{origin,resource}); + app=express();app.use(express.json()); + app.use((req,_res,next)=>{req.actor={type:"none",source:"none"};next();}); + const personal=createPublicMcpOAuth(db,{origin,resource:origin+"/mcp/paperclip"}),dispatch=createMcpApiDispatch(express.Router()); + app.use(publicMcpIngressRoutes(oauth,createPublicMcpExecutor(db,oauth,dispatch))); + app.use((req,_res,next)=>{if(req.get("x-selected-operator")===userId)req.actor={type:"board",source:"session",userId,companyIds:[companyId],memberships:[{companyId,membershipRole:"owner",status:"active"}]};next();}); + app.use("/api",publicMcpManagementRoutes(personal,oauth));app.use(errorHandler); + }); + afterEach(async()=>{if(db)await db.$client.end({timeout:1});if(url)await closeRegisteredClients(url,{scope:"database"});}); + async function issue(){ + const redirect="https://chatgpt.com/connector_platform/oauth/callback"; + const registration=await request(app).post("/mcp/runner/oauth/register").send({client_name:"Actual principal leaf",redirect_uris:[redirect],grant_types:["authorization_code","refresh_token"],response_types:["code"],token_endpoint_auth_method:"none"}); + expect(registration.status,JSON.stringify(registration.body)).toBe(201); + const clientId=registration.body.client_id,verifier=randomBytes(48).toString("base64url"); + const start=await request(app).get("/mcp/runner/oauth/authorize").query({client_id:clientId,redirect_uri:redirect,response_type:"code",resource,scope:"paperclip:agent offline_access",code_challenge:createHash("sha256").update(verifier).digest("base64url"),code_challenge_method:"S256"}); + expect(start.status,JSON.stringify(start.body)).toBe(303); + const id=new URL(start.headers.location,origin).pathname.split("/").at(-1)!; + const consent=await request(app).post(`/api/mcp/requests/${id}/consent`).set("Origin",origin).set("x-selected-operator",userId).send({decision:"approve",companyId,allowWrites:false}); + expect(consent.status,JSON.stringify(consent.body)).toBe(200); + const tokens=await request(app).post("/mcp/runner/oauth/token").send({grant_type:"authorization_code",client_id:clientId,redirect_uri:redirect,resource,code:new URL(consent.body.redirectUrl).searchParams.get("code"),code_verifier:verifier}); + expect(tokens.status,JSON.stringify(tokens.body)).toBe(200); + const [grant]=await db.select().from(mcpOauthGrants).where(and(eq(mcpOauthGrants.companyId,companyId),eq(mcpOauthGrants.userId,userId),eq(mcpOauthGrants.clientId,clientId))); + expect(grant).toMatchObject({companyId,userId,clientId,purpose:"agent",resource,agentId:null,revokedAt:null});expect(grant.scopes).toContain("paperclip:agent"); + return{clientId,token:tokens.body.access_token,refresh:tokens.body.refresh_token,grant}; + } + function project(input:Parameters[1]){ + return readDotOriginalTokenPrincipal({mode:oauth.mode,config:oauth.config,authenticate:token=>{calls++;return oauth.authenticate(token);}},input); + } + it("projects a real unpaired grant once, refreshes it and rejects normal revocation",async()=>{ + const issued=await issue(),input={accessToken:issued.token,upstreamClientId:issued.clientId,selectingUserId:userId,canonicalVerifiedResource:resource,requestedCompanyId:companyId}; + expect(await project(input)).toEqual({userId,companyId,grantId:issued.grant.id,clientId:issued.clientId,resource,purpose:"agent"});expect(calls).toBe(1); + const refresh=await request(app).post("/mcp/runner/oauth/token").send({grant_type:"refresh_token",client_id:issued.clientId,resource,refresh_token:issued.refresh});expect(refresh.status,JSON.stringify(refresh.body)).toBe(200); + expect((await project({...input,accessToken:refresh.body.access_token})).grantId).toBe(issued.grant.id);expect(calls).toBe(2); + const revoked=await request(app).post("/mcp/runner/oauth/revoke").send({client_id:issued.clientId,resource,token:refresh.body.refresh_token,token_type_hint:"refresh_token"});expect(revoked.status,JSON.stringify(revoked.body)).toBe(200); + await expect(project({...input,accessToken:refresh.body.access_token})).rejects.toThrow();expect(calls).toBe(3); + expect((await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.id,issued.grant.id)))[0].revokedAt).not.toBeNull(); + },30_000); + it("retains client, selecting-user, company, current membership and feature authority",async()=>{ + const issued=await issue(),input={accessToken:issued.token,upstreamClientId:issued.clientId,selectingUserId:userId,canonicalVerifiedResource:resource}; + for(const change of [{upstreamClientId:"foreign-client"},{selectingUserId:"foreign-user"},{requestedCompanyId:otherCompanyId}]){ + const before=calls;await expect(project({...input,...change})).rejects.toMatchObject({code:"invalid_grant"});expect(calls).toBe(before+1); + } + const before=calls;await expect(project({...input,canonicalVerifiedResource:origin+"/mcp/paperclip"})).rejects.toThrow();expect(calls).toBe(before); + await accessService(db).ensureMembership(companyId,"user",userId,"owner","suspended");await expect(project(input)).rejects.toThrow(); + await accessService(db).ensureMembership(companyId,"user",userId,"owner","active");expect((await project(input)).grantId).toBe(issued.grant.id); + await instanceSettingsService(db).updateExperimental({enableOpenAiDot:false});await expect(project(input)).rejects.toThrow(); + await instanceSettingsService(db).updateExperimental({enableOpenAiDot:true});expect((await project(input)).grantId).toBe(issued.grant.id); + },30_000); +}); diff --git a/server/src/modules/external-agents/original-token-principal.test.ts b/server/src/modules/external-agents/original-token-principal.test.ts new file mode 100644 index 00000000000..7469ba7a966 --- /dev/null +++ b/server/src/modules/external-agents/original-token-principal.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { McpOAuthError, type McpPrincipal, type PublicMcpOAuth } from "../../services/public-mcp/oauth.js"; +import { readDotOriginalTokenPrincipal } from "./original-token-principal.js"; + +const origin="https://dot-principal.example.test",resource=origin+"/mcp/runner",token="original-access-boundary-input"; +const expected={accessToken:token,upstreamClientId:"actual-upstream-client",selectingUserId:"actual-user",canonicalVerifiedResource:resource}; +function boundary(){ + const principal:McpPrincipal={grant:{id:"actual-grant",companyId:"actual-company",userId:"actual-user",clientId:"actual-upstream-client",resource,purpose:"agent",agentId:null,scopes:["paperclip:agent","offline_access"],revokedAt:null,revocationRevision:0,revocationIndexReceipt:null,createdAt:new Date(0)},actor:{type:"none",source:"mcp_oauth",companyId:"actual-company"},company:{id:"actual-company",name:"Actual authenticated company",issuePrefix:"DOT",status:"active"}}; + const authenticate=vi.fn(async(_token:string)=>principal); + const oauth:Pick={mode:"openai_dot_agent",config:{origin,resource},authenticate}; + return{principal,oauth,authenticate}; +} +describe("Dot original-token principal UNIT boundary; controlled authenticate, no real OAuth or PG",()=>{ + it("authenticates original access once and projects actual unpaired identity without execution or binding authority",async()=>{ + const f=boundary();expect(await readDotOriginalTokenPrincipal(f.oauth,expected)).toEqual({userId:"actual-user",companyId:"actual-company",grantId:"actual-grant",clientId:"actual-upstream-client",resource,purpose:"agent"}); + expect(f.authenticate).toHaveBeenCalledExactlyOnceWith(token);expect(f.principal.actor.type).toBe("none");expect(f.principal.grant.agentId).toBeNull(); + }); + it("requested company is optional restriction against actual grant company, not a replacement",async()=>{ + const f=boundary();await expect(readDotOriginalTokenPrincipal(f.oauth,{...expected,requestedCompanyId:"actual-company"})).resolves.toMatchObject({companyId:"actual-company"});expect(f.authenticate).toHaveBeenCalledExactlyOnceWith(token); + const mismatch=boundary();await expect(readDotOriginalTokenPrincipal(mismatch.oauth,{...expected,requestedCompanyId:"stack-primary-company"})).rejects.toMatchObject({code:"invalid_grant"});expect(mismatch.authenticate).toHaveBeenCalledExactlyOnceWith(token); + }); + it.each(["personal","muse_agent"] as const)("rejects %s owner mode before credential authentication",async mode=>{ + const f=boundary();await expect(readDotOriginalTokenPrincipal({...f.oauth,mode},expected)).rejects.toMatchObject({code:"invalid_grant"});expect(f.authenticate).not.toHaveBeenCalled(); + }); + it.each(["https://foreign.example.test/mcp/runner",origin+"/mcp/paperclip",resource+"?resource=other","not-an-origin"])("rejects nonmatching current canonical resource %s",async canonicalVerifiedResource=>{ + const f=boundary();await expect(readDotOriginalTokenPrincipal(f.oauth,{...expected,canonicalVerifiedResource})).rejects.toMatchObject({code:"invalid_grant"});expect(f.authenticate).not.toHaveBeenCalled(); + }); + it.each(["origin","resource"] as const)("rejects configured %s mismatch before authenticating",async field=>{ + const f=boundary();await expect(readDotOriginalTokenPrincipal({...f.oauth,config:{...f.oauth.config,[field]:"https://foreign.example.test"}},expected)).rejects.toMatchObject({code:"invalid_grant"});expect(f.authenticate).not.toHaveBeenCalled(); + }); + it.each(["clientId","userId","companyId","resource","purpose","scope"] as const)("rejects authenticated %s mismatch after exactly one owner call",async field=>{ + const f=boundary();if(field==="purpose")f.principal.grant.purpose="personal";else if(field==="scope")f.principal.grant.scopes=["paperclip:read"];else f.principal.grant[field]="foreign"; + await expect(readDotOriginalTokenPrincipal(f.oauth,expected)).rejects.toMatchObject({code:"invalid_grant"});expect(f.authenticate).toHaveBeenCalledExactlyOnceWith(token); + }); + it.each(["feature-disabled","expired-access","revoked-grant","removed-membership","operator-denied"])("propagates actual owner rejection unchanged: %s (controlled unit, not real lifecycle proof)",async reason=>{ + const f=boundary(),failure=new McpOAuthError("invalid_grant",reason);f.authenticate.mockRejectedValueOnce(failure);await expect(readDotOriginalTokenPrincipal(f.oauth,expected)).rejects.toBe(failure);expect(f.authenticate).toHaveBeenCalledExactlyOnceWith(token); + }); +}); diff --git a/server/src/modules/external-agents/original-token-principal.ts b/server/src/modules/external-agents/original-token-principal.ts new file mode 100644 index 00000000000..f152226113b --- /dev/null +++ b/server/src/modules/external-agents/original-token-principal.ts @@ -0,0 +1,32 @@ +import { McpOAuthError, type PublicMcpOAuth } from "../../services/public-mcp/oauth.js"; + +type DotOAuthPrincipalOwner = Pick; +export interface DotOriginalTokenPrincipal { + userId: string; + companyId: string; + grantId: string; + clientId: string; + resource: string; + purpose: "agent"; +} +export async function readDotOriginalTokenPrincipal(oauth: DotOAuthPrincipalOwner, input: { + accessToken: string; + upstreamClientId: string; + selectingUserId: string; + canonicalVerifiedResource: string; + requestedCompanyId?: string; +}): Promise { + const deny = () => new McpOAuthError("invalid_grant", "Dot connection authority does not match the selected request."); + let origin: string; + try { origin = new URL(input.canonicalVerifiedResource).origin; } catch { throw deny(); } + if (oauth.mode !== "openai_dot_agent" || input.canonicalVerifiedResource !== origin + "/mcp/runner" + || oauth.config.origin !== origin || oauth.config.resource !== input.canonicalVerifiedResource) throw deny(); + const { grant, company } = await oauth.authenticate(input.accessToken); + if (grant.purpose !== "agent" || !grant.scopes.includes("paperclip:agent") + || !grant.id || !grant.companyId || company.id !== grant.companyId + || !input.upstreamClientId || grant.clientId !== input.upstreamClientId + || !input.selectingUserId || grant.userId !== input.selectingUserId + || grant.resource !== input.canonicalVerifiedResource + || input.requestedCompanyId !== undefined && grant.companyId !== input.requestedCompanyId) throw deny(); + return { userId: grant.userId, companyId: grant.companyId, grantId: grant.id, clientId: grant.clientId, resource: grant.resource, purpose: "agent" }; +} diff --git a/server/src/services/cloud-runtime-identity.ts b/server/src/services/cloud-runtime-identity.ts index af476ee0168..b1f37dbea87 100644 --- a/server/src/services/cloud-runtime-identity.ts +++ b/server/src/services/cloud-runtime-identity.ts @@ -455,6 +455,7 @@ export const CLOUD_CONTROL_ACTIONS = [ "lifecycle:unarchive-primary", "connector-events:wake", "mcp-device:mint", + "mcp-dot:principal", ] as const; export type CloudControlAction = (typeof CLOUD_CONTROL_ACTIONS)[number];