mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +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>
287 lines
11 KiB
JavaScript
287 lines
11 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
assertAutomaticRegistrationSource,
|
|
assertSanitizedEvidence,
|
|
connectionRemovalFacts,
|
|
extractNotionIdentity,
|
|
extractNotionVerificationCode,
|
|
inspectAuthorizationUrl,
|
|
isFreshNotionVerificationMessage,
|
|
NotionGenericLivePreflightError,
|
|
notionVerificationAuthenticationPassed,
|
|
parseRuntimeAbsenceProof,
|
|
parseSanitizedAgentProof,
|
|
persistedOAuthStartResult,
|
|
preflightNotionGenericLive,
|
|
prepareNotionGenericLiveSmoke,
|
|
safeEndpointSummary,
|
|
} from "./notion-generic-live-lib.mjs";
|
|
|
|
const COMPLETE_ENV = {
|
|
PAPERCLIP_E2E_BASE_URL: "https://paperclip.example.test",
|
|
PAPERCLIP_E2E_EMAIL: "operator@example.test",
|
|
PAPERCLIP_DEV_LOGIN_PASSWORD: "not-a-real-password",
|
|
PAPERCLIP_API_URL: "https://paperclip.example.test/api",
|
|
PAPERCLIP_API_KEY: "not-a-real-agent-key",
|
|
PAPERCLIP_RUN_ID: "run-123",
|
|
PAPERCLIP_TASK_ID: "issue-123",
|
|
};
|
|
|
|
test("preflight reports binding names without exposing supplied values", () => {
|
|
assert.throws(
|
|
() => preflightNotionGenericLive({ PAPERCLIP_DEV_LOGIN_PASSWORD: "present" }),
|
|
(error) => {
|
|
assert.ok(error instanceof NotionGenericLivePreflightError);
|
|
assert.equal(error.code, "missing_environment");
|
|
assert.deepEqual(error.details.missing, [
|
|
"PAPERCLIP_E2E_BASE_URL",
|
|
"PAPERCLIP_E2E_EMAIL",
|
|
"PAPERCLIP_API_URL",
|
|
"PAPERCLIP_API_KEY",
|
|
"PAPERCLIP_RUN_ID",
|
|
"PAPERCLIP_TASK_ID",
|
|
]);
|
|
assert.doesNotMatch(error.message, /present/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("preflight requires explicit credential-free HTTPS target and control-plane URLs", () => {
|
|
for (const baseUrl of [
|
|
"http://127.0.0.1:3100",
|
|
"http://paperclip.example.test",
|
|
"https://user:secret@paperclip.example.test",
|
|
"https://paperclip.example.test/?code=secret",
|
|
]) {
|
|
assert.throws(
|
|
() => preflightNotionGenericLive({ ...COMPLETE_ENV, PAPERCLIP_E2E_BASE_URL: baseUrl }),
|
|
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_base_url",
|
|
);
|
|
}
|
|
const split = preflightNotionGenericLive({
|
|
...COMPLETE_ENV,
|
|
PAPERCLIP_API_URL: "https://control-plane.example.test/api",
|
|
});
|
|
assert.equal(split.baseUrl, "https://paperclip.example.test");
|
|
assert.equal(split.apiBaseUrl, "https://control-plane.example.test/api");
|
|
});
|
|
|
|
test("health and binding metadata pass before browser loading, without fetching the value", async () => {
|
|
const requests = [];
|
|
let browserLoaded = false;
|
|
const prepared = await prepareNotionGenericLiveSmoke({
|
|
environment: COMPLETE_ENV,
|
|
fetchImpl: async (url, init = {}) => {
|
|
requests.push({ url: String(url), method: init.method ?? "GET" });
|
|
if (String(url).endsWith("/api/health")) {
|
|
return { ok: true, json: async () => ({ status: "ok" }) };
|
|
}
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ secrets: [{ key: "generic-flow-test-account", delivery: "api" }] }),
|
|
};
|
|
},
|
|
loadBrowser: async () => {
|
|
browserLoaded = true;
|
|
return { chromium: {} };
|
|
},
|
|
});
|
|
assert.equal(prepared.config.callbackUrl, "https://paperclip.example.test/api/tools/oauth/callback");
|
|
assert.equal(browserLoaded, true);
|
|
assert.deepEqual(requests.map((entry) => entry.method), ["GET", "GET"]);
|
|
assert.equal(requests.some((entry) => entry.url.includes("/value")), false);
|
|
});
|
|
|
|
test("an unavailable secret binding fails before browser or credential entry", async () => {
|
|
let browserLoaded = false;
|
|
await assert.rejects(
|
|
prepareNotionGenericLiveSmoke({
|
|
environment: COMPLETE_ENV,
|
|
fetchImpl: async (url) => String(url).endsWith("/api/health")
|
|
? { ok: true, json: async () => ({ status: "ok" }) }
|
|
: { ok: true, json: async () => ({ secrets: [] }) },
|
|
loadBrowser: async () => {
|
|
browserLoaded = true;
|
|
},
|
|
}),
|
|
(error) => error instanceof NotionGenericLivePreflightError && error.code === "secret_binding_unavailable",
|
|
);
|
|
assert.equal(browserLoaded, false);
|
|
});
|
|
|
|
test("selects only fresh authenticated Notion verification mail and extracts one code", () => {
|
|
const notBefore = new Date("2026-08-18T12:00:00.000Z");
|
|
const message = {
|
|
timestamp: new Date("2026-08-18T12:00:05.000Z"),
|
|
from: "Notion <login@mail.notion.so>",
|
|
subject: "Your Notion login code",
|
|
extractedText: "Your temporary login code is 123 456.",
|
|
headers: {
|
|
"authentication-results": "dkim=pass; spf=pass; dmarc=pass",
|
|
},
|
|
};
|
|
assert.equal(isFreshNotionVerificationMessage(message, { notBefore }), true);
|
|
assert.equal(notionVerificationAuthenticationPassed(message), true);
|
|
assert.equal(extractNotionVerificationCode(message), "123456");
|
|
assert.equal(isFreshNotionVerificationMessage({
|
|
...message,
|
|
timestamp: new Date("2026-08-18T11:59:59.000Z"),
|
|
}, { notBefore }), false);
|
|
assert.equal(isFreshNotionVerificationMessage({
|
|
...message,
|
|
from: "Notion <login@example.test>",
|
|
}, { notBefore }), false);
|
|
assert.equal(notionVerificationAuthenticationPassed({
|
|
...message,
|
|
headers: { "authentication-results": "dkim=fail; spf=pass" },
|
|
}), false);
|
|
assert.equal(extractNotionVerificationCode({ ...message, extractedText: "Codes 123456 and 654321" }), null);
|
|
});
|
|
|
|
test("authorization proof requires automatic registration, PKCE, callback, resource, and safe endpoints", () => {
|
|
assert.equal(assertAutomaticRegistrationSource("cimd"), "cimd");
|
|
assert.equal(assertAutomaticRegistrationSource("dcr"), "dcr");
|
|
for (const source of ["manual", "preconfigured", null]) {
|
|
assert.throws(
|
|
() => assertAutomaticRegistrationSource(source),
|
|
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unexpected_registration_source",
|
|
);
|
|
}
|
|
|
|
const baseUrl = "https://paperclip.example.test";
|
|
const callbackUrl = `${baseUrl}/api/tools/oauth/callback`;
|
|
const resource = "https://mcp.notion.com/mcp";
|
|
const url = new URL("https://mcp.notion.com/authorize");
|
|
url.searchParams.set("response_type", "code");
|
|
url.searchParams.set("client_id", `${baseUrl}/api/tools/oauth/client-metadata`);
|
|
url.searchParams.set("redirect_uri", callbackUrl);
|
|
url.searchParams.set("state", "not-recorded");
|
|
url.searchParams.set("code_challenge", "not-recorded");
|
|
url.searchParams.set("code_challenge_method", "S256");
|
|
url.searchParams.set("resource", resource);
|
|
|
|
assert.deepEqual(inspectAuthorizationUrl(url.toString(), {
|
|
callbackUrl,
|
|
resource,
|
|
registrationSource: "cimd",
|
|
baseUrl,
|
|
}), {
|
|
endpoint: { origin: "https://mcp.notion.com", path: "/authorize" },
|
|
parameters: { clientId: true, state: true, pkceS256: true, callbackUri: true, resource: true },
|
|
});
|
|
assert.deepEqual(safeEndpointSummary("https://mcp.notion.com/token", "token"), {
|
|
origin: "https://mcp.notion.com",
|
|
path: "/token",
|
|
});
|
|
assert.throws(
|
|
() => safeEndpointSummary("http://mcp.notion.com/token", "token"),
|
|
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_token_endpoint",
|
|
);
|
|
});
|
|
|
|
test("authorization proof rejects a provider login page after OAuth parameters were consumed", () => {
|
|
assert.throws(
|
|
() => inspectAuthorizationUrl("https://id.notion.test/login", {
|
|
callbackUrl: "https://paperclip.example/api/tools/oauth/callback",
|
|
resource: "https://mcp.notion.com/mcp",
|
|
registrationSource: "dcr",
|
|
baseUrl: "https://paperclip.example",
|
|
}),
|
|
(error) => error instanceof NotionGenericLivePreflightError
|
|
&& error.code === "authorization_parameter_missing",
|
|
);
|
|
});
|
|
|
|
test("reconstructs the inline OAuth start from durable connection state and provider navigation", () => {
|
|
assert.deepEqual(persistedOAuthStartResult({
|
|
id: "connection-123",
|
|
config: {
|
|
oauth: {
|
|
clientRegistrationSource: "cimd",
|
|
issuer: "https://mcp.notion.com",
|
|
resource: "https://mcp.notion.com/mcp",
|
|
},
|
|
},
|
|
}, " https://mcp.notion.com/authorize?state=not-recorded "), {
|
|
connectionId: "connection-123",
|
|
authorizationUrl: "https://mcp.notion.com/authorize?state=not-recorded",
|
|
registrationSource: "cimd",
|
|
issuer: "https://mcp.notion.com",
|
|
resource: "https://mcp.notion.com/mcp",
|
|
});
|
|
assert.equal(persistedOAuthStartResult({ id: "connection-123", config: {} }, "https://example.test"), null);
|
|
});
|
|
|
|
test("accepts zero-count cleanup before setup but requires full revocation after install", () => {
|
|
const partial = {
|
|
installsRemoved: 0,
|
|
appProfileBindingsRemoved: 0,
|
|
credentialRefsCleared: 0,
|
|
secretsRevoked: 0,
|
|
secretBindingsRemoved: 0,
|
|
grantsRevoked: 0,
|
|
oauthStatesDiscarded: 1,
|
|
runtimeSlotsStopped: 0,
|
|
appProfile: "absent",
|
|
};
|
|
assert.deepEqual(connectionRemovalFacts(partial), {
|
|
credentialsRemoved: 0,
|
|
secretBindingsRemoved: 0,
|
|
grantsRevoked: 0,
|
|
accessBindingsRemoved: 0,
|
|
installsRemoved: 0,
|
|
oauthStatesDiscarded: 1,
|
|
runtimeSlotsStopped: 0,
|
|
appProfile: "absent",
|
|
});
|
|
assert.equal(connectionRemovalFacts(partial, { requireInstalled: true }), null);
|
|
assert.ok(connectionRemovalFacts({
|
|
...partial,
|
|
installsRemoved: 1,
|
|
appProfileBindingsRemoved: 1,
|
|
secretsRevoked: 1,
|
|
appProfile: "deleted",
|
|
}, { requireInstalled: true }));
|
|
});
|
|
|
|
test("workspace proof extraction and fresh-run comments retain only sanitized identity", () => {
|
|
const identity = extractNotionIdentity({
|
|
result: {
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
id: "bot-123",
|
|
type: "bot",
|
|
bot: { workspace_id: "workspace-123", workspace_name: "Paperclip" },
|
|
token: "discard-me",
|
|
}),
|
|
}],
|
|
},
|
|
});
|
|
assert.deepEqual(identity, {
|
|
workspaceId: "workspace-123",
|
|
workspaceName: "Paperclip",
|
|
botId: "bot-123",
|
|
});
|
|
assert.deepEqual(
|
|
parseSanitizedAgentProof(
|
|
'{"workspaceId":"workspace-123","workspaceName":"Paperclip","invocationId":"inv-123"}',
|
|
identity,
|
|
),
|
|
{ workspaceId: "workspace-123", workspaceName: "Paperclip", invocationId: "inv-123" },
|
|
);
|
|
assert.deepEqual(
|
|
parseRuntimeAbsenceProof('{"connectionId":"conn-123","toolPresent":false}', "conn-123"),
|
|
{ connectionId: "conn-123", toolPresent: false },
|
|
);
|
|
});
|
|
|
|
test("sanitized evidence rejects credential fields, sessions, and OAuth query values", () => {
|
|
assert.doesNotThrow(() => assertSanitizedEvidence({ workspaceId: "workspace-123", invocationId: "inv-123" }));
|
|
assert.throws(() => assertSanitizedEvidence({ accessToken: "secret" }), /unsafe_evidence_key/);
|
|
assert.throws(() => assertSanitizedEvidence({ sessionId: "secret" }), /unsafe_evidence_key/);
|
|
assert.throws(() => assertSanitizedEvidence({ note: "callback?code=secret" }), /unsafe_evidence_text/);
|
|
});
|