Files
PaperClipAI/packages/shared/src/mcp-remote-headers.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

128 lines
4.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
checkMcpRemoteHeaderName,
checkMcpRemoteHeaderValue,
mcpRemoteHeaderNameFromConfigPath,
mcpRemoteHeaderRejectionMessage,
} from "./mcp-remote-headers.js";
describe("checkMcpRemoteHeaderName", () => {
it("accepts the header names real MCP servers ask for", () => {
for (const name of [
"Authorization",
"authorization",
"X-PostHog-Project-Id",
"x-api-key",
"Mcp-Session-Id",
"X_Custom.Token",
]) {
expect(checkMcpRemoteHeaderName(name), name).toEqual({ ok: true });
}
});
it("rejects hop-by-hop, framing, routing and ambient-credential headers", () => {
for (const name of [
"Host",
"host",
"Connection",
"Content-Length",
"Transfer-Encoding",
"TE",
"Trailer",
"Upgrade",
"Keep-Alive",
"Cookie",
"Set-Cookie",
"Proxy-Authorization",
"Via",
"Expect",
]) {
expect(checkMcpRemoteHeaderName(name), name).toEqual({ ok: false, reason: "forbidden" });
}
});
it("rejects reserved prefixes browsers and proxies own", () => {
for (const name of ["Sec-Fetch-Mode", "sec-websocket-key", "Proxy-Foo", "HTTP2-Settings"]) {
expect(checkMcpRemoteHeaderName(name), name).toEqual({ ok: false, reason: "forbidden" });
}
});
it("rejects names that could smuggle a separator or a new header line", () => {
for (const name of [
"X-Bad\r\nX-Injected",
"X-Bad\nX-Injected",
"X Bad",
"X-Bad:",
"X-Bad;Other",
"X-Bad,Other",
'X-"Bad"',
"X-Bad/Other",
"X-Bad(Other)",
"X-Bad@Other",
"X-Bad\u0000",
]) {
expect(checkMcpRemoteHeaderName(name), JSON.stringify(name)).toEqual({
ok: false,
reason: "invalid_characters",
});
}
});
it("rejects blank and over-long names", () => {
expect(checkMcpRemoteHeaderName("")).toEqual({ ok: false, reason: "empty" });
expect(checkMcpRemoteHeaderName(" ")).toEqual({ ok: false, reason: "empty" });
expect(checkMcpRemoteHeaderName("x".repeat(129))).toEqual({ ok: false, reason: "too_long" });
expect(checkMcpRemoteHeaderName("x".repeat(128))).toEqual({ ok: true });
});
});
describe("checkMcpRemoteHeaderValue", () => {
it("accepts ordinary credential values", () => {
for (const value of ["Bearer phx_abc123", "phx_abc123", "12345", "a b c", ""]) {
expect(checkMcpRemoteHeaderValue(value), JSON.stringify(value)).toEqual({ ok: true });
}
});
it("rejects values that would split the request", () => {
for (const value of [
"abc\r\nX-Injected: 1",
"abc\nX-Injected: 1",
"abc\rdef",
"abc\u0000def",
"abc\tdef",
"abc\u007fdef",
]) {
expect(checkMcpRemoteHeaderValue(value), JSON.stringify(value)).toEqual({
ok: false,
reason: "value_control_characters",
});
}
});
it("rejects over-long values", () => {
expect(checkMcpRemoteHeaderValue("x".repeat(8_193))).toEqual({ ok: false, reason: "value_too_long" });
expect(checkMcpRemoteHeaderValue("x".repeat(8_192))).toEqual({ ok: true });
});
});
describe("mcpRemoteHeaderNameFromConfigPath", () => {
it("extracts a header name from a headers.* config path", () => {
expect(mcpRemoteHeaderNameFromConfigPath("headers.X-Api-Key")).toBe("X-Api-Key");
expect(mcpRemoteHeaderNameFromConfigPath("headers. X-Api-Key ")).toBe("X-Api-Key");
});
it("returns null for non-header and empty paths", () => {
expect(mcpRemoteHeaderNameFromConfigPath("credentials.authorization")).toBeNull();
expect(mcpRemoteHeaderNameFromConfigPath("headers.")).toBeNull();
expect(mcpRemoteHeaderNameFromConfigPath("headers. ")).toBeNull();
});
});
describe("mcpRemoteHeaderRejectionMessage", () => {
it("explains the problem without echoing the value", () => {
expect(mcpRemoteHeaderRejectionMessage("Host", "forbidden")).toContain("Host");
expect(mcpRemoteHeaderRejectionMessage("X-Api-Key", "value_control_characters"))
.toBe('The value for "X-Api-Key" contains line breaks or control characters.');
});
});