Files
PaperClipAI/scripts/smoke/posthog-live-lib.mjs
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

248 lines
8.7 KiB
JavaScript

const REQUIRED_ENVIRONMENT = [
"INTEGRATIONS_POSTHOG_PAPERCLIP_E2E_EMAIL",
"INTEGRATIONS_POSTHOG_PAPERCLIP_DEV_LOGIN_PASSWORD",
"INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID",
];
const REQUIRED_PROJECT_ID = "483530";
export class PosthogLivePreflightError extends Error {
constructor(code, details = {}) {
super(code);
this.name = "PosthogLivePreflightError";
this.code = code;
this.details = details;
}
}
function requiredValue(environment, key) {
const value = environment[key];
return typeof value === "string" ? value.trim() : "";
}
export function parsePosthogLiveArguments(args = []) {
if (args.length === 0) return {};
if (args.length === 1 && !args[0].startsWith("-")) return { baseUrl: args[0] };
if (args.length === 2 && args[0] === "--base-url" && args[1].trim()) {
return { baseUrl: args[1] };
}
throw new PosthogLivePreflightError("invalid_arguments");
}
export function preflightPosthogLive(environment = process.env, options = {}) {
const missing = REQUIRED_ENVIRONMENT.filter((key) => requiredValue(environment, key) === "");
if (missing.length > 0) {
throw new PosthogLivePreflightError("missing_environment", { missing });
}
const baseUrlValue = typeof options.baseUrl === "string" && options.baseUrl.trim()
? options.baseUrl.trim()
: requiredValue(environment, "PAPERCLIP_API_URL");
if (!baseUrlValue) {
throw new PosthogLivePreflightError("missing_base_url");
}
let baseUrl;
try {
baseUrl = new URL(baseUrlValue);
} catch {
throw new PosthogLivePreflightError("invalid_base_url");
}
const loopback = baseUrl.hostname === "127.0.0.1" || baseUrl.hostname === "localhost" || baseUrl.hostname === "[::1]";
if ((baseUrl.protocol !== "https:" && !(loopback && baseUrl.protocol === "http:"))
|| baseUrl.username
|| baseUrl.password
|| baseUrl.search
|| baseUrl.hash) {
throw new PosthogLivePreflightError("unsafe_base_url");
}
baseUrl.pathname = baseUrl.pathname.replace(/\/+$/, "") || "/";
const projectId = requiredValue(environment, "INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID");
if (!/^\d+$/.test(projectId)) {
throw new PosthogLivePreflightError("invalid_project_id");
}
if (projectId !== REQUIRED_PROJECT_ID) {
throw new PosthogLivePreflightError("unexpected_project_id");
}
const email = requiredValue(environment, "INTEGRATIONS_POSTHOG_PAPERCLIP_E2E_EMAIL");
if (!email.includes("@")) {
throw new PosthogLivePreflightError("invalid_email");
}
return {
baseUrl: baseUrl.origin,
email,
password: environment.INTEGRATIONS_POSTHOG_PAPERCLIP_DEV_LOGIN_PASSWORD,
projectId,
};
}
export async function preparePosthogLiveSmoke({
environment = process.env,
baseUrl,
fetchImpl = globalThis.fetch,
loadBrowser,
}) {
const config = preflightPosthogLive(environment, { baseUrl });
let response;
try {
response = await fetchImpl(new URL("/api/health", config.baseUrl), {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(15_000),
});
} catch {
throw new PosthogLivePreflightError("health_unreachable");
}
if (!response.ok) {
throw new PosthogLivePreflightError("health_http_error", { status: response.status });
}
let body;
try {
body = await response.json();
} catch {
throw new PosthogLivePreflightError("health_invalid_json");
}
if (body?.status !== "ok") {
throw new PosthogLivePreflightError("health_not_ok");
}
return { config, browserModule: await loadBrowser() };
}
function projectIdFrom(candidate) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return null;
for (const key of ["projectId", "project_id", "projectID", "id"]) {
const value = candidate[key];
if (typeof value === "string" || typeof value === "number") return String(value);
}
return null;
}
function projectNameFrom(candidate) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return null;
for (const key of ["projectName", "project_name", "name"]) {
const value = candidate[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function parsedJsonString(value) {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > 200_000) return null;
const candidates = [trimmed];
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fenced) candidates.unshift(fenced[1]);
for (const candidate of candidates) {
if (!candidate.startsWith("{") && !candidate.startsWith("[")) continue;
try {
return JSON.parse(candidate);
} catch {
// Some MCP servers wrap structured JSON in prose; the regex fallback below
// handles the small project summary without retaining that prose.
}
}
return null;
}
export function extractProjectSummary(value, expectedProjectId) {
const seen = new Set();
const visit = (candidate, depth) => {
if (depth > 12 || candidate === null || candidate === undefined) return null;
if (typeof candidate === "string") {
const parsed = parsedJsonString(candidate);
if (parsed !== null) {
const nested = visit(parsed, depth + 1);
if (nested) return nested;
}
if (candidate.length <= 200_000) {
const idMatch = candidate.match(/(?:project[_\s-]*id|\bid\b)["'\s:=]+([0-9]+)/i);
if (idMatch?.[1] === expectedProjectId) {
const nameMatch = candidate.match(/(?:project[_\s-]*name|\bname\b)["'\s:=]+["']?([^"'\n,}\]]+)/i);
return { id: expectedProjectId, name: nameMatch?.[1]?.trim() || null };
}
}
return null;
}
if (typeof candidate !== "object" || seen.has(candidate)) return null;
seen.add(candidate);
if (!Array.isArray(candidate)) {
const id = projectIdFrom(candidate);
if (id === expectedProjectId) {
return { id, name: projectNameFrom(candidate) };
}
}
const children = Array.isArray(candidate) ? candidate : Object.values(candidate);
for (const child of children) {
const found = visit(child, depth + 1);
if (found) return found;
}
return null;
};
return visit(value, 0);
}
export function parseSanitizedAgentProof(commentBody, expectedProjectId) {
if (typeof commentBody !== "string") return null;
const trimmed = commentBody.trim();
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
let parsed;
try {
parsed = JSON.parse(fenced ? fenced[1] : trimmed);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (Object.keys(parsed).sort().join(",") !== "invocationId,projectId,projectName") return null;
if (String(parsed.projectId ?? "") !== expectedProjectId) return null;
if (typeof parsed.projectName !== "string" || !parsed.projectName.trim()) return null;
if (typeof parsed.invocationId !== "string" || !parsed.invocationId.trim()) return null;
return {
projectId: expectedProjectId,
projectName: parsed.projectName.trim(),
invocationId: parsed.invocationId.trim(),
};
}
const FORBIDDEN_EVIDENCE_KEYS = /(?:password|access[_-]?token|refresh[_-]?token|authorization|cookie|oauth[_-]?code|client[_-]?secret)/i;
const FORBIDDEN_EVIDENCE_TEXT = /(?:authorization:\s*bearer|cookie:|[?&](?:code|state|token|access_token|refresh_token)=)/i;
export function assertSanitizedEvidence(value) {
const seen = new Set();
const visit = (candidate, path) => {
if (candidate === null || candidate === undefined) return;
if (typeof candidate === "string") {
if (FORBIDDEN_EVIDENCE_TEXT.test(candidate)) {
throw new Error(`unsafe_evidence_text:${path}`);
}
return;
}
if (typeof candidate !== "object" || seen.has(candidate)) return;
seen.add(candidate);
for (const [key, child] of Object.entries(candidate)) {
if (FORBIDDEN_EVIDENCE_KEYS.test(key)) {
throw new Error(`unsafe_evidence_key:${path}.${key}`);
}
visit(child, `${path}.${key}`);
}
};
visit(value, "$");
}
export function preflightFailureMessage(error) {
if (!(error instanceof PosthogLivePreflightError)) return "PostHog live smoke preflight failed.";
if (error.code === "missing_environment") {
return `PostHog live smoke preflight failed: missing ${error.details.missing.join(", ")}.`;
}
if (error.code === "health_http_error") {
return `PostHog live smoke preflight failed: /api/health returned HTTP ${error.details.status}.`;
}
if (error.code === "missing_base_url") {
return "PostHog live smoke preflight failed: pass the target Paperclip URL or run inside a Paperclip heartbeat.";
}
if (error.code === "invalid_arguments") {
return "PostHog live smoke preflight failed: expected an optional Paperclip URL or --base-url <url>.";
}
return `PostHog live smoke preflight failed: ${error.code}.`;
}