mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Connect personal Muse through secure native execution and Vault OAuth (#16448)
Add company-scoped Muse native control, OAuth, wake receiver and recovery. Recheck current authority after waits and before effects or receipt replay. Preserve task-read scope, assignment expiry, independent holds, nonblocking reassignment and truthful cleanup provenance. Retain source trust on progress comments and verify current Muse V8 authority through real signed-JWT HTTP requests. Preserve the upstream trimmed ordinary authentication lookup. Refs #16448. Preserve captured canonical queue recovery and concurrent environment preparation while retaining the scoped Muse V8 exclusion. Normal migration 0354 and existing Muse SQL remain unchanged. Fresh current-head Greptile review is 5/5 with the project-write finding addressed. Required CI and independent technical review gate the merge; maintainer override is reserved for formal approval rules. Recheck current Muse project-write authority after all receipt/task/run/admission waits, before creation or replay. Real PostgreSQL regressions reproduce four late effects/replays on the old route; the repaired three-file treatment passes 354 cases with no skips, and the scoped server typecheck passes. Ordinary/Dot behavior is preserved. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
63fbb714e6
commit
e6dcc6b095
72 files changed
+9775
-1021
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { Request, RequestHandler } from "express";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -429,6 +429,29 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
identityRun.activeIdentityContextId = captured.context?.id ?? null;
|
||||
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
|
||||
}
|
||||
if (claims.authorizing_user_id) {
|
||||
// This signed projection exists only for internal native tools. Recheck
|
||||
// the admitted run and current immutable binding on every API call.
|
||||
try {
|
||||
const { parseNativeExecutionInput } = await import("../vendor/paperclip-runner/index.js");
|
||||
const { museRunnerBroker } = await import("../services/muse-runner-broker.js");
|
||||
const [nativeRun] = await db.select({ status: heartbeatRuns.status, runtimeMode: heartbeatRuns.runtimeMode,
|
||||
nativeExecutionInput: sql<unknown>`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'`,
|
||||
}).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
|
||||
));
|
||||
const execution = parseNativeExecutionInput(nativeRun?.nativeExecutionInput);
|
||||
if (nativeRun?.status !== "running" || nativeRun.runtimeMode !== "native" || execution.schema !== "paperclip.native-execution-input.v8"
|
||||
|| execution.binding.runId !== claims.run_id || execution.binding.agentId !== claims.sub || execution.binding.companyId !== claims.company_id
|
||||
|| await museRunnerBroker(db).authorizingUserIdForBinding(execution.provider.binding) !== claims.authorizing_user_id) {
|
||||
throw new Error("Muse internal tool authority mismatch");
|
||||
}
|
||||
await museRunnerBroker(db).assertRunAuthority(execution);
|
||||
} catch {
|
||||
_res.status(403).json({ error: "Muse native tool authority is unavailable" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const onBehalfOfUserId = identityRun?.activeIdentityContextId
|
||||
? identityRun.responsibleUserId
|
||||
: claims.responsible_user_id !== undefined
|
||||
@@ -451,6 +474,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
|
||||
runId: claims.run_id,
|
||||
onBehalfOfUserId,
|
||||
...(claims.authorizing_user_id ? { authorizingUserId: claims.authorizing_user_id } : {}),
|
||||
identityContextId: identityRun?.activeIdentityContextId ?? null,
|
||||
onBehalfOfMemberships,
|
||||
source: "agent_jwt",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const SILENCED_SUCCESS_METHODS = new Set(["GET", "HEAD"]);
|
||||
|
||||
const SILENCED_SUCCESS_API_PATHS = [
|
||||
/^\/api\/muse\/v1\/signal$/i,
|
||||
/^\/api\/health(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/activity(?:\/|$)/,
|
||||
/^\/api\/companies\/[^/]+\/dashboard(?:\/|$)/,
|
||||
@@ -53,6 +54,17 @@ export function isPrivateAgentCommentaryHttpRequest(url: string | undefined): bo
|
||||
return /^\/api\/companies\/[^/]+\/agent-commentary(?:\/|$)/i.test(pathname);
|
||||
}
|
||||
|
||||
/** Muse OAuth and worker payloads stay private even on malformed/rejected paths. */
|
||||
export function isPrivateMuseHttpRequest(url:string|undefined):boolean {
|
||||
if(!url)return false;
|
||||
let pathname=normalizePath(url).replace(/^https?:\/\/[^/]*/i, "");
|
||||
for(let depth=0;depth<4;depth++) {
|
||||
if(/^\/api\/muse(?:\/|$)/i.test(pathname) || /^\/api\/companies\/[^/]+\/(?:muse-invitations|agents\/[^/]+\/muse-binding)(?:\/|$)/i.test(pathname))return true;
|
||||
try {const decoded=decodeURIComponent(pathname);if(decoded===pathname)break;pathname=decoded;} catch {break;}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Provider payloads are private even when a method/signature is rejected. */
|
||||
export function isPrivateWebhookHttpRequest(
|
||||
method: string | undefined,
|
||||
@@ -87,7 +99,7 @@ export function isSecretSensitiveHttpRequest(
|
||||
method: string | undefined,
|
||||
url: string | undefined,
|
||||
): boolean {
|
||||
if (isPrivateWebhookHttpRequest(method, url)) return true;
|
||||
if (isPrivateWebhookHttpRequest(method, url) || isPrivateMuseHttpRequest(url)) return true;
|
||||
if (url && normalizePath(url).startsWith("/mcp/files/")) return true;
|
||||
if (isPrivateAgentCommentaryHttpRequest(url)) return true;
|
||||
// Avatar uploads and dedicated Dot MCP calls may contain private image bytes.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isPrivateWebhookHttpRequest,
|
||||
isSecretSensitiveHttpRequest,
|
||||
isPrivateAgentCommentaryHttpRequest,
|
||||
isPrivateMuseHttpRequest,
|
||||
shouldSilenceHttpSuccessLog,
|
||||
} from "./http-log-policy.js";
|
||||
import {
|
||||
@@ -74,6 +75,7 @@ function requestLogUrl(req: {
|
||||
originalUrl?: unknown;
|
||||
url?: unknown;
|
||||
}) {
|
||||
if(isPrivateMuseHttpRequest(requestClassificationUrl(req)))return "/api/muse/:protocol";
|
||||
return isPrivateWebhook(req)
|
||||
? privateWebhookLogUrl(requestClassificationUrl(req))
|
||||
: stripSecretBearingUrlParts(typeof req.url === "string" ? req.url : "");
|
||||
@@ -84,6 +86,7 @@ export function createHttpLogger(baseLogger: Logger) {
|
||||
logger: baseLogger,
|
||||
serializers: {
|
||||
req(req: Record<string, unknown> & { url?: unknown }) {
|
||||
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) };
|
||||
}
|
||||
@@ -123,7 +126,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) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(res.raw.req)))
|
||||
return res.raw?.req && (isPrivateWebhook(res.raw.req) || isPrivateMuseHttpRequest(requestClassificationUrl(res.raw.req)) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(res.raw.req)))
|
||||
? { statusCode: res.statusCode }
|
||||
: res;
|
||||
},
|
||||
@@ -154,6 +157,7 @@ export function createHttpLogger(baseLogger: Logger) {
|
||||
return `${req.method} ${stripSecretBearingUrlParts(req.url ?? "")} ${res.statusCode} — ${errMsg}`;
|
||||
},
|
||||
customErrorObject(req, _res, _err, value) {
|
||||
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" } };
|
||||
}
|
||||
@@ -172,7 +176,7 @@ export function createHttpLogger(baseLogger: Logger) {
|
||||
if (/^\/mcp\/(?:oauth|paperclip)(?:\/|$)/.test(requestClassificationUrl(req) ?? "")) {
|
||||
return { reqBody: "[REDACTED]", ...(ctx ? { errorContext: { name: "Error" } } : {}) };
|
||||
}
|
||||
if (isPrivateWebhook(req) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(req))) {
|
||||
if (isPrivateWebhook(req) || isPrivateMuseHttpRequest(requestClassificationUrl(req)) || isPrivateAgentCommentaryHttpRequest(requestClassificationUrl(req))) {
|
||||
// Omit, rather than recursively redact, the entire provider payload.
|
||||
// This applies equally before/after parsing and with/without context.
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user