mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
feat(connections): add self-serve intent runtime (#12345)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents need a governed way to request app connections during issue work. > - The catalog now describes the available providers and setup methods. > - A request must become a durable, company-scoped intent before an operator acts on it. > - This pull request adds that intent runtime across server, agent, CLI, and shared contracts. > - The benefit is a safe bridge from agent need to operator-approved setup. ## Linked Issues or Issue Description Refs #11965 This is stack 7 of 11. It depends on stack 6 and replaces another reviewable part of #11965. ## What Changed - Add connection intent types, validation, service logic, and routes. - Add agent runtime tools and CLI support for connection requests. - Add issue-thread interaction support for connection intents. - Add runtime, route, adapter, and contract tests. - Hold the final resolved-continuation row lock through asynchronous adapter preparation until an actual process spawn, so parking or reassignment cannot cross that boundary. - Report Hermes Gateway's first remote run request through the shared dispatch hook so the resolved-intent lock is released at the true dispatch boundary. - Revalidate the addressed user's live non-viewer membership and connection-management authority for every intent mutation, including OAuth completion. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` - Result: 176 tests passed. - `pnpm build` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-stale-queue-invalidation.test.ts` (32 passed; includes non-process dispatch lock-release coverage) - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/connection-intents-service.test.ts -t "addressed-user mutation"` (1 passed) - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/tool-access-service.test.ts -t "binds OAuth callback completion to the initiating board session"` (1 passed) - `pnpm --filter @paperclipai/hermes-paperclip-adapter test -- src/gateway/server/execute.test.ts` (23 passed; includes dispatch-hook ordering and exactly-once coverage) - `pnpm --filter @paperclipai/hermes-paperclip-adapter typecheck` ## Risks - A malformed intent could create an unusable operator request. - Validators and company checks reject invalid or cross-company requests. - The final continuation gate holds the issue row lock through adapter preparation until process or remote dispatch; later operator changes use the normal active-run interruption path. - The change does not add a database migration. > 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 linked the public source pull request with `Refs #` - [x] I have not referenced internal or instance-local Paperclip issues or links - [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>
This commit is contained in:
1 parent
fcb2e99e8f
commit
b3343dbd64
96 files changed
+8695
-971
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
CONNECTION_RUNTIME_TOOL_NAMES,
|
||||
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
} from "./connection-intent-guidance.js";
|
||||
|
||||
describe("connection intent agent guidance", () => {
|
||||
it.each([
|
||||
[
|
||||
"explicit connect request",
|
||||
"explicitly asks to connect",
|
||||
"connections_search",
|
||||
],
|
||||
[
|
||||
"implicit service dependency",
|
||||
"implicitly depends on that service",
|
||||
"connections_search",
|
||||
],
|
||||
[
|
||||
"already-ready service",
|
||||
"returns `ready`",
|
||||
"do not create a connection intent",
|
||||
],
|
||||
[
|
||||
"available service",
|
||||
"returns `available` or `needs_user_action`",
|
||||
"connection_request",
|
||||
],
|
||||
[
|
||||
"unavailable service",
|
||||
"returns `unavailable`",
|
||||
"do not call `connection_request`",
|
||||
],
|
||||
["arbitrary MCP URL", "arbitrary MCP URLs", "Do not use connection tools"],
|
||||
[
|
||||
"pending user action",
|
||||
"returns `needs_user_action`",
|
||||
"end the run in a waiting posture",
|
||||
],
|
||||
[
|
||||
"continuation run",
|
||||
"On a continuation run",
|
||||
"instead of requesting it again",
|
||||
],
|
||||
])("gives an explicit instruction for %s", (_scenario, trigger, action) => {
|
||||
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain(trigger);
|
||||
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain(action);
|
||||
});
|
||||
|
||||
it("names the canonical tools without embedding secrets or authorization URLs", () => {
|
||||
expect(CONNECTION_RUNTIME_TOOL_NAMES).toEqual([
|
||||
"connections_search",
|
||||
"connection_request",
|
||||
]);
|
||||
expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(
|
||||
/bearer|credential value|https?:\/\//i,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps MCP descriptions aligned with the guidance decision points", () => {
|
||||
expect(CONNECTIONS_SEARCH_TOOL_DESCRIPTION).toContain(
|
||||
"usable access is uncertain",
|
||||
);
|
||||
expect(CONNECTIONS_SEARCH_TOOL_DESCRIPTION).toContain("arbitrary MCP URLs");
|
||||
expect(CONNECTION_REQUEST_TOOL_DESCRIPTION).toContain(
|
||||
"available or needs_user_action",
|
||||
);
|
||||
expect(CONNECTION_REQUEST_TOOL_DESCRIPTION).toContain("end the run");
|
||||
expect(CONNECTION_REQUEST_TOOL_DESCRIPTION).toContain("without retrying");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Canonical instructions for the run-scoped connection tools.
|
||||
*
|
||||
* Keep this text provider-neutral and free of run identity, credentials, URLs,
|
||||
* or bearer tokens: it is reused in prompts, adapter descriptors, CLI help,
|
||||
* environment delivery, and MCP tool descriptions.
|
||||
*/
|
||||
export const CONNECTION_INTENT_AGENT_GUIDANCE = [
|
||||
"Connection tools:",
|
||||
"- When work requires a known external service and usable access is uncertain, call `connections_search` with the service name or capability.",
|
||||
"- This applies both when the user explicitly asks to connect a service and when the requested work implicitly depends on that service.",
|
||||
"- If search returns `ready`, use the installed connection; do not create a connection intent.",
|
||||
"- If search returns `available` or `needs_user_action`, call `connection_request` with the returned service slug.",
|
||||
"- If search returns `unavailable`, explain that the service is unavailable and do not call `connection_request`.",
|
||||
"- If `connection_request` returns `needs_user_action`, end the run in a waiting posture. Do not retry the request, ask for credentials in comments, or claim access.",
|
||||
"- Do not use connection tools for arbitrary MCP URLs, unsupported services, or work that does not require an external service.",
|
||||
"- On a continuation run after connection setup, use the newly installed connection instead of requesting it again.",
|
||||
].join("\n");
|
||||
|
||||
export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = [
|
||||
"Search Paperclip's known connectable services and report this run's agent-relative access state.",
|
||||
"Use it when work requires a known external service and usable access is uncertain; do not use it for arbitrary MCP URLs or unrelated work.",
|
||||
].join(" ");
|
||||
|
||||
export const CONNECTION_REQUEST_TOOL_DESCRIPTION = [
|
||||
"Request access to a known connectable service for this run's agent from the responsible user.",
|
||||
"Call it only with a slug returned as available or needs_user_action by connections_search; if user action is needed, end the run without retrying or asking for credentials in comments.",
|
||||
].join(" ");
|
||||
|
||||
export const CONNECTION_RUNTIME_TOOL_NAMES = [
|
||||
"connections_search",
|
||||
"connection_request",
|
||||
] as const;
|
||||
@@ -265,6 +265,7 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
|
||||
"request_confirmation",
|
||||
"request_checkbox_confirmation",
|
||||
"request_item_verdicts",
|
||||
"connection_intent",
|
||||
] as const;
|
||||
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
|
||||
export {
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
CONNECTION_RUNTIME_TOOL_NAMES,
|
||||
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
} from "./connection-intent-guidance.js";
|
||||
export {
|
||||
nativeFinalizationResultSchema,
|
||||
nativeFinalizationResultV1Schema,
|
||||
@@ -37,6 +43,27 @@ export {
|
||||
|
||||
export { decisionEffectTargetIssueIds } from "./types/decision.js";
|
||||
|
||||
export type {
|
||||
ConnectionAvailabilityState,
|
||||
ConnectionSearchResultItem,
|
||||
ConnectionsSearchResult,
|
||||
ConnectionRequestResult,
|
||||
ConnectionIntentSetupOptions,
|
||||
CompleteConnectionIntentInput,
|
||||
DeclineConnectionIntentInput,
|
||||
} from "./types/connection-intent.js";
|
||||
|
||||
export {
|
||||
connectionsSearchInputSchema,
|
||||
connectionRequestInputSchema,
|
||||
completeConnectionIntentSchema,
|
||||
declineConnectionIntentSchema,
|
||||
type ConnectionsSearchInput,
|
||||
type ConnectionRequestInput,
|
||||
type CompleteConnectionIntent,
|
||||
type DeclineConnectionIntent,
|
||||
} from "./validators/connection-intent.js";
|
||||
|
||||
export type {
|
||||
DecisionEffectStaleness,
|
||||
DecisionOptionStyle,
|
||||
@@ -262,16 +289,30 @@ export {
|
||||
} from "./trust-policy.js";
|
||||
export {
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
CONNECTABLE_APP_SLUGS,
|
||||
DEFAULT_OWNERSHIP_AVAILABILITY,
|
||||
appAcceptsCustomerOAuthClient,
|
||||
appSupportsCatalogSetup,
|
||||
appSupportsAutomaticOAuth,
|
||||
connectionMethodAcceptsCustomerOAuthClient,
|
||||
connectionMethodRequiresConfiguration,
|
||||
connectionMethodSupportsCatalogSetup,
|
||||
connectionMethodSupportsAutomaticOAuth,
|
||||
credentialConfigPath,
|
||||
getAppDefinitionForUrl,
|
||||
getAvailableConnectionMethod,
|
||||
getAvailableConnectionMethods,
|
||||
getConnectableAppDefinition,
|
||||
isConnectableAppSlug,
|
||||
recommendedDefaultsForApp,
|
||||
resolveConnectionMethodServerUrl,
|
||||
} from "./app-definitions.js";
|
||||
export { APP_DEFINITIONS } from "./app-definitions.generated.js";
|
||||
export {
|
||||
BLOCKED_MCP_PROVIDERS,
|
||||
SELF_SERVE_MCP_CANDIDATES,
|
||||
SELF_SERVE_MCP_RESEARCH,
|
||||
} from "./self-serve-mcp-research.js";
|
||||
export * from "./validators/status-card.js";
|
||||
export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js";
|
||||
export {
|
||||
@@ -1108,6 +1149,9 @@ export type {
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestConfirmationConnectionAuthorizationPayload,
|
||||
ConnectionIntentPhase,
|
||||
ConnectionIntentPayload,
|
||||
ConnectionIntentResult,
|
||||
RequestConfirmationSecretProposalPayload,
|
||||
RequestConfirmationSecretProposalResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
@@ -1130,6 +1174,7 @@ export type {
|
||||
RequestConfirmationInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestItemVerdictsInteraction,
|
||||
ConnectionIntentInteraction,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionPayload,
|
||||
IssueThreadInteractionResult,
|
||||
@@ -1304,6 +1349,8 @@ export type {
|
||||
RejectSecretProposalInput,
|
||||
ConnectToolAppAuthChallenge,
|
||||
ConnectToolAppResult,
|
||||
ToolAppMetadataPreflightAttempt,
|
||||
ToolAppMetadataPreflightResult,
|
||||
ToolOAuthClientRegistrationSource,
|
||||
ToolOAuthStartResult,
|
||||
ToolActionRequest,
|
||||
@@ -1870,6 +1917,9 @@ export {
|
||||
issueThreadInteractionStatusSchema,
|
||||
issueThreadInteractionKindSchema,
|
||||
issueThreadInteractionContinuationPolicySchema,
|
||||
connectionIntentPhaseSchema,
|
||||
connectionIntentPayloadSchema,
|
||||
connectionIntentResultSchema,
|
||||
suggestedTaskDraftSchema,
|
||||
suggestTasksPayloadSchema,
|
||||
suggestTasksResultCreatedTaskSchema,
|
||||
@@ -2097,6 +2147,8 @@ export {
|
||||
reconnectToolAppSchema,
|
||||
createToolApplicationSchema,
|
||||
finishToolAppSchema,
|
||||
finalizeOAuthAccessSchema,
|
||||
startToolOAuthSchema,
|
||||
updateToolApplicationSchema,
|
||||
createToolConnectionSchema,
|
||||
createToolMcpGatewaySchema,
|
||||
@@ -2159,6 +2211,8 @@ export {
|
||||
type CreateToolActionRequest,
|
||||
type CreateToolApplication,
|
||||
type FinishToolApp,
|
||||
type FinalizeOAuthAccess,
|
||||
type StartToolOAuth,
|
||||
type UpdateToolApplication,
|
||||
type CreateToolConnection,
|
||||
type CreateToolMcpGateway,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ConnectionIntentInteraction } from "./issue.js";
|
||||
import type { ToolConnection } from "./tool-access.js";
|
||||
|
||||
export type ConnectionAvailabilityState =
|
||||
| "ready"
|
||||
| "needs_user_action"
|
||||
| "available"
|
||||
| "unavailable";
|
||||
|
||||
export interface ConnectionSearchResultItem {
|
||||
service: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
methods: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
auth: "oauth" | "api_key" | "none";
|
||||
}>;
|
||||
state: ConnectionAvailabilityState;
|
||||
connectionId: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectionsSearchResult {
|
||||
version: 1;
|
||||
query: string;
|
||||
results: ConnectionSearchResultItem[];
|
||||
}
|
||||
|
||||
export interface ConnectionRequestResult {
|
||||
version: 1;
|
||||
service: string;
|
||||
state: "ready" | "needs_user_action";
|
||||
connectionId: string | null;
|
||||
interactionId: string | null;
|
||||
instruction: string;
|
||||
}
|
||||
|
||||
export interface ConnectionIntentSetupOptions {
|
||||
version: 1;
|
||||
interaction: ConnectionIntentInteraction;
|
||||
service: ConnectionSearchResultItem;
|
||||
existingConnections: ToolConnection[];
|
||||
requestedAgentId: string;
|
||||
}
|
||||
|
||||
export interface CompleteConnectionIntentInput {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export interface DeclineConnectionIntentInput {
|
||||
reason?: string;
|
||||
}
|
||||
@@ -7,6 +7,15 @@ export {
|
||||
type NativeRuntimeMode,
|
||||
type NativeRunTerminalState,
|
||||
} from "./native-finalization.js";
|
||||
export type {
|
||||
ConnectionAvailabilityState,
|
||||
ConnectionSearchResultItem,
|
||||
ConnectionsSearchResult,
|
||||
ConnectionRequestResult,
|
||||
ConnectionIntentSetupOptions,
|
||||
CompleteConnectionIntentInput,
|
||||
DeclineConnectionIntentInput,
|
||||
} from "./connection-intent.js";
|
||||
export type {
|
||||
Company,
|
||||
InteractionResolverGovernance,
|
||||
@@ -452,6 +461,8 @@ export type {
|
||||
ToolAccessSelector,
|
||||
ConnectToolAppAuthChallenge,
|
||||
ConnectToolAppResult,
|
||||
ToolAppMetadataPreflightAttempt,
|
||||
ToolAppMetadataPreflightResult,
|
||||
FinishToolAppResult,
|
||||
ToolOAuthClientRegistrationSource,
|
||||
ToolOAuthStartResult,
|
||||
@@ -721,6 +732,9 @@ export type {
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestConfirmationConnectionAuthorizationPayload,
|
||||
ConnectionIntentPhase,
|
||||
ConnectionIntentPayload,
|
||||
ConnectionIntentResult,
|
||||
RequestConfirmationSecretProposalPayload,
|
||||
RequestConfirmationSecretProposalResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
@@ -743,6 +757,7 @@ export type {
|
||||
RequestConfirmationInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestItemVerdictsInteraction,
|
||||
ConnectionIntentInteraction,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionPayload,
|
||||
IssueThreadInteractionResult,
|
||||
|
||||
@@ -1285,6 +1285,32 @@ export interface RequestConfirmationConnectionAuthorizationPayload {
|
||||
requestingAgentName?: string | null;
|
||||
}
|
||||
|
||||
export type ConnectionIntentPhase = "requested" | "authorizing" | "needs_retry";
|
||||
|
||||
/**
|
||||
* Server-authored request for a responsible user to connect a first-party app.
|
||||
* It intentionally contains presentation-safe identifiers only; credentials and
|
||||
* authorization URLs are returned solely from addressed board endpoints.
|
||||
*/
|
||||
export interface ConnectionIntentPayload {
|
||||
version: 1;
|
||||
serviceSlug: string;
|
||||
serviceName: string;
|
||||
serviceLogoUrl?: string | null;
|
||||
serviceDarkLogoUrl?: string | null;
|
||||
requestingAgentId: string;
|
||||
requestingAgentName: string;
|
||||
phase: ConnectionIntentPhase;
|
||||
}
|
||||
|
||||
export interface ConnectionIntentResult {
|
||||
version: 1;
|
||||
outcome: "connected" | "declined" | "superseded" | "expired";
|
||||
connectionId?: string | null;
|
||||
reason?: string | null;
|
||||
supersededByInteractionId?: string | null;
|
||||
}
|
||||
|
||||
export interface RequestConfirmationPayload {
|
||||
version: 1;
|
||||
prompt: string;
|
||||
@@ -1463,26 +1489,35 @@ export interface RequestItemVerdictsInteraction extends IssueThreadInteractionBa
|
||||
result?: RequestItemVerdictsResult | null;
|
||||
}
|
||||
|
||||
export interface ConnectionIntentInteraction extends IssueThreadInteractionBase {
|
||||
kind: "connection_intent";
|
||||
payload: ConnectionIntentPayload;
|
||||
result?: ConnectionIntentResult | null;
|
||||
}
|
||||
|
||||
export type IssueThreadInteraction =
|
||||
| SuggestTasksInteraction
|
||||
| AskUserQuestionsInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction
|
||||
| RequestItemVerdictsInteraction;
|
||||
| RequestItemVerdictsInteraction
|
||||
| ConnectionIntentInteraction;
|
||||
|
||||
export type IssueThreadInteractionPayload =
|
||||
| SuggestTasksPayload
|
||||
| AskUserQuestionsPayload
|
||||
| RequestConfirmationPayload
|
||||
| RequestCheckboxConfirmationPayload
|
||||
| RequestItemVerdictsPayload;
|
||||
| RequestItemVerdictsPayload
|
||||
| ConnectionIntentPayload;
|
||||
|
||||
export type IssueThreadInteractionResult =
|
||||
| SuggestTasksResult
|
||||
| AskUserQuestionsResult
|
||||
| RequestConfirmationResult
|
||||
| RequestCheckboxConfirmationResult
|
||||
| RequestItemVerdictsResult;
|
||||
| RequestItemVerdictsResult
|
||||
| ConnectionIntentResult;
|
||||
|
||||
export interface IssueAttachment {
|
||||
id: string;
|
||||
|
||||
@@ -1080,6 +1080,29 @@ export interface ConnectToolAppResult {
|
||||
auth?: ConnectToolAppAuthChallenge | null;
|
||||
}
|
||||
|
||||
export interface ToolAppMetadataPreflightAttempt {
|
||||
kind: "endpoint" | "oauth_metadata";
|
||||
url: string;
|
||||
status: number;
|
||||
ok: boolean;
|
||||
contentType: string | null;
|
||||
}
|
||||
|
||||
/** Credential-free inspection of a curated app's public MCP/OAuth metadata. */
|
||||
export interface ToolAppMetadataPreflightResult {
|
||||
galleryKey: string;
|
||||
methodKey: string;
|
||||
serverUrl: string;
|
||||
endpointReachable: boolean;
|
||||
oauth: {
|
||||
metadataFound: boolean;
|
||||
registrationAdvertised: boolean;
|
||||
clientIdMetadataDocumentSupported: boolean;
|
||||
} | null;
|
||||
attempts: ToolAppMetadataPreflightAttempt[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface ToolOAuthStartResult {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
connectionIntentPayloadSchema,
|
||||
connectionIntentResultSchema,
|
||||
connectionRequestInputSchema,
|
||||
connectionsSearchInputSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
} from "../index.js";
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("connection intent contracts", () => {
|
||||
it("accepts the versioned server-authored payload and safe phases", () => {
|
||||
expect(connectionIntentPayloadSchema.parse({
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
serviceLogoUrl: "https://example.test/notion.svg",
|
||||
requestingAgentId: agentId,
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "requested",
|
||||
})).toMatchObject({ serviceSlug: "notion", phase: "requested" });
|
||||
});
|
||||
|
||||
it("rejects credentials, authorization URLs, and unknown phases in thread payloads", () => {
|
||||
const base = {
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
requestingAgentId: agentId,
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "requested",
|
||||
};
|
||||
expect(connectionIntentPayloadSchema.safeParse({ ...base, credential: "secret" }).success).toBe(false);
|
||||
expect(connectionIntentPayloadSchema.safeParse({ ...base, authorizationUrl: "https://oauth.test" }).success).toBe(false);
|
||||
expect(connectionIntentPayloadSchema.safeParse({ ...base, phase: "connected" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates terminal outcomes independently from payload state", () => {
|
||||
expect(connectionIntentResultSchema.parse({
|
||||
version: 1,
|
||||
outcome: "connected",
|
||||
connectionId: "22222222-2222-4222-8222-222222222222",
|
||||
}).outcome).toBe("connected");
|
||||
expect(connectionIntentResultSchema.parse({ version: 1, outcome: "declined" }).outcome).toBe("declined");
|
||||
});
|
||||
|
||||
it("keeps generic interaction creation closed to the server-owned kind", () => {
|
||||
expect(createIssueThreadInteractionSchema.safeParse({
|
||||
kind: "connection_intent",
|
||||
payload: {
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
requestingAgentId: agentId,
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "requested",
|
||||
},
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes canonical search and request tool inputs", () => {
|
||||
expect(connectionsSearchInputSchema.parse({ query: " notion " })).toEqual({ query: "notion" });
|
||||
expect(connectionRequestInputSchema.parse({ service: " notion " })).toEqual({ service: "notion" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const connectionsSearchInputSchema = z.object({
|
||||
query: z.string().trim().max(200).default(""),
|
||||
}).strict();
|
||||
|
||||
export const connectionRequestInputSchema = z.object({
|
||||
service: z.string().trim().min(1).max(120),
|
||||
}).strict();
|
||||
|
||||
export const completeConnectionIntentSchema = z.object({
|
||||
connectionId: z.string().guid(),
|
||||
}).strict();
|
||||
|
||||
export const declineConnectionIntentSchema = z.object({
|
||||
reason: z.string().trim().max(4000).optional(),
|
||||
}).strict();
|
||||
|
||||
export type ConnectionsSearchInput = z.infer<typeof connectionsSearchInputSchema>;
|
||||
export type ConnectionRequestInput = z.infer<typeof connectionRequestInputSchema>;
|
||||
export type CompleteConnectionIntent = z.infer<typeof completeConnectionIntentSchema>;
|
||||
export type DeclineConnectionIntent = z.infer<typeof declineConnectionIntentSchema>;
|
||||
@@ -1,3 +1,14 @@
|
||||
export {
|
||||
connectionsSearchInputSchema,
|
||||
connectionRequestInputSchema,
|
||||
completeConnectionIntentSchema,
|
||||
declineConnectionIntentSchema,
|
||||
type ConnectionsSearchInput,
|
||||
type ConnectionRequestInput,
|
||||
type CompleteConnectionIntent,
|
||||
type DeclineConnectionIntent,
|
||||
} from "./connection-intent.js";
|
||||
|
||||
export {
|
||||
nativeFinalizationResultSchema,
|
||||
nativeFinalizationResultV1Schema,
|
||||
@@ -440,6 +451,9 @@ export {
|
||||
issueThreadInteractionResolverPolicyProvenanceSchema,
|
||||
issueThreadInteractionEffectiveResolverPolicySourceSchema,
|
||||
issueThreadInteractionContinuationPolicySchema,
|
||||
connectionIntentPhaseSchema,
|
||||
connectionIntentPayloadSchema,
|
||||
connectionIntentResultSchema,
|
||||
suggestedTaskDraftSchema,
|
||||
suggestTasksPayloadSchema,
|
||||
suggestTasksResultCreatedTaskSchema,
|
||||
@@ -884,6 +898,8 @@ export {
|
||||
type GenericMcpOAuthClient,
|
||||
reconnectToolAppSchema,
|
||||
finishToolAppSchema,
|
||||
finalizeOAuthAccessSchema,
|
||||
startToolOAuthSchema,
|
||||
updateToolApplicationSchema,
|
||||
createToolConnectionSchema,
|
||||
createToolMcpGatewaySchema,
|
||||
@@ -917,6 +933,8 @@ export {
|
||||
type ReconnectToolApp,
|
||||
type CreateToolApplication,
|
||||
type FinishToolApp,
|
||||
type FinalizeOAuthAccess,
|
||||
type StartToolOAuth,
|
||||
type UpdateToolApplication,
|
||||
type CreateToolConnection,
|
||||
type CreateToolMcpGateway,
|
||||
|
||||
@@ -756,6 +756,50 @@ export const issueThreadInteractionContinuationPolicySchema = z.enum(
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
);
|
||||
|
||||
export const connectionIntentPhaseSchema = z.enum(["requested", "authorizing", "needs_retry"]);
|
||||
const connectionIntentBrandAssetSchema = z.string().max(2048).refine((value) => {
|
||||
if (/^\/brands\/apps\/[a-z0-9][a-z0-9._-]*\.(?:svg|png)$/i.test(value)) return true;
|
||||
try {
|
||||
return new URL(value).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Connection intent brand assets must be HTTPS URLs or local app brand paths");
|
||||
|
||||
export const connectionIntentPayloadSchema = z.object({
|
||||
version: z.literal(1),
|
||||
serviceSlug: z.string().trim().min(1).max(120),
|
||||
serviceName: z.string().trim().min(1).max(160),
|
||||
serviceLogoUrl: connectionIntentBrandAssetSchema.nullable().optional(),
|
||||
serviceDarkLogoUrl: connectionIntentBrandAssetSchema.nullable().optional(),
|
||||
requestingAgentId: z.string().guid(),
|
||||
requestingAgentName: z.string().trim().min(1).max(160),
|
||||
phase: connectionIntentPhaseSchema,
|
||||
}).strict();
|
||||
|
||||
export const connectionIntentResultSchema = z.object({
|
||||
version: z.literal(1),
|
||||
outcome: z.enum(["connected", "declined", "superseded", "expired"]),
|
||||
connectionId: z.string().guid().nullable().optional(),
|
||||
reason: z.string().trim().max(4000).nullable().optional(),
|
||||
supersededByInteractionId: z.string().guid().nullable().optional(),
|
||||
}).strict().superRefine((value, ctx) => {
|
||||
if (value.outcome === "connected" && !value.connectionId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["connectionId"],
|
||||
message: "Connected intents require a connection id",
|
||||
});
|
||||
}
|
||||
if (value.outcome === "superseded" && !value.supersededByInteractionId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["supersededByInteractionId"],
|
||||
message: "Superseded intents require the replacement interaction id",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const issueDocumentKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@@ -72,6 +72,17 @@ describe("tool access validators", () => {
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only UUID connection request links during app setup", () => {
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
interactionId: "11111111-1111-4111-8111-111111111111",
|
||||
}).success).toBe(true);
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
interactionId: "not-an-interaction",
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
// PAP-17087: the guided generic flow and paste-config both reach the connect
|
||||
// endpoint, so unsafe header names/values are rejected once at this boundary.
|
||||
it("accepts generic advanced-authentication input for a pasted URL", () => {
|
||||
@@ -91,6 +102,11 @@ describe("tool access validators", () => {
|
||||
oauthClient: { clientId: "client-abc", clientSecret: "shhh" },
|
||||
});
|
||||
expect(manualClient.success).toBe(true);
|
||||
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "asana",
|
||||
oauthClient: { clientId: "customer-client", clientSecret: "customer-secret" },
|
||||
}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects header credentials Paperclip refuses to send", () => {
|
||||
@@ -123,7 +139,7 @@ describe("tool access validators", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps generic advanced authentication off the curated gallery path", () => {
|
||||
it("keeps generic auth-mode selection off curated apps while allowing owned OAuth clients", () => {
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
authMode: "bearer",
|
||||
@@ -131,7 +147,7 @@ describe("tool access validators", () => {
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
oauthClient: { clientId: "client-abc" },
|
||||
}).success).toBe(false);
|
||||
}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts secret references for connection credentials", () => {
|
||||
|
||||
@@ -364,6 +364,8 @@ export const connectToolAppSchema = z.object({
|
||||
credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)).optional(),
|
||||
configValues: z.record(z.string().trim().min(1).max(200), z.unknown()).optional(),
|
||||
applicationId: z.string().guid().optional(),
|
||||
/** Pending connection request this setup should resolve after authorization. */
|
||||
interactionId: z.string().uuid().optional(),
|
||||
authMode: genericMcpAuthModeSchema.optional(),
|
||||
oauthClient: genericMcpOAuthClientSchema.optional(),
|
||||
/**
|
||||
@@ -383,13 +385,6 @@ export const connectToolAppSchema = z.object({
|
||||
message: "Authentication mode selection applies to a pasted URL, not a gallery app",
|
||||
});
|
||||
}
|
||||
if (value.oauthClient && value.galleryKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["oauthClient"],
|
||||
message: "Preregistered OAuth client credentials apply to a pasted URL, not a gallery app",
|
||||
});
|
||||
}
|
||||
}).refine(
|
||||
(value) => Boolean(value.galleryKey) !== Boolean(value.link),
|
||||
{ message: "Provide exactly one of galleryKey or link" },
|
||||
@@ -415,6 +410,24 @@ export const finishToolAppSchema = z.object({
|
||||
|
||||
export type FinishToolApp = z.infer<typeof finishToolAppSchema>;
|
||||
|
||||
/**
|
||||
* Legacy promotion boundary retained for connection-intent flows. Interactive
|
||||
* app setup chooses identity before OAuth and writes the token directly to that
|
||||
* credential scope.
|
||||
*/
|
||||
export const finalizeOAuthAccessSchema = z.object({
|
||||
grantKind: connectionGrantKindSchema,
|
||||
}).strict();
|
||||
|
||||
export type FinalizeOAuthAccess = z.infer<typeof finalizeOAuthAccessSchema>;
|
||||
|
||||
export const startToolOAuthSchema = z.object({
|
||||
asCurrentUser: z.boolean().optional(),
|
||||
interactionId: z.string().uuid().optional(),
|
||||
}).strict().default({});
|
||||
|
||||
export type StartToolOAuth = z.infer<typeof startToolOAuthSchema>;
|
||||
|
||||
export const upsertToolCatalogEntrySchema = z.object({
|
||||
applicationId: z.string().guid(),
|
||||
connectionId: z.string().guid(),
|
||||
|
||||
Reference in new issue
Block a user