Files
PaperClipAI/packages/shared/src/connection-routing.ts
T
DottaandPaperclip d0db8820db fix(connections): repair native baseline and approval continuations (#15420)
## Thinking Path

> - Paperclip manages AI agents and the tools they may use.
> - Connection setup separates provider preference from permission to
use a tool.
> - The first native connection baseline could not exercise its intended
decisions.
> - The browser used mutable task titles, and the provider fixture
already granted access.
> - Native provider-choice instructions also disagreed with the
preferred question format. Schema rejection gave no field guidance.
> - This pull request repairs those test preconditions and native
guidance, then fixes restart/approval defects exposed by the corrected
baseline. It also restores missing OpenCode tool-error evidence.
> - The benefit is an inspectable baseline before any further
instruction reduction.

## Linked Issues or Issue Description

Refs #15407.

The original 15-cell baseline remains 0 PASS / 15 FAIL. Ten cells
stopped on stale titles, two Codex cells had schema denials, two
OpenCode cells used already-granted tools, and one Claude cell returned
no native result. No intended user decisions were submitted. The exact
invalid Codex field and underlying Claude failure cause remain unknown.

[Original
campaign](https://github.com/paperclipai/paperclip/actions/runs/37562577199)
· [Original
report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37562577199-1/index.html)

## What Changed

- Match the browser's task route and visible identifier instead of a
title the agent can change.
- Start native provider-choice fixtures with no agent tool access.
Verify the public effective-access records.
- In the positive case, select Arcade, then grant its exact HubSpot tool
through the real access card. Require both saved decisions and exactly
one observed call.
- Return a canonical `providerQuestionSet` for native input and retain
the equivalent legacy `providerQuestion`.
- Keep invalid input rejected. Return bounded schema locations and
required field names without submitted values.
- Preserve Claude's exact session/content identity while allowing
authenticated registered instruction-copy paths to rotate on a new run.
- Reject duplicate approval reports for an existing exact tool-action
card before they create another human review.
- Wait for a recorded service-approval continuation within the existing
deadline; retain missing or failed continuation grades.
- Forward OpenCode tool activity through the runner facade, preserving
bounded errors and execution-part identity without inventing host-call
joins or exposing arguments.
- Preserve original grades, costs, scope limits and diagnoses in the
dated repair report.

## Verification

- Eval typecheck passes. Support suite: 1,801 PASS, one intentional
skip; Node checks: 128 PASS.
- Connection/schema tests: 51 PASS. Real-server public fixture setup:
one PASS with zero providers.
- Browser support regression: five PASS, including renamed and wrong
tasks.
- Focused Rust safe-feedback test: one PASS.
- Repository typecheck and build pass before the latest master replay.
Post-replay connection/shared/real-server fixture checks: 52 PASS; eval
typecheck passes. The browser review fix additionally passes all five
browser checks and seven suite checks.
- The full local repository run was interrupted incomplete after about
45 minutes, with five integration failures retained. All five pass in a
separate targeted invocation (1,250 unrelated tests skipped). No full
local-suite pass or root cause for the initial local failures is
claimed.
- Corrected frozen source `162cc90fdabe7f505b88ae095044531b82784c92`:
**10 PASS / 5 FAIL** across the [passing Codex
canary](https://github.com/paperclipai/paperclip/actions/runs/37575158761)
and [remaining 14
cells](https://github.com/paperclipai/paperclip/actions/runs/37576261807).
The canary passes all 17 checks. Claude's two provider-choice
continuations fail on restart, Claude service approval exposes an early
evaluator rejection, Codex service approval creates a duplicate
approval, and OpenCode provider-second times out after both decisions
with no HubSpot call. No original result is regraded.
- Final ledgers count 31 actual runs: 27 succeeded, two failed, two
cancelled during cleanup. All 15 cleanup/budget checks pass. The late
Claude continuation is absent from its earlier workflow snapshot; it
remains in the result/API/final ledger. Original evidence retains 279
hashes. Recorded LLM subtotal $0.04553787 is incomplete billing, not
actual total cost; local runtime is unmetered.
- New repair regressions reproduce the Claude attach failure, duplicate
approval acceptance and dropped OpenCode tool events before their
respective fixes. Nine Rust attachment checks, 127 ACPX host/adapter
tests, 33 completion/control-plane checks, nine eval deadline tests, 59
OpenCode proxy/driver tests, one Rust tool-error/redaction check, and
TypeScript/Rust composer parity pass. Eval typecheck, repository
typecheck and build pass. Existing support coverage is 1,802 PASS plus
128 Node PASS, one intentional support skip; two additional deadline
tests also pass.
- New-source full CI/review and live canaries are pending. The next
bounded selection is Claude provider-decline, Codex service-approve and
one OpenCode provider-second diagnostic with repaired event evidence. No
broader campaign or instruction-reduction qualification is claimed.
- Initial corrected campaign
[37574251834](https://github.com/paperclipai/paperclip/actions/runs/37574251834)
was cancelled during shared build after review found the breadcrumb
whitespace assumption. Its matrix job has zero steps and no provider
execution. The real adjacent-span browser regression now reproduces the
old failure and passes after the fix.

## Risks

- The corrected baseline remains 10/15. The new restart/approval fixes
require live qualification; OpenCode evidence forwarding does not itself
establish or fix its prior behavioral failure.
- The positive provider case now expects three runs, including separate
access approval. Its new results are distinct from the original invalid
fixture.
- The old Claude missing-result cause and rejected Codex field are
unknown. These repairs do not retroactively explain or erase either
failure.
- Path rotation must preserve prompt, custom instruction, skill/content
identity and protected provider settings; regression checks reject stale
or changed content. No connection authorization, JSON schema, budget,
cleanup, or final-result requirement is relaxed. Historical Everyday
prompts and gateway setup remain unchanged.

## Model Used

OpenAI Codex, GPT-6. The exact deployment variant and context window are
not exposed in this session. Used repository inspection, code editing,
test execution and retained-evidence analysis.

## 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 (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] 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
- [ ] All Paperclip CI gates are green
- [ ] 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-10-07 06:18:51 -05:00

373 lines
12 KiB
TypeScript

import type {
AskUserQuestionsQuestion,
PaperclipQuestionSetPayload,
ConnectionSearchResultItem,
} from "./types/index.js";
import {
isRemoteMcpConnectorId,
type RemoteMcpConnectorId,
} from "./remote-mcp-connectors.js";
import composioCatalog from "./composio-search-catalog.json" with { type: "json" };
import arcadeCatalog from "./arcade-app-catalog.json" with { type: "json" };
import { aggregatorAppIdentity } from "./aggregator-app-catalog.js";
import { prepareConnectionSearch, scoreConnectionSearch } from "./connection-search.js";
export const AGGREGATOR_PRIORITY = [
"composio",
"arcade",
"executor",
"zapier",
] as const;
export const AGGREGATOR_NAMES: Record<RemoteMcpConnectorId, string> = {
composio: "Composio",
arcade: "Arcade",
executor: "Executor",
zapier: "Zapier",
};
/** Reviewed public catalog snapshot, not a claim that an account has authorized an app.
* Add only services confirmed in the cited official catalog. No provider credentials.
* Executor has no universal app catalog: use the authorized workspace's indexed tools.
*/
const REVIEWED_AGGREGATOR_SUPPORT = [
{
slug: "hubspot",
name: "HubSpot",
aliases: ["hub spot"],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "salesforce",
name: "Salesforce",
aliases: [],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "ashby",
name: "Ashby",
aliases: [],
providers: { composio: "2026-09-23", arcade: "2026-09-23" },
},
{
slug: "pipedrive",
name: "Pipedrive",
aliases: [],
providers: { composio: "2026-09-23", zapier: "2026-09-23" },
},
{
slug: "gong",
name: "Gong",
aliases: [],
providers: { composio: "2026-09-23" },
},
{
slug: "outlook",
name: "Outlook",
aliases: ["microsoft outlook"],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "microsoft-teams",
name: "Microsoft Teams",
aliases: ["teams"],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "firecrawl",
name: "Firecrawl",
aliases: [],
providers: { composio: "2026-09-23", arcade: "2026-09-23" },
},
{
slug: "tavily",
name: "Tavily",
aliases: [],
providers: { composio: "2026-09-23", arcade: "2026-09-23" },
},
{
slug: "exa",
name: "Exa",
aliases: [],
providers: { composio: "2026-09-23", arcade: "2026-09-23" },
},
{
slug: "jira",
name: "Jira",
aliases: ["atlassian jira"],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "notion",
name: "Notion",
aliases: [],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "github",
name: "GitHub",
aliases: ["git hub"],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "gmail",
name: "Gmail",
aliases: ["google mail"],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "slack",
name: "Slack",
aliases: [],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "airtable",
name: "Airtable",
aliases: [],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "asana",
name: "Asana",
aliases: [],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
{
slug: "linear",
name: "Linear",
aliases: [],
providers: {
composio: "2026-09-23",
arcade: "2026-09-23",
zapier: "2026-09-23",
},
},
] as const;
export interface AggregatorServiceDefinition {
slug: string;
name: string;
aliases: readonly string[];
providers: Partial<Record<RemoteMcpConnectorId, string>>;
evidenceUrls?: Partial<Record<RemoteMcpConnectorId, string>>;
}
// Keep the other providers' reviewed support claims, and cover the complete
// public Composio catalog instead of maintaining a tiny app-name allowlist.
export const AGGREGATOR_SUPPORT_INDEX: AggregatorServiceDefinition[] = REVIEWED_AGGREGATOR_SUPPORT.map(entry => ({ ...entry }));
for (const [toolkit, name] of composioCatalog.toolkits as Array<[string, string]>) {
const slug = toolkit.replaceAll("_", "-").replace(/^-+/, "");
const evidenceUrl = `https://docs.composio.dev/toolkits/${toolkit}`;
const existing = AGGREGATOR_SUPPORT_INDEX.find(entry => entry.slug === slug
|| (toolkit.endsWith("_mcp") && entry.slug === slug.slice(0, -4)));
if (existing) {
existing.aliases = [...new Set([...existing.aliases, toolkit, name])];
existing.providers = { ...existing.providers, composio: composioCatalog.verifiedAt };
existing.evidenceUrls ??= { composio: evidenceUrl };
} else {
AGGREGATOR_SUPPORT_INDEX.push({ slug, name,
aliases: [toolkit, name.replace(/ MCP$/i, "")],
providers: { composio: composioCatalog.verifiedAt },
evidenceUrls: { composio: evidenceUrl },
});
}
}
for (const app of arcadeCatalog.apps) {
const existing = AGGREGATOR_SUPPORT_INDEX.find((entry) =>
entry.slug === app.slug || aggregatorAppIdentity(entry.name) === aggregatorAppIdentity(app.name));
if (existing) {
existing.aliases = [...new Set([...existing.aliases, app.slug, ...app.aliases])];
existing.providers = { ...existing.providers, arcade: arcadeCatalog.verifiedAt };
existing.evidenceUrls = { ...existing.evidenceUrls, arcade: app.docsUrl };
} else {
AGGREGATOR_SUPPORT_INDEX.push({ slug: app.slug, name: app.name, aliases: app.aliases,
providers: { arcade: arcadeCatalog.verifiedAt }, evidenceUrls: { arcade: app.docsUrl } });
}
}
export function searchAggregatorServices(query: string | ReturnType<typeof prepareConnectionSearch>) {
const prepared = typeof query === "string" ? prepareConnectionSearch(query) : query;
return AGGREGATOR_SUPPORT_INDEX.map(service => ({ service,
...scoreConnectionSearch(prepared, [service.slug, service.name, ...service.aliases]),
})).filter(match => match.nameScore > 0 && !isRemoteMcpConnectorId(match.service.slug))
.sort((a, b) => b.score - a.score || a.service.slug.localeCompare(b.service.slug));
}
export const AGGREGATOR_CATALOG_SOURCES: Partial<
Record<RemoteMcpConnectorId, string>
> = {
composio: "https://composio.dev/toolkits",
arcade: "https://docs.arcade.dev/en/resources/integrations",
zapier: "https://zapier.com/apps",
};
export function normalizeConnectionQuery(value: string): string {
return value
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
export function findAggregatorService(query: string, rankedMatches?: ReturnType<typeof searchAggregatorServices>) {
const normalized = normalizeConnectionQuery(query);
const exact = AGGREGATOR_SUPPORT_INDEX.find((entry) =>
[entry.slug, entry.name, ...entry.aliases].some(
(name) => normalizeConnectionQuery(name) === normalized,
),
);
if (exact) return exact;
// Agents often include the desired capability ("HubSpot recent contacts").
// Whole phrases avoid substring guesses; multiple named services need clarification.
const matches = (rankedMatches ?? searchAggregatorServices(query))
.filter(match => match.nameScore >= 500).map(match => match.service);
// "Atlassian Jira" identifies Jira, not both Jira and Atlassian's MCP.
const phrases = (entry: AggregatorServiceDefinition) => [entry.slug, entry.name, ...entry.aliases]
.map(normalizeConnectionQuery).filter(name => ` ${normalized} `.includes(` ${name} `));
const specific = matches.filter(entry => !matches.some(other => other !== entry
&& phrases(entry).every(name => phrases(other).some(longer => longer !== name && ` ${longer} `.includes(` ${name} `)))));
return specific.length === 1 ? specific[0] : undefined;
}
/** Preserve a provider explicitly named by the user instead of applying default ranking. */
export function explicitAggregatorQuery(query: string) {
// A trailing exclusion is not a second proposed provider. Keep this grammar
// narrow; alternatives and contradictory choices still require confirmation.
const exclusion = query.match(/\s*,?\s+not\s+(composio|arcade|executor|zapier)[.!]?\s*$/i);
if (exclusion) query = query.slice(0, exclusion.index).trim();
const matches = [
...query.matchAll(
/\b(?:through|via|using)\s+(composio|arcade|executor|zapier)\b/gi,
),
];
const namedProviders = new Set(
(query.match(/\b(?:composio|arcade|executor|zapier)\b/gi) ?? []).map(
(name) => name.toLowerCase(),
),
);
if (matches.length !== 1 || namedProviders.size !== 1) return null;
const match = matches[0]!;
if (exclusion?.[1]?.toLowerCase() === match[1]!.toLowerCase()) return null;
return {
provider: match[1]!.toLowerCase() as RemoteMcpConnectorId,
serviceQuery: (
query.slice(0, match.index) + query.slice(match.index! + match[0].length)
).trim(),
};
}
export function parseAggregatorRoute(service: string) {
const parts = service.split(":");
if (
parts.length !== 3 ||
parts[0] !== "via" ||
!isRemoteMcpConnectorId(parts[1]) ||
!/^[a-z0-9][a-z0-9-]{0,79}$/.test(parts[2]!)
)
return null;
return { provider: parts[1], targetService: parts[2]! };
}
export function aggregatorProviderQuestion(
targetService: string,
targetName: string,
results: ConnectionSearchResultItem[],
): AskUserQuestionsQuestion {
return {
id: `connection-provider:${targetService}`,
// Keep required disclosure in the prompt: all question transports preserve it.
prompt: `Connect ${targetName} through an external service? These services handle the connection and requests to ${targetName}. Connecting a provider does not yet authorize ${targetName}.`,
selectionMode: "single",
required: true,
allowOther: false,
options: [
...results.map((result, index) => ({
id: result.service,
label: `${AGGREGATOR_NAMES[result.aggregator!.provider]}${index === 0 ? " — Recommended" : ""}`,
description:
result.reason ?? "Verify the app in this external provider.",
})),
{
id: "none",
label: "None for now",
description: "Do not connect through an external service.",
},
],
};
}
/** Native question form, preserving the exact saved-answer authorization contract. */
export function aggregatorProviderQuestionSet(question: AskUserQuestionsQuestion): PaperclipQuestionSetPayload {
return {
schema: "paperclip.question_set.v1",
questions: [{
id: question.id,
prompt: question.prompt,
required: true,
answerMode: "single_select",
options: question.options.map(({ id, label, description }) => ({ id, label, ...(description != null ? { description } : {}) })),
}],
};
}
export function aggregatorContinuationInstruction(
provider: RemoteMcpConnectorId,
targetName: string,
) {
const name = AGGREGATOR_NAMES[provider];
const discovery = {
composio:
"Use COMPOSIO_SEARCH_TOOLS to verify the requested app and capability, then COMPOSIO_MANAGE_CONNECTIONS if app authorization is needed.",
arcade:
"Inspect the gateway tools for the requested app. If absent, ask the user to add the app's tools in the Arcade gateway and refresh the connection. Follow tool authorization links when required.",
executor:
"Inspect the Executor workspace's actual integrations using its documented discovery tools. Do not assume arbitrary apps exist or register integrations merely to search. Follow provider authorization and resume instructions when needed.",
zapier:
"In Agentic mode, use discover_zapier_actions and the documented app connection flow. In Managed mode, ask the user to add the required actions in Zapier and refresh the catalog.",
}[provider];
return `${name} is an external service. Its connection is available to this agent, but ${targetName} access is not yet verified. ${discovery} Use only the requested capability and effective permissions. If support is absent, explain that result and ask before switching providers. Claim success only after verifying the underlying app; never repeat an uncertain write.`;
}