mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 01:25:26 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps give those agents governed access to external tools. > - Remote MCP setup needs secure endpoint validation and durable credentials. > - PostHog needs both browser sign-in and personal API key setup paths. > - This pull request adds the shared remote MCP foundation and the PostHog definition. > - The benefit is a secure and reusable base for later app connection work. ## Linked Issues or Issue Description Refs #11965 This is stack 1 of 11. It replaces the first reviewable part of #11965. ## What Changed - Add guarded remote MCP setup and credential handling. - Add PostHog OAuth and API key connection methods. - Add focused server, shared contract, and UI coverage. - Keep the migration replay-safe and idempotent. - Give the late-close security regression the same 10-second CI headroom as the adjacent real-timer handshake test. - Synchronize fake-timer handshake tests at the exact ensure-session boundary so real filesystem setup cannot race the fake deadline. - Drive PTY overflow coverage only after listener registration so scheduling cannot reorder the test fixture. ## Verification - pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts server/src/__tests__/plugin-worker-manager.test.ts (220 passed; affected cases also passed five focused stress repetitions) - `pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never leaks a sandbox-provided value from a late close rejection into logs or the result"` (1 passed) - `pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never promotes a late ensureSession resolution|closes a late-resolving real handle exactly once"` (2 passed) - `pnpm -r typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` - `pnpm --filter @paperclipai/db check:migrations` - `pnpm build` ## Risks - Remote endpoint validation can reject configurations that previously passed without checks. - OAuth configuration errors can block setup until the operator corrects the provider settings. - The migration uses guarded statements so repeated execution is safe. - The test-only synchronization changes do not affect runtime behavior; they remove filesystem/fake-clock and listener-registration races observed under parallel CI load. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
174 lines
6.8 KiB
TypeScript
174 lines
6.8 KiB
TypeScript
/**
|
|
* Scheme/transport safety for OAuth endpoints Paperclip learned from a remote
|
|
* server (PAP-17099).
|
|
*
|
|
* A generic remote MCP connection can point at any endpoint, and that endpoint
|
|
* gets to advertise its own authorization-server metadata. The
|
|
* `authorization_endpoint` it names is not just fetched server-side — Paperclip
|
|
* hands it to the operator's browser as a top-level navigation, so an arbitrary
|
|
* string there is a code-execution and open-redirect primitive:
|
|
* `javascript:` / `data:` would run in the board's origin, and plain `http:`
|
|
* would carry the authorization request (and the operator) over a network any
|
|
* intermediary can rewrite.
|
|
*
|
|
* So every endpoint URL discovered from metadata, pasted by an operator, taken
|
|
* from a `WWW-Authenticate` hint, or shipped as a gallery default is parsed
|
|
* here — once, in shared code — and must be:
|
|
*
|
|
* - a well-formed absolute URL (anything `new URL` rejects is rejected);
|
|
* - `https:`, or `http:` only for a loopback host under the caller's explicit
|
|
* local-development policy (the server passes its private-network policy, the
|
|
* board passes "am I myself served over plaintext HTTP");
|
|
* - free of embedded credentials, because `https://evil.test@real.test/...`
|
|
* reads as the wrong origin to a human and Paperclip must not help;
|
|
* - free of a fragment, which never survives an authorization request usefully
|
|
* and is a classic way to hide the effective target from a reader.
|
|
*
|
|
* Both the server and the board import this so a value that passes the API
|
|
* boundary cannot fail the navigation boundary, or vice versa.
|
|
*/
|
|
|
|
export type OAuthEndpointKind = "authorization" | "token" | "registration" | "metadata";
|
|
|
|
export type OAuthEndpointUrlRejection =
|
|
| "missing"
|
|
| "malformed"
|
|
| "unsupported_scheme"
|
|
| "insecure_transport"
|
|
| "embedded_credentials"
|
|
| "fragment";
|
|
|
|
export interface OAuthEndpointUrlOptions {
|
|
/**
|
|
* Allow `http:` for loopback hosts. Only true under an explicit
|
|
* local-development policy — never for a public deployment.
|
|
*/
|
|
allowInsecureLoopback?: boolean;
|
|
/**
|
|
* Origins whose `http:` is already the operator's own trust boundary —
|
|
* in practice just Paperclip's own deployment origin. A deployment served over
|
|
* plaintext HTTP on a LAN address can still run its own authorization
|
|
* endpoints (the smoke-lab fixture does), and opening Paperclip from Paperclip
|
|
* adds no exposure the board does not already have.
|
|
*/
|
|
allowInsecureOrigins?: string[];
|
|
}
|
|
|
|
export type OAuthEndpointUrlCheck =
|
|
| { ok: true; url: string; host: string }
|
|
| { ok: false; reason: OAuthEndpointUrlRejection };
|
|
|
|
/** `127.0.0.0/8` — the whole loopback range, not just `127.0.0.1`. */
|
|
function isLoopbackIpv4(hostname: string): boolean {
|
|
const parts = hostname.split(".");
|
|
if (parts.length !== 4) return false;
|
|
if (!parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return false;
|
|
return parts[0] === "127";
|
|
}
|
|
|
|
function isLoopbackHost(hostname: string): boolean {
|
|
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
if (host === "::1") return true;
|
|
const mapped = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
if (mapped?.[1]) return isLoopbackIpv4(mapped[1]);
|
|
return isLoopbackIpv4(host);
|
|
}
|
|
|
|
function normalizedOrigin(value: string): string | null {
|
|
try {
|
|
const origin = new URL(value).origin;
|
|
return origin === "null" ? null : origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function insecureTransportAllowed(parsed: URL, options: OAuthEndpointUrlOptions): boolean {
|
|
if (options.allowInsecureLoopback && isLoopbackHost(parsed.hostname)) return true;
|
|
return (options.allowInsecureOrigins ?? []).some((origin) => normalizedOrigin(origin) === parsed.origin);
|
|
}
|
|
|
|
/**
|
|
* Is `value` an OAuth endpoint Paperclip is willing to use — and, for the
|
|
* authorization endpoint, to navigate a browser to? Returns the normalized URL
|
|
* and its host on success, or the specific rejection reason so callers can
|
|
* produce an actionable message.
|
|
*/
|
|
export function checkOAuthEndpointUrl(
|
|
value: unknown,
|
|
options: OAuthEndpointUrlOptions = {},
|
|
): OAuthEndpointUrlCheck {
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
return { ok: false, reason: "missing" };
|
|
}
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(value.trim());
|
|
} catch {
|
|
return { ok: false, reason: "malformed" };
|
|
}
|
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
return { ok: false, reason: "unsupported_scheme" };
|
|
}
|
|
// A `javascript:`/`data:` URL has no host, and neither does something like
|
|
// `http:relative` — without an authority there is no origin to trust.
|
|
if (!parsed.hostname) return { ok: false, reason: "malformed" };
|
|
if (parsed.protocol === "http:" && !insecureTransportAllowed(parsed, options)) {
|
|
return { ok: false, reason: "insecure_transport" };
|
|
}
|
|
if (parsed.username || parsed.password) return { ok: false, reason: "embedded_credentials" };
|
|
if (parsed.hash) return { ok: false, reason: "fragment" };
|
|
return { ok: true, url: parsed.toString(), host: parsed.host };
|
|
}
|
|
|
|
export function isSafeOAuthEndpointUrl(value: unknown, options: OAuthEndpointUrlOptions = {}): boolean {
|
|
return checkOAuthEndpointUrl(value, options).ok;
|
|
}
|
|
|
|
const KIND_LABELS: Record<OAuthEndpointKind, string> = {
|
|
authorization: "sign-in",
|
|
token: "token",
|
|
registration: "client registration",
|
|
metadata: "metadata",
|
|
};
|
|
|
|
/**
|
|
* A UI-safe explanation for a rejected endpoint. Never echoes the URL, because
|
|
* the string came from the remote server and may itself be the attack.
|
|
*/
|
|
export function oauthEndpointUrlRejectionMessage(
|
|
kind: OAuthEndpointKind,
|
|
reason: OAuthEndpointUrlRejection,
|
|
): string {
|
|
const label = KIND_LABELS[kind];
|
|
switch (reason) {
|
|
case "missing":
|
|
return `This server did not provide a ${label} address.`;
|
|
case "malformed":
|
|
return `This server's ${label} address is not a valid URL, so Paperclip stopped.`;
|
|
case "unsupported_scheme":
|
|
return `This server's ${label} address does not use https, so Paperclip stopped.`;
|
|
case "insecure_transport":
|
|
return `This server's ${label} address is not secure (https), so Paperclip stopped.`;
|
|
case "embedded_credentials":
|
|
return `This server's ${label} address hides a different site behind a username, so Paperclip stopped.`;
|
|
case "fragment":
|
|
return `This server's ${label} address is malformed for sign-in, so Paperclip stopped.`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The host to show an operator before they are sent to an authorization page.
|
|
* `null` when the value is not a usable URL — callers should be refusing to
|
|
* navigate at that point anyway.
|
|
*/
|
|
export function oauthEndpointDisplayHost(value: unknown): string | null {
|
|
if (typeof value !== "string" || !value.trim()) return null;
|
|
try {
|
|
return new URL(value.trim()).host || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|