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:
parent
fcb2e99e8f
commit
b3343dbd64
|
|
@ -0,0 +1,13 @@
|
|||
name = "codemod-runner"
|
||||
description = "Writes and runs codemod scripts that replace hardcoded visual values with token references in ui/src/index.css. Use for Phase 2 of the design simplification run — mechanical refactors only."
|
||||
developer_instructions = """
|
||||
You perform mechanical refactors via scripts, never hand-edits. Follow DESIGN.md at the repo root.
|
||||
|
||||
Rules:
|
||||
|
||||
- The token destination is ui/src/index.css (Tailwind v4), optionally a tokens.css imported by it. NEVER create a parallel token source. Tokens that must be runtime-tunable go in a NON-inline block — `@theme inline` bakes literals at build time.
|
||||
- Where a hardcoded value EXACTLY matches an existing token, replace it with that token reference. Otherwise extract the value into a new token VERBATIM — no normalizing, rounding, or inventing a scale. Ugly values stay ugly.
|
||||
- Every rewrite happens through a codemod script committed to scripts/ before it is run. Scripts must be idempotent and reviewable.
|
||||
- Third-party style overrides that cannot use tokens go on a documented allowlist in the token source, each with an inline comment saying why.
|
||||
- Verify after every script run: rg gates (zero hardcoded hex, zero arbitrary px/bracket values in ui/src/components/** and ui/src/pages/** outside the allowlist), pnpm typecheck, and the Storybook snapshot suite. Snapshots must match the Phase 0 baseline exactly.
|
||||
- If a replacement cannot be made without visual change, skip it and record it in doc/design/TOKEN-AUDIT.md under "Needs human decision"."""
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
name = "token-auditor"
|
||||
description = "Scans ui/src/ for hardcoded visual values, duplicate components, and shadcn replacement candidates; produces doc/design/TOKEN-AUDIT.md and doc/design/COMPONENT-INVENTORY.md. Read-only on source — never modifies component files. Use for Phase 1 of the design simplification run."
|
||||
developer_instructions = """
|
||||
You inventory design-system debt in this repository. Follow DESIGN.md at the repo root; read doc/design/PRIOR-ART.md first — a previous audit found only 6 of ~220 drift sites were exact-value-mappable to existing tokens, so expect most hardcoded values to need new verbatim tokens.
|
||||
|
||||
Your outputs (written to the repo root):
|
||||
|
||||
1. TOKEN-AUDIT.md — every hardcoded color/spacing/radius/type/shadow value in ui/src/, with frequency, file locations, and near-duplicate clusters (e.g. 13/14/15px used interchangeably). For each value, note whether it EXACTLY matches one of the ~80 existing tokens in ui/src/index.css (semantic / brand / domain tiers — see DESIGN.md). Flag clusters for human review; never merge or normalize them. Include a "Needs human decision" section.
|
||||
|
||||
2. COMPONENT-INVENTORY.md — all components under ui/src/components/ (24 primitives in ui/, ~277 feature components), their variants, and suspected duplicates with evidence (similar props, similar rendered output, copy-pasted origins). Include a "shadcn candidates" section: (a) custom components duplicating an available shadcn primitive, (b) installed shadcn components that drifted from the registry (npx shadcn@latest diff where available), (c) raw Radix/plain elements where an installed shadcn wrapper exists. For each, state the recommended replacement and expected visual impact. Recommendations only — merges and swaps happen in later human-approved runs, never this one.
|
||||
|
||||
Never modify source files. Bash access is for read-only commands (rg, find, npx shadcn diff) and writing the two report files only."""
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { Command } from "commander";
|
||||
import {
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
connectionRequestInputSchema,
|
||||
connectionsSearchInputSchema,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
interface RuntimeConnectionOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
async function callRuntimeConnectionTool(
|
||||
endpointEnv: "PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL" | "PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL",
|
||||
body: unknown,
|
||||
) {
|
||||
const endpoint = process.env[endpointEnv]?.trim();
|
||||
const token = process.env.PAPERCLIP_RUNTIME_TOOLS_TOKEN?.trim();
|
||||
if (!endpoint || !token) {
|
||||
throw new Error("This command requires the runtime connection environment from an active heartbeat run");
|
||||
}
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
const parsed = text ? JSON.parse(text) as unknown : null;
|
||||
if (!response.ok) {
|
||||
const message = parsed && typeof parsed === "object" && "error" in parsed
|
||||
? String((parsed as { error: unknown }).error)
|
||||
: `Runtime connection request failed with ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function writeResult(value: unknown, options: RuntimeConnectionOptions) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, options.json ? 2 : 0)}\n`);
|
||||
}
|
||||
|
||||
export function registerConnectionIntentCommands(program: Command) {
|
||||
const connections = program
|
||||
.command("connections")
|
||||
.description("Search or request connections from an active heartbeat run")
|
||||
.addHelpText("after", `\n${CONNECTION_INTENT_AGENT_GUIDANCE}\n`);
|
||||
|
||||
connections
|
||||
.command("search")
|
||||
.argument("[query]", "Service name or capability")
|
||||
.option("--json", "Print formatted JSON")
|
||||
.action(async (query: string | undefined, options: RuntimeConnectionOptions) => {
|
||||
const input = connectionsSearchInputSchema.parse({ query: query ?? "" });
|
||||
writeResult(await callRuntimeConnectionTool(
|
||||
"PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL",
|
||||
input,
|
||||
), options);
|
||||
});
|
||||
|
||||
connections
|
||||
.command("request")
|
||||
.argument("<service>", "Connectable service slug")
|
||||
.option("--json", "Print formatted JSON")
|
||||
.action(async (service: string, options: RuntimeConnectionOptions) => {
|
||||
const input = connectionRequestInputSchema.parse({ service });
|
||||
writeResult(await callRuntimeConnectionTool(
|
||||
"PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL",
|
||||
input,
|
||||
), options);
|
||||
});
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import { installCommand } from "./commands/install.js";
|
|||
import { uninstallCommand } from "./commands/uninstall.js";
|
||||
import { updateCommand } from "./commands/update.js";
|
||||
import { registerServiceCommands } from "./commands/service.js";
|
||||
import { registerConnectionIntentCommands } from "./commands/client/connections.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
|
|
@ -209,6 +210,7 @@ heartbeat
|
|||
|
||||
registerContextCommands(program);
|
||||
registerConnectCommand(program);
|
||||
registerConnectionIntentCommands(program);
|
||||
registerCompanyCommands(program);
|
||||
registerIssueCommands(program);
|
||||
registerAgentCommands(program);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
# Connection intents
|
||||
|
||||
Connection intents let an agent ask the responsible user for a known service connection without leaving the task thread. The user can reuse an eligible connection or run the normal provider setup in a dialog. A successful resolution grants and installs the connection for the requesting agent, then wakes the task assignee in a fresh run.
|
||||
|
||||
## Shared setup flow
|
||||
|
||||
`ui/src/features/connections/ConnectionSetupFlow.tsx` is the only connection setup implementation. It owns provider selection, method and identity choices, provider fields, validation, OAuth, access, catalog setup, installs, retry states, and completion. It has two presentation hosts:
|
||||
|
||||
- `ui/src/pages/apps/AppsConnect.tsx` supplies full-page routing and breadcrumbs.
|
||||
- `ui/src/features/connections/ConnectionIntentInteractionBody.tsx` supplies the task dialog, intent resolution, query invalidation, and focus return.
|
||||
|
||||
Provider-specific setup must stay in the shared feature and `AppDefinition` metadata. Do not add provider forms or connection mutations to either host.
|
||||
|
||||
## Agent tools
|
||||
|
||||
Every active heartbeat with a responsible user receives two run-bound tools:
|
||||
|
||||
- `connections_search({ query })` searches first-party connectable definitions and returns `ready`, `needs_user_action`, `available`, or `unavailable` from the requesting agent's perspective.
|
||||
- `connection_request({ service })` returns immediately when the service is already usable. Otherwise it creates or reuses a `connection_intent` and instructs the agent to end the run pending continuation.
|
||||
|
||||
Claude and Codex receive the tools through a native managed MCP server. Local/process adapters receive `PAPERCLIP_RUNTIME_TOOLS_*` environment variables and CLI guidance. Cloud, HTTP, gateway, and external adapters receive the typed runtime descriptor in their invocation context; compatible adapters may also project it into their remote environment.
|
||||
|
||||
The equivalent CLI helpers are:
|
||||
|
||||
```sh
|
||||
paperclipai connections search notion
|
||||
paperclipai connections request notion
|
||||
```
|
||||
|
||||
The manually configured Paperclip MCP server also advertises `connections_search` and `connection_request`. Both helper surfaces require the narrow runtime token and fail outside an active heartbeat.
|
||||
|
||||
## Security and lifecycle
|
||||
|
||||
- Company, agent, run, task, and responsible user come only from the signed runtime token and stored heartbeat context.
|
||||
- Tokens are scoped to connection intents, expire after one hour, and are rejected when the heartbeat is no longer running.
|
||||
- The thread payload contains only service identity, requesting-agent identity, and a safe phase. It never contains credentials or authorization URLs.
|
||||
- OAuth state is linked to the interaction. The same-origin callback finalizes the existing connection pipeline, posts only interaction ID/outcome to its opener, and redirects back to the task if there is no opener.
|
||||
- Personal OAuth defaults to the addressed user and creates an explicit delegation to the requesting agent. Reuse and installs are additive.
|
||||
- Task-hosted setup locks install reach to the requesting agent; the store host retains its normal broader access choices.
|
||||
- The intent resolves only after the connection, grant/delegation, profile access, and install succeed. Failures remain pending with `needs_retry`.
|
||||
- Closing the task, a newer run requesting the same service, or a newer human task comment expires the intent and deletes linked OAuth state.
|
||||
- Success and decline wake the assignee once using an interaction-and-status idempotency key and force a fresh continuation session.
|
||||
|
||||
Legacy `request_confirmation.payload.connectionAuthorization` interactions remain readable and resolvable. New agent requests use `connection_intent` exclusively.
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
# Self-Serve MCP Connections Program
|
||||
|
||||
Date: 2026-08-26
|
||||
|
||||
## Outcome
|
||||
|
||||
Paperclip treats a connection method as the capability boundary. A curated MCP method may declare automatic OAuth registration (`dcr`, including CIMD), a customer-owned OAuth client (`customer`), an API key, or a provider-generated MCP URL. Provider tokens and client secrets remain in the instance's encrypted vault. Paperclip ID remains the future broker for `platform_shared` registrations; the self-serve catalog does not depend on it.
|
||||
|
||||
The machine-readable evidence ledger is [`packages/shared/src/self-serve-mcp-research.json`](../../packages/shared/src/self-serve-mcp-research.json). It is the source for the generated app definitions and records the documentation URL, current endpoint, authentication mode, prerequisite, risk tier, and verification date for all 46 researched providers.
|
||||
|
||||
## Platform checklist
|
||||
|
||||
- [x] Replace the Notion-only OAuth allowlist with app-definition capability checks.
|
||||
- [x] Support DCR/CIMD browser sign-in for curated remote MCP methods.
|
||||
- [x] Accept customer-owned OAuth client IDs and secrets only when a method declares `customer` ownership.
|
||||
- [x] Store customer OAuth secrets and provider tokens as encrypted secret references, never inline in connection configuration or API responses.
|
||||
- [x] Keep Paperclip ID limited to explicitly brokered `platform_shared` methods such as Gmail.
|
||||
- [x] Contain curated OAuth scopes to the method's reviewed `scopesHint`; omit scope when the method has no hint and reject caller widening.
|
||||
- [x] Reuse the existing connection setup flow for browser sign-in, customer OAuth apps, API keys, tenant fields, and generated URLs.
|
||||
- [x] Correct the Jira, Cloudinary, Kernel, Resend, ClickHouse, Postman, PagerDuty, Supabase, PlanetScale, and Zapier connection shapes.
|
||||
- [x] Keep G2, Vercel, and Zomato out of the connectable catalog while retaining their evidence and reconsideration criteria.
|
||||
|
||||
## Catalog delivery and branding checklist
|
||||
|
||||
- [x] Derive Browse, setup, reconnect, and additional-account routes from method capabilities instead of a slug switch.
|
||||
- [x] Route automatic OAuth, customer OAuth, API-key, and no-auth methods through `/apps/connect?source=<slug>`; retain Zapier's provider-generated URL path.
|
||||
- [x] Show the instance-provided `availability.reason` for Gmail, Google Sheets, and any future instance-disabled app; remove “Coming soon” from the connectable catalog.
|
||||
- [x] Ship 50 unique local provider marks under `ui/public/brands/apps/`, with no favicon proxy or generated provider imitation.
|
||||
- [x] Record provider, local asset, official source, upstream asset, format, visibility, and dark-variant requirements in `ui/public/brands/apps/manifest.json`.
|
||||
- [x] Preserve local light/dark branding paths through definition regeneration and validate every SVG/PNG during manifest tests.
|
||||
- [x] Reuse `AppLogo` across Browse, setup, success, Connections, details, sidebars, and connection-intent cards; retain its deterministic letter tile only for runtime image failure.
|
||||
- [x] Replace the compact method segment with full-row radio choices that name authentication, mode/region, and when to use each method.
|
||||
- [x] Present warnings, prerequisites, and provider documentation before credentials or consent.
|
||||
- [x] Prevent automatic OAuth from bypassing tenant/extension fields or the customer-owned OAuth alternative; ClickHouse must collect `serviceId`.
|
||||
- [x] Default S4 write and destructive actions to ask-first while retaining Supabase's project-scoped read-only default.
|
||||
- [x] Add an opt-in credential-free metadata preflight. It performs guarded GET requests only and never creates a connection or invokes OAuth registration.
|
||||
|
||||
## Provider rollout checklist
|
||||
|
||||
“Definition” means the reviewed manifest and UI/server setup contract are implemented. “Live proof” requires a provider account and must be completed before production enablement: authorize in a browser, list tools, run one safe read, refresh/reconnect, revoke, and inspect API responses and logs for secret leakage.
|
||||
|
||||
| Provider | Wave | Definition | Live proof | Notes |
|
||||
|---|---:|:---:|:---:|---|
|
||||
| Jira | 1 | [x] | [ ] | Reference DCR/CIMD flow; `https://mcp.atlassian.com/v1/mcp/authv2`. |
|
||||
| Airtable | 1 | [x] | [ ] | Enterprise client allowlisting may apply. |
|
||||
| beehiiv | 1 | [x] | [ ] | Plan controls write capabilities. |
|
||||
| Bitly | 1 | [x] | [ ] | Browser sign-in and API-token methods. |
|
||||
| Candid | 1 | [x] | [ ] | DCR. |
|
||||
| Cloudflare | 1 | [x] | [ ] | Browser sign-in and API-token methods. |
|
||||
| Cloudinary | 1 | [x] | [ ] | Current `/mcp` endpoint, not the captured SSE endpoint. |
|
||||
| Coda | 1 | [x] | [ ] | Browser sign-in and personal token; beta warning. |
|
||||
| Hugging Face | 1 | [x] | [ ] | DCR/CIMD. |
|
||||
| Kernel | 1 | [x] | [ ] | Current `/mcp` endpoint; API-key alternative. |
|
||||
| Local Falcon | 1 | [x] | [ ] | DCR. |
|
||||
| Make | 1 | [x] | [ ] | DCR. |
|
||||
| Manufact | 1 | [x] | [ ] | DCR. |
|
||||
| Miro | 1 | [x] | [ ] | Enterprise client restrictions may apply. |
|
||||
| Netlify | 1 | [x] | [ ] | DCR. |
|
||||
| Notion | 1 | [x] | [ ] | Existing DCR definition hardened by scope containment. |
|
||||
| O'Reilly | 1 | [x] | [ ] | Browser sign-in and token methods. |
|
||||
| PlanetScale | 1 | [x] | [ ] | Database and insights-only methods; optional intended project/branch metadata. |
|
||||
| PostHog | 1 | [x] | [ ] | OAuth and API-key methods retain project pinning. |
|
||||
| Resend | 1 | [x] | [ ] | Current `/mcp` endpoint. |
|
||||
| Sentry | 1 | [x] | [ ] | Existing DCR/CIMD definition enabled. |
|
||||
| TickTick | 1 | [x] | [ ] | DCR. |
|
||||
| Todoist | 1 | [x] | [ ] | DCR. |
|
||||
| Webflow | 1 | [x] | [ ] | Tenant roles constrain site access. |
|
||||
| Wix | 1 | [x] | [ ] | DCR. |
|
||||
| Brex | 2 | [x] | [ ] | Early access/admin prerequisite; S4 warning. |
|
||||
| ClickHouse | 2 | [x] | [ ] | `/clickstack`; required `x-service-id` header. |
|
||||
| Egnyte | 2 | [x] | [ ] | Plan and external-LLM admin prerequisites. |
|
||||
| Embat | 2 | [x] | [ ] | WorkOS DCR/CIMD; pilot because documentation is sparse. |
|
||||
| Mixpanel | 2 | [x] | [ ] | Beta warning. |
|
||||
| Postman | 2 | [x] | [ ] | US OAuth and EU API-key methods for minimal/code/full endpoints. |
|
||||
| Razorpay | 2 | [x] | [ ] | OAuth and key method; S4 financial warning. |
|
||||
| Sanity | 2 | [x] | [ ] | Browser sign-in and token methods. |
|
||||
| Stripe | 2 | [x] | [ ] | OAuth and key method; public-preview/S4 warning. |
|
||||
| Supabase | 2 | [x] | [ ] | Project required, read-only default, optional feature groups, production-data warning. |
|
||||
| Ticket Tailor | 2 | [x] | [ ] | Provider-hosted authorization may request an API key. |
|
||||
| Asana | 3 | [x] | [ ] | Customer-owned OAuth app; DCR intentionally disabled. |
|
||||
| Box | 3 | [x] | [ ] | Customer-owned OAuth app and Box admin prerequisite. |
|
||||
| Mem0 | 3 | [x] | [ ] | Bearer API key. |
|
||||
| PagerDuty | 3 | [x] | [ ] | API token; separate US and EU methods. |
|
||||
| Similarweb | 3 | [x] | [ ] | `api-key` header and API-enabled subscription. |
|
||||
| Xero | 3 | [x] | [ ] | Customer-owned OAuth app; confirm remote endpoint and data-use terms during live proof. |
|
||||
| Zapier | 3 | [x] | [ ] | Existing generated-URL flow; never substitutes a static shared endpoint. |
|
||||
| G2 | Blocked | [x] | n/a | Reconsider after a customer-created client works without G2 coordination. |
|
||||
| Vercel | Blocked | [x] | n/a | Reconsider when reviewed-client approval is removed or Paperclip is approved. |
|
||||
| Zomato | Blocked | [x] | n/a | Reconsider when third-party clients and unallowlisted redirect URIs are supported. |
|
||||
|
||||
## Automated acceptance
|
||||
|
||||
- [x] Manifest tests assert 46 researched entries, 43 self-serve candidates, three blocked providers, unique slugs, HTTPS documentation/endpoints, authentication mode, prerequisite, risk tier, and verification date.
|
||||
- [x] Definition tests cover corrected endpoints, ClickHouse's service header, Postman's six modes, Supabase's read-only default, and customer-owned OAuth ownership.
|
||||
- [x] Server tests cover DCR reuse, CIMD/DCR fixtures, customer OAuth secret storage, scope containment, token refresh/revocation, SSRF rejection, company isolation, and failed-setup cleanup.
|
||||
- [x] UI tests cover automatic OAuth, customer-owned OAuth credentials, API keys, generated URLs, tenant fields, prerequisites, and unavailable-provider policy.
|
||||
- [x] Branding tests require exactly 50 visible, unique, local, decodable marks and verify dark variants and the failed-image fallback.
|
||||
- [x] Routing tests prove all 43 researched self-serve candidates are actionable and all three blocked providers remain absent.
|
||||
- [x] Metadata preflight tests prove Jira discovery sends no credential, makes no registration request, and treats an authentication challenge as endpoint reachability.
|
||||
- [x] `pnpm check:token-gates` is required for the UI change.
|
||||
- [ ] Complete the account-bound live proof column above before declaring each provider production-verified.
|
||||
|
||||
## Remaining external verification
|
||||
|
||||
The code paths and catalog definitions are complete. The unchecked work is deliberately account-bound and cannot be inferred from public metadata alone:
|
||||
|
||||
1. Start with Jira, then complete Wave 1 automatic OAuth providers. For each provider: authorize, list tools, run one safe read, reconnect/refresh, revoke, and inspect API responses and server logs for secrets.
|
||||
2. Validate customer-created OAuth applications end to end for Asana, Box, and Xero, including redirect URI configuration and tenant-admin prerequisites.
|
||||
3. Validate restricted-key flows for Mem0, PagerDuty, Similarweb, and every API-key alternative; confirm the manifest's exact header/query placement.
|
||||
4. Exercise all six Postman modes, both PagerDuty regions, PlanetScale database/insights modes, and Supabase's project-scoped read-only default against real accounts.
|
||||
5. Confirm Xero's endpoint and applicable AI/data-use terms before marking it verified. Pilot Embat before removing its sparse-documentation warning.
|
||||
6. Keep preview, paid-plan, early-access, and tenant-admin-gated providers connectable with their current warnings. These prerequisites do not change self-serve status.
|
||||
7. Reconsider G2, Vercel, and Zomato only when their provider-approval constraints change; until then they remain absent from the catalog.
|
||||
|
||||
## Operating rules
|
||||
|
||||
- “Self-serve” allows normal accounts, subscriptions, tenant-admin policies, and OAuth consent, but excludes a Paperclip/provider partnership.
|
||||
- Provider documentation and working live OAuth metadata are both required for production verification.
|
||||
- Preview and early-access providers retain warnings until their live proof passes.
|
||||
- This program covers hosted remote MCP connections and credential custody. Generic REST execution and Paperclip-ID-managed shared OAuth registrations remain separate follow-up programs.
|
||||
|
|
@ -62,6 +62,7 @@
|
|||
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e:mcp-user-stories": "node scripts/e2e-mcp-user-stories.mjs",
|
||||
"test:e2e:connection-intents": "npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/connection-intents.spec.ts",
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
|
||||
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
|
||||
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"acpx": "0.12.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export type {
|
|||
AdapterRuntimeMcpServer,
|
||||
AdapterRuntimeMcpAccess,
|
||||
AdapterExecutionContext,
|
||||
AdapterRuntimeToolAccess,
|
||||
AdapterRuntimeToolDelivery,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestStatus,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|||
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { CONNECTION_INTENT_AGENT_GUIDANCE } from "@paperclipai/shared";
|
||||
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
||||
import {
|
||||
buildLocalProcessSandboxSpawnTarget,
|
||||
|
|
@ -11,10 +12,26 @@ import {
|
|||
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import { redactCommandText } from "./command-redaction.js";
|
||||
import type {
|
||||
AdapterRuntimeToolAccess,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
export function buildRuntimeToolsEnv(
|
||||
access: AdapterRuntimeToolAccess | null | undefined,
|
||||
): Record<string, string> {
|
||||
if (!access) return {};
|
||||
return {
|
||||
PAPERCLIP_RUNTIME_TOOLS_MCP_URL: access.mcpEndpoint,
|
||||
PAPERCLIP_RUNTIME_TOOLS_TOKEN: access.bearerToken,
|
||||
PAPERCLIP_RUNTIME_TOOLS_EXPIRES_AT: access.expiresAt,
|
||||
PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL: access.rest.connectionsSearch,
|
||||
PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL: access.rest.connectionRequest,
|
||||
PAPERCLIP_RUNTIME_TOOLS_AVAILABLE: access.tools.join(","),
|
||||
PAPERCLIP_RUNTIME_TOOLS_GUIDANCE: access.guidance,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunProcessResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
|
|
@ -182,6 +199,8 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
|||
"- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.",
|
||||
"- If blocked, mark the issue blocked and name the unblock owner and action.",
|
||||
"- Respect budget, pause/cancel, approval gates, and company boundaries.",
|
||||
"",
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
export const WATCHDOG_DEFAULT_MANDATE = [
|
||||
|
|
|
|||
|
|
@ -159,6 +159,22 @@ export interface AdapterRuntimeMcpAccess {
|
|||
getServers(): AdapterRuntimeMcpServer[];
|
||||
}
|
||||
|
||||
export type AdapterRuntimeToolDelivery = "native_mcp" | "environment" | "invocation_context";
|
||||
|
||||
export interface AdapterRuntimeToolAccess {
|
||||
version: 1;
|
||||
/** Provider-neutral instructions shared by every delivery strategy. */
|
||||
guidance: string;
|
||||
mcpEndpoint: string;
|
||||
rest: {
|
||||
connectionsSearch: string;
|
||||
connectionRequest: string;
|
||||
};
|
||||
bearerToken: string;
|
||||
expiresAt: string;
|
||||
tools: readonly ["connections_search", "connection_request"];
|
||||
}
|
||||
|
||||
export interface AdapterRuntimeEvent {
|
||||
eventType: string;
|
||||
stream?: "system" | "stdout" | "stderr";
|
||||
|
|
@ -184,10 +200,18 @@ export interface AdapterExecutionContext {
|
|||
remoteExecution?: Record<string, unknown> | null;
|
||||
};
|
||||
runtimeMcp?: AdapterRuntimeMcpAccess;
|
||||
runtimeTools?: AdapterRuntimeToolAccess;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onEvent?: (event: AdapterRuntimeEvent) => Promise<void>;
|
||||
onRuntimeProgress?: RuntimeStatusSink;
|
||||
/**
|
||||
* Reports that execution has crossed the adapter's dispatch boundary.
|
||||
* Process-backed adapters normally report this through `onSpawn`; adapters
|
||||
* without a local process should call this immediately before starting the
|
||||
* remote operation.
|
||||
*/
|
||||
onDispatch?: () => void;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
authToken?: string;
|
||||
/**
|
||||
|
|
@ -427,6 +451,8 @@ export interface ServerAdapterModule {
|
|||
sessionCodec?: AdapterSessionCodec;
|
||||
sessionManagement?: import("./session-compaction.js").AdapterSessionManagement;
|
||||
supportsLocalAgentJwt?: boolean;
|
||||
/** How this adapter receives Paperclip's run-scoped control tools. */
|
||||
runtimeToolDelivery?: AdapterRuntimeToolDelivery;
|
||||
models?: AdapterModel[];
|
||||
listModels?: () => Promise<AdapterModel[]>;
|
||||
modelProfiles?: AdapterModelProfileDefinition[];
|
||||
|
|
|
|||
|
|
@ -207,6 +207,18 @@ describe("cursor_cloud execute", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("reports dispatch before starting the first remote SDK operation", async () => {
|
||||
const run = createMockRun({ agentId: "agent-dispatch" });
|
||||
const sdkAgent = createMockSdkAgent({ agentId: "agent-dispatch", sendRun: run });
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
const onDispatch = vi.fn();
|
||||
|
||||
await execute(createContext({ onDispatch }));
|
||||
|
||||
expect(onDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(onDispatch.mock.invocationCallOrder[0]).toBeLessThan(createMock.mock.invocationCallOrder[0]!);
|
||||
});
|
||||
|
||||
it("omits the Paperclip API callback when no run JWT is issued (remote worker cannot call home)", async () => {
|
||||
const run = createMockRun({ agentId: "agent-no-jwt" });
|
||||
const sdkAgent = createMockSdkAgent({ agentId: "agent-no-jwt", sendRun: run });
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
asBoolean,
|
||||
asString,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
joinPromptSections,
|
||||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
|
|
@ -106,6 +107,7 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, st
|
|||
const env: Record<string, string> = {
|
||||
...configEnv,
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
PAPERCLIP_RUN_ID: runId,
|
||||
};
|
||||
// PAPERCLIP_API_KEY is never accepted from config — the harness-minted run
|
||||
|
|
@ -337,7 +339,7 @@ async function getAttachedRun(input: {
|
|||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onDispatch } = ctx;
|
||||
const envConfig = asStringEnvMap(config.env);
|
||||
const apiKey = asString(envConfig.CURSOR_API_KEY, "").trim();
|
||||
if (!apiKey) {
|
||||
|
|
@ -480,6 +482,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
let run: Run | null = null;
|
||||
let streamError: string | null = null;
|
||||
try {
|
||||
// This adapter has no local child process, so crossing into the first SDK
|
||||
// request is its dispatch boundary. Report it before any potentially
|
||||
// long-running remote reattach/create/send operation.
|
||||
onDispatch?.();
|
||||
const attachedRun = canReuseSession
|
||||
? await getAttachedRun({ apiKey, session })
|
||||
: null;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensurePaperclipSkillSymlink,
|
||||
|
|
@ -241,7 +242,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
let env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
let env: Record<string, string> = {
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
asString,
|
||||
asStringArray,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensurePaperclipSkillSymlink,
|
||||
|
|
@ -266,7 +267,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
const env: Record<string, string> = {
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
asStringArray,
|
||||
buildInvocationEnvForLogs,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensurePathInEnv,
|
||||
joinPromptSections,
|
||||
|
|
@ -247,7 +248,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
try {
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
const env: Record<string, string> = {
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,36 @@ describe("execute", () => {
|
|||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports dispatch before starting the remote run create request", async () => {
|
||||
const ctx = makeCtx({
|
||||
apiBaseUrl: "http://127.0.0.1:8642",
|
||||
apiKey: "secret-key",
|
||||
timeoutSec: 5,
|
||||
});
|
||||
const onDispatch = vi.fn();
|
||||
ctx.onDispatch = onDispatch;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/v1/runs")) {
|
||||
expect(onDispatch).toHaveBeenCalledTimes(1);
|
||||
return new Response(JSON.stringify({ run_id: "run-hermes-1", status: "started" }), { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/events")) {
|
||||
return new Response(
|
||||
sseStream(["event: run.completed", "data: {\"status\":\"completed\",\"output\":\"done\"}", ""].join("\n")),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "completed", output: "done" }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(onDispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("constructs POST /v1/runs with auth, idempotency, and Hermes session headers", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
|
|
|||
|
|
@ -872,6 +872,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
let runId: string | null = null;
|
||||
try {
|
||||
// This adapter has no local child process, so crossing into the first
|
||||
// remote create request is its dispatch boundary. Report it before the
|
||||
// request can block so continuation gates may release their issue lock.
|
||||
ctx.onDispatch?.();
|
||||
const created = await fetchJson(createRunUrl, {
|
||||
method: "POST",
|
||||
headers: runHeaders,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type {
|
|||
import {
|
||||
runChildProcess,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
renderTemplate,
|
||||
ensureAbsoluteDirectory,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
|
|
@ -487,6 +488,7 @@ export async function execute(
|
|||
...(process.env as Record<string, string>),
|
||||
...(userEnv && typeof userEnv === "object" ? userEnv : {}),
|
||||
...buildPaperclipEnv(ctx.agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
|
||||
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
asString,
|
||||
asStringArray,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
joinPromptSections,
|
||||
|
|
@ -237,7 +238,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
const env: Record<string, string> = {
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const websocketState = vi.hoisted(() => ({
|
||||
connectionAttempts: 0,
|
||||
failConnectAttempts: 0,
|
||||
failAgentRequests: 0,
|
||||
events: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("ws", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
static readonly OPEN = 1;
|
||||
readonly readyState = FakeWebSocket.OPEN;
|
||||
readonly attempt: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attempt = ++websocketState.connectionAttempts;
|
||||
websocketState.events.push(`construct:${this.attempt}`);
|
||||
queueMicrotask(() => {
|
||||
if (this.attempt <= websocketState.failConnectAttempts) {
|
||||
this.emit("error", new Error("ECONNREFUSED"));
|
||||
return;
|
||||
}
|
||||
this.emit("open");
|
||||
this.emit("message", JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "test-nonce" },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
send(payload: string) {
|
||||
const request = JSON.parse(payload) as { id: string; method: string };
|
||||
websocketState.events.push(`send:${request.method}`);
|
||||
if (request.method === "agent" && websocketState.failAgentRequests > 0) {
|
||||
websocketState.failAgentRequests--;
|
||||
queueMicrotask(() => {
|
||||
this.emit("close", 1006, Buffer.from("ECONNRESET"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const responsePayload = request.method === "connect"
|
||||
? { protocol: 3 }
|
||||
: { status: "ok", runId: "remote-run-1", summary: "done" };
|
||||
queueMicrotask(() => {
|
||||
this.emit("message", JSON.stringify({
|
||||
type: "res",
|
||||
id: request.id,
|
||||
ok: true,
|
||||
payload: responsePayload,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
return { WebSocket: FakeWebSocket };
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
function createContext(input: {
|
||||
onDispatch?: () => void;
|
||||
onLog?: AdapterExecutionContext["onLog"];
|
||||
} = {}): AdapterExecutionContext {
|
||||
return {
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
disableDeviceAuth: true,
|
||||
timeoutSec: 1,
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "interaction_resolved",
|
||||
},
|
||||
onLog: input.onLog ?? (async () => {}),
|
||||
onDispatch: input.onDispatch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("openclaw_gateway execute dispatch boundary", () => {
|
||||
beforeEach(() => {
|
||||
websocketState.connectionAttempts = 0;
|
||||
websocketState.failConnectAttempts = 0;
|
||||
websocketState.failAgentRequests = 0;
|
||||
websocketState.events = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reports dispatch after transport setup and before the remote agent request", async () => {
|
||||
const onDispatch = vi.fn(() => {
|
||||
websocketState.events.push("dispatch");
|
||||
});
|
||||
|
||||
const result = await execute(createContext({ onDispatch }));
|
||||
|
||||
expect(result).toMatchObject({ exitCode: 0 });
|
||||
expect(onDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(websocketState.events).toEqual([
|
||||
"construct:1",
|
||||
"send:connect",
|
||||
"dispatch",
|
||||
"send:agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains the continuation gate through transient connection backoff", async () => {
|
||||
vi.useFakeTimers();
|
||||
websocketState.failConnectAttempts = 1;
|
||||
let resolveBackoff!: () => void;
|
||||
const backoffReached = new Promise<void>((resolve) => {
|
||||
resolveBackoff = resolve;
|
||||
});
|
||||
let resolveAuthorityChange!: () => void;
|
||||
const authorityChange = new Promise<void>((resolve) => {
|
||||
resolveAuthorityChange = resolve;
|
||||
});
|
||||
const onDispatch = vi.fn(resolveAuthorityChange);
|
||||
const resultPromise = execute(createContext({
|
||||
onDispatch,
|
||||
onLog: async (_stream, chunk) => {
|
||||
if (chunk.includes("transient error, retry")) resolveBackoff();
|
||||
},
|
||||
}));
|
||||
|
||||
await backoffReached;
|
||||
expect(websocketState.connectionAttempts).toBe(1);
|
||||
expect(onDispatch).not.toHaveBeenCalled();
|
||||
|
||||
let authorityChangeSettled = false;
|
||||
void authorityChange.then(() => {
|
||||
authorityChangeSettled = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(authorityChangeSettled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
const result = await resultPromise;
|
||||
await authorityChange;
|
||||
|
||||
expect(result).toMatchObject({ exitCode: 0 });
|
||||
expect(websocketState.connectionAttempts).toBe(2);
|
||||
expect(onDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(authorityChangeSettled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retry after the remote-work boundary has been crossed", async () => {
|
||||
websocketState.failAgentRequests = 1;
|
||||
const onDispatch = vi.fn();
|
||||
|
||||
const result = await execute(createContext({ onDispatch }));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 1,
|
||||
errorCode: "openclaw_gateway_request_failed",
|
||||
});
|
||||
expect(websocketState.connectionAttempts).toBe(1);
|
||||
expect(onDispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
asNumber,
|
||||
asString,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
renderPaperclipWakePrompt,
|
||||
|
|
@ -345,6 +346,7 @@ function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: Wak
|
|||
const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl);
|
||||
const paperclipEnv: Record<string, string> = {
|
||||
...buildPaperclipEnv(ctx.agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
PAPERCLIP_RUN_ID: ctx.runId,
|
||||
};
|
||||
|
||||
|
|
@ -1159,8 +1161,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
let autoPairAttempted = false;
|
||||
let latestResultPayload: unknown = null;
|
||||
let retryCount = 0;
|
||||
let dispatchReported = false;
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
const reportDispatch = () => {
|
||||
if (dispatchReported) return;
|
||||
dispatchReported = true;
|
||||
ctx.onDispatch?.();
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const trackedRunIds = new Set<string>([ctx.runId]);
|
||||
const assistantChunks: string[] = [];
|
||||
|
|
@ -1288,6 +1297,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`,
|
||||
);
|
||||
|
||||
// Keep any server-side continuation lock through retryable websocket
|
||||
// setup and backoff. The first agent request is the remote-work boundary:
|
||||
// once it is sent, retrying would be unsafe because the gateway may have
|
||||
// accepted work even if the response is lost.
|
||||
reportDispatch();
|
||||
const acceptedPayload = await client.request<Record<string, unknown>>("agent", agentParams, {
|
||||
timeoutMs: connectTimeoutMs,
|
||||
});
|
||||
|
|
@ -1457,7 +1471,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
lower.includes("socket hang up") ||
|
||||
(timedOut && !lower.includes("agent.wait")));
|
||||
|
||||
if (isTransient && retryCount < MAX_RETRIES) {
|
||||
if (isTransient && !dispatchReported && retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
const backoffMs = retryCount * 2000;
|
||||
await ctx.onLog(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
joinPromptSections,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
|
|
@ -264,7 +265,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
const env: Record<string, string> = {
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
joinPromptSections,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
|
|
@ -268,7 +269,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
// Build environment
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
const env: Record<string, string> = {
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
|
||||
const wakeTaskId =
|
||||
|
|
|
|||
|
|
@ -489,7 +489,9 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
|
||||
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
||||
const originalPgDumpPath = process.env.PAPERCLIP_PG_DUMP_PATH;
|
||||
const originalPsqlPath = process.env.PAPERCLIP_PSQL_PATH;
|
||||
process.env.PAPERCLIP_PG_DUMP_PATH = "/bin/false";
|
||||
process.env.PAPERCLIP_PSQL_PATH = "/bin/false";
|
||||
|
||||
try {
|
||||
await sourceSql.unsafe(`
|
||||
|
|
@ -525,6 +527,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
expect(backupSql.indexOf("-- Data for: public.aaa_child_records")).toBeLessThan(
|
||||
backupSql.indexOf("-- Data for: public.zzz_parent_records"),
|
||||
);
|
||||
expect(backupSql).not.toContain(" FROM stdin;");
|
||||
|
||||
await runDatabaseRestore({
|
||||
connectionString: restoreConnectionString,
|
||||
|
|
@ -543,6 +546,11 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
} else {
|
||||
process.env.PAPERCLIP_PG_DUMP_PATH = originalPgDumpPath;
|
||||
}
|
||||
if (originalPsqlPath === undefined) {
|
||||
delete process.env.PAPERCLIP_PSQL_PATH;
|
||||
} else {
|
||||
process.env.PAPERCLIP_PSQL_PATH = originalPsqlPath;
|
||||
}
|
||||
await sourceSql.end();
|
||||
await restoreSql.end();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -529,6 +529,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
const retention = opts.retention;
|
||||
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
|
||||
const backupEngine = opts.backupEngine ?? "auto";
|
||||
let effectiveBackupEngine = backupEngine;
|
||||
const canUsePgDump = !hasBackupTransforms(opts);
|
||||
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
|
||||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||
|
|
@ -569,6 +570,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
if (backupEngine === "pg_dump") {
|
||||
throw error;
|
||||
}
|
||||
effectiveBackupEngine = "javascript";
|
||||
sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
sqlClosed = false;
|
||||
}
|
||||
|
|
@ -932,7 +934,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
|
||||
|
||||
const nullifiedColumns = nullifiedColumnsByTable.get(currentTableKey) ?? new Set<string>();
|
||||
if (backupEngine !== "javascript" && nullifiedColumns.size === 0) {
|
||||
if (effectiveBackupEngine !== "javascript" && nullifiedColumns.size === 0) {
|
||||
emit(`COPY ${qualifiedTableName} (${colNames}) FROM stdin;`);
|
||||
await writer.writeRaw("\n");
|
||||
const copySql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ The server reads its configuration from environment variables:
|
|||
- `PAPERCLIP_AGENT_ID` - optional default agent for checkout helpers
|
||||
- `PAPERCLIP_RUN_ID` - optional run id forwarded on mutating requests
|
||||
|
||||
Inside an active heartbeat, Paperclip also injects `PAPERCLIP_RUNTIME_TOOLS_*` variables. They enable the run-scoped `connections_search` and `connection_request` tools and expire with the run.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
|
|
@ -30,6 +32,11 @@ node packages/mcp-server/dist/stdio.js
|
|||
|
||||
## Tool Surface
|
||||
|
||||
Run-scoped connection tools:
|
||||
|
||||
- `connections_search`
|
||||
- `connection_request`
|
||||
|
||||
Read tools:
|
||||
|
||||
- `paperclipMe`
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
addIssueCommentSchema,
|
||||
askUserQuestionsPayloadSchema,
|
||||
checkoutIssueSchema,
|
||||
connectionRequestInputSchema,
|
||||
connectionsSearchInputSchema,
|
||||
createApprovalSchema,
|
||||
createIssueInputSchema,
|
||||
issueThreadInteractionContinuationPolicySchema,
|
||||
|
|
@ -51,6 +55,35 @@ function parseOptionalJson(raw: string | undefined | null): unknown {
|
|||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function callRuntimeConnectionTool(
|
||||
endpointEnv: "PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL" | "PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL",
|
||||
body: unknown,
|
||||
) {
|
||||
const endpoint = process.env[endpointEnv]?.trim();
|
||||
const token = process.env.PAPERCLIP_RUNTIME_TOOLS_TOKEN?.trim();
|
||||
if (!endpoint || !token) {
|
||||
throw new Error("Connection intent tools are available only inside an active Paperclip heartbeat run");
|
||||
}
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
const parsed = text ? JSON.parse(text) as unknown : null;
|
||||
if (!response.ok) {
|
||||
const message = parsed && typeof parsed === "object" && "error" in parsed
|
||||
? String((parsed as { error: unknown }).error)
|
||||
: `Runtime connection tool failed with ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const companyIdOptional = z.string().guid().optional().nullable();
|
||||
const agentIdOptional = z.string().guid().optional().nullable();
|
||||
const issueIdSchema = z.string().min(1);
|
||||
|
|
@ -236,6 +269,24 @@ async function getIssueWorkspaceRuntime(client: PaperclipApiClient, issueId: str
|
|||
|
||||
export function createToolDefinitions(client: PaperclipApiClient): ToolDefinition[] {
|
||||
return [
|
||||
makeTool(
|
||||
"connections_search",
|
||||
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
connectionsSearchInputSchema,
|
||||
async (input) => callRuntimeConnectionTool(
|
||||
"PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL",
|
||||
input,
|
||||
),
|
||||
),
|
||||
makeTool(
|
||||
"connection_request",
|
||||
CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
connectionRequestInputSchema,
|
||||
async (input) => callRuntimeConnectionTool(
|
||||
"PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL",
|
||||
input,
|
||||
),
|
||||
),
|
||||
makeTool(
|
||||
"paperclipMe",
|
||||
"Get the current authenticated Paperclip actor details",
|
||||
|
|
|
|||
|
|
@ -388,6 +388,12 @@ export type {
|
|||
PluginApiRouteMethod,
|
||||
PluginEventType,
|
||||
PluginBridgeErrorCode,
|
||||
ConnectionIntentInteraction,
|
||||
ConnectionIntentPayload,
|
||||
ConnectionIntentResult,
|
||||
ConnectionIntentSetupOptions,
|
||||
ConnectionRequestResult,
|
||||
ConnectionsSearchResult,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ import type {
|
|||
IssueAssigneeAdapterOverrides,
|
||||
IssueAttachment,
|
||||
IssueThreadInteraction,
|
||||
ConnectionIntentInteraction,
|
||||
ConnectionIntentPayload,
|
||||
ConnectionIntentResult,
|
||||
ConnectionIntentSetupOptions,
|
||||
ConnectionRequestResult,
|
||||
ConnectionsSearchResult,
|
||||
Approval,
|
||||
SuggestTasksInteraction,
|
||||
AskUserQuestionsInteraction,
|
||||
|
|
@ -126,6 +132,12 @@ export type {
|
|||
IssueDocumentSummary,
|
||||
IssueRelationIssueSummary,
|
||||
IssueThreadInteraction,
|
||||
ConnectionIntentInteraction,
|
||||
ConnectionIntentPayload,
|
||||
ConnectionIntentResult,
|
||||
ConnectionIntentSetupOptions,
|
||||
ConnectionRequestResult,
|
||||
ConnectionsSearchResult,
|
||||
SuggestTasksInteraction,
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ function createApp(db: any, deploymentMode: "authenticated" | "local_trusted" =
|
|||
app.get("/actor", (req, res) => {
|
||||
res.json(req.actor);
|
||||
});
|
||||
app.post("/mcp/gateways/:gatewayPublicId", (req, res) => {
|
||||
res.json({ reachedGatewayProtocol: true, actorType: req.actor.type });
|
||||
});
|
||||
app.get("/companies/:companyId/protected", (req, res) => {
|
||||
assertCompanyAccess(req, req.params.companyId);
|
||||
res.json({ ok: true });
|
||||
|
|
@ -203,6 +206,31 @@ describe("agent auth middleware", () => {
|
|||
expect(commentWrites).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves public MCP gateway bearers for the gateway protocol to validate", async () => {
|
||||
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
|
||||
const publicId = `gw_${"a".repeat(32)}`;
|
||||
|
||||
const res = await request(createApp(db, "local_trusted"))
|
||||
.post(`/mcp/gateways/${publicId}`)
|
||||
.set("Authorization", "Bearer pcgw_runtime_token")
|
||||
.send({ jsonrpc: "2.0", id: 1, method: "initialize" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ reachedGatewayProtocol: true });
|
||||
});
|
||||
|
||||
it("does not bypass actor authentication for lookalike MCP gateway paths", async () => {
|
||||
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
|
||||
|
||||
const res = await request(createApp(db, "local_trusted"))
|
||||
.post("/mcp/gateways/not-a-public-id")
|
||||
.set("Authorization", "Bearer pcgw_runtime_token")
|
||||
.send({ jsonrpc: "2.0", id: 1, method: "initialize" });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toContain("Agent token did not verify");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["terminated", "Agent is terminated"],
|
||||
["pending_approval", "Agent is pending approval"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,579 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
connectionGrantDelegations,
|
||||
connectionGrants,
|
||||
createDb,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
toolApplications,
|
||||
toolConnectionInstalls,
|
||||
toolConnections,
|
||||
toolProfileBindings,
|
||||
toolProfiles,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
|
||||
import { wakeConnectionIntentAfterResolution } from "../routes/connection-intents.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describe("wakeConnectionIntentAfterResolution", () => {
|
||||
it("preserves resolved interaction evidence in the queued run snapshot", async () => {
|
||||
const wakeup = vi.fn().mockResolvedValue(null);
|
||||
await wakeConnectionIntentAfterResolution(
|
||||
{ wakeup } as Parameters<typeof wakeConnectionIntentAfterResolution>[0],
|
||||
{
|
||||
loaded: {
|
||||
issue: { id: "issue-1", assigneeAgentId: "agent-1", status: "in_progress" },
|
||||
interaction: {
|
||||
id: "interaction-1",
|
||||
resolvedAt: "2026-08-28T13:30:00.000Z",
|
||||
},
|
||||
},
|
||||
status: "accepted",
|
||||
actorId: "user-1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(wakeup).toHaveBeenCalledWith("agent-1", expect.objectContaining({
|
||||
contextSnapshot: expect.objectContaining({
|
||||
interactionId: "interaction-1",
|
||||
interactionKind: "connection_intent",
|
||||
interactionStatus: "accepted",
|
||||
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
|
||||
mutation: "interaction",
|
||||
wakeReason: "issue_commented",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let connectionString!: string;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
let claims!: RuntimeToolsTokenClaims;
|
||||
let runId!: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-connection-intents-");
|
||||
cleanup = tempDb.cleanup;
|
||||
connectionString = tempDb.connectionString;
|
||||
db = createDb(connectionString);
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
runId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Connection tests",
|
||||
issuePrefix: "CONN",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: "responsible-user",
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Connect a service",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Researcher",
|
||||
role: "researcher",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Read Notion",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
responsibleUserId: "responsible-user",
|
||||
contextSnapshot: { issueId },
|
||||
});
|
||||
claims = {
|
||||
sub: agentId,
|
||||
company_id: companyId,
|
||||
run_id: runId,
|
||||
responsible_user_id: "responsible-user",
|
||||
scope: "connection_intents",
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
instance_id: "test",
|
||||
};
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup?.();
|
||||
});
|
||||
|
||||
async function waitForBlockedMembershipLock() {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const [waiting] = await db.execute<{ waiting: boolean }>(sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_stat_activity
|
||||
WHERE state = 'active'
|
||||
AND wait_event_type = 'Lock'
|
||||
AND query ILIKE '%company_memberships%'
|
||||
AND query ILIKE '%for update%'
|
||||
) AS waiting
|
||||
`);
|
||||
if (waiting?.waiting) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
it("searches first-party definitions without leaking run identity", async () => {
|
||||
const result = await connectionIntentService(db).search(claims, "notion");
|
||||
expect(result.results).toEqual([
|
||||
expect.objectContaining({ service: "notion", state: "available" }),
|
||||
]);
|
||||
const serialized = JSON.stringify(result);
|
||||
expect(serialized).not.toContain("responsible-user");
|
||||
expect(serialized).not.toContain(claims.sub);
|
||||
});
|
||||
|
||||
it("creates one addressed request, resolves only after delegation and install, and then reports ready", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
const first = await service.request(claims, "notion");
|
||||
const repeated = await service.request(claims, "notion");
|
||||
expect(repeated.interactionId).toBe(first.interactionId);
|
||||
const [row] = await db.select().from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, first.interactionId!));
|
||||
expect(row).toMatchObject({
|
||||
kind: "connection_intent",
|
||||
addresseeUserId: "responsible-user",
|
||||
sourceRunId: runId,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const [application] = await db.insert(toolApplications).values({
|
||||
companyId: claims.company_id,
|
||||
applicationKey: `notion-${randomUUID()}`,
|
||||
name: "Notion",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
metadata: { sourceTemplateKey: "notion" },
|
||||
}).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({
|
||||
companyId: claims.company_id,
|
||||
applicationId: application!.id,
|
||||
name: "Responsible user's Notion",
|
||||
uid: `notion/${randomUUID()}`,
|
||||
transport: "mcp_remote",
|
||||
authKind: "api_key",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "ok",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
transportConfig: { sourceTemplateKey: "notion" },
|
||||
}).returning();
|
||||
const [grant] = await db.insert(connectionGrants).values({
|
||||
companyId: claims.company_id,
|
||||
connectionId: connection!.id,
|
||||
kind: "user",
|
||||
subjectUserId: claims.responsible_user_id,
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
}).returning();
|
||||
const [otherAgent] = await db.insert(agents).values({
|
||||
companyId: claims.company_id,
|
||||
name: "Existing Notion user",
|
||||
role: "researcher",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
}).returning();
|
||||
await db.insert(toolConnectionInstalls).values({
|
||||
companyId: claims.company_id,
|
||||
connectionId: connection!.id,
|
||||
targetType: "agent",
|
||||
targetId: otherAgent!.id,
|
||||
});
|
||||
|
||||
await expect(service.complete(first.interactionId!, connection!.id, "someone-else"))
|
||||
.rejects.toThrow("Only the addressed user");
|
||||
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
||||
expect(await db.select().from(issueThreadInteractions).where(
|
||||
eq(issueThreadInteractions.id, first.interactionId!),
|
||||
)).toEqual([expect.objectContaining({ status: "pending" })]);
|
||||
|
||||
const resolved = await service.complete(
|
||||
first.interactionId!,
|
||||
connection!.id,
|
||||
claims.responsible_user_id,
|
||||
);
|
||||
expect(resolved).toMatchObject({
|
||||
status: "accepted",
|
||||
result: { outcome: "connected", connectionId: connection!.id },
|
||||
});
|
||||
expect(await db.select().from(connectionGrantDelegations).where(
|
||||
eq(connectionGrantDelegations.grantId, grant!.id),
|
||||
)).toEqual([
|
||||
expect.objectContaining({
|
||||
agentId: claims.sub,
|
||||
createdByUserId: claims.responsible_user_id,
|
||||
}),
|
||||
]);
|
||||
const installs = await db.select().from(toolConnectionInstalls).where(
|
||||
eq(toolConnectionInstalls.connectionId, connection!.id),
|
||||
);
|
||||
expect(installs).toHaveLength(2);
|
||||
expect(installs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ targetType: "agent", targetId: claims.sub }),
|
||||
expect.objectContaining({ targetType: "agent", targetId: otherAgent!.id }),
|
||||
]));
|
||||
|
||||
const continuationRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: continuationRunId,
|
||||
companyId: claims.company_id,
|
||||
agentId: claims.sub,
|
||||
status: "running",
|
||||
responsibleUserId: claims.responsible_user_id,
|
||||
contextSnapshot: { issueId: row!.issueId },
|
||||
});
|
||||
const continuationClaims: RuntimeToolsTokenClaims = {
|
||||
...claims,
|
||||
run_id: continuationRunId,
|
||||
};
|
||||
const readySearch = await service.search(continuationClaims, "notion");
|
||||
expect(readySearch.results).toEqual([
|
||||
expect.objectContaining({ service: "notion", state: "ready", connectionId: connection!.id }),
|
||||
]);
|
||||
const readyRequest = await service.request(continuationClaims, "notion");
|
||||
expect(readyRequest).toMatchObject({
|
||||
state: "ready",
|
||||
interactionId: null,
|
||||
connectionId: connection!.id,
|
||||
});
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(1);
|
||||
await expect(service.complete(first.interactionId!, connection!.id, claims.responsible_user_id))
|
||||
.rejects.toThrow("already resolved");
|
||||
await expect(service.request(claims, "unknown-service"))
|
||||
.rejects.toThrow("is not available");
|
||||
});
|
||||
|
||||
it("serializes OAuth intent completion behind addressed-user membership revocation", async () => {
|
||||
const raceCompanyId = randomUUID();
|
||||
const raceAgentId = randomUUID();
|
||||
const raceGoalId = randomUUID();
|
||||
const raceIssueId = randomUUID();
|
||||
const raceRunId = randomUUID();
|
||||
const raceUserId = `race-user-${randomUUID()}`;
|
||||
await db.insert(companies).values({
|
||||
id: raceCompanyId,
|
||||
name: "Connection intent authority race",
|
||||
issuePrefix: `RACE${randomUUID().slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: raceCompanyId,
|
||||
principalType: "user",
|
||||
principalId: raceUserId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: raceGoalId,
|
||||
companyId: raceCompanyId,
|
||||
title: "Connect Notion during authority race",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: raceAgentId,
|
||||
companyId: raceCompanyId,
|
||||
name: "Race agent",
|
||||
role: "researcher",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: raceIssueId,
|
||||
companyId: raceCompanyId,
|
||||
goalId: raceGoalId,
|
||||
title: "Use Notion",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: raceAgentId,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: raceRunId,
|
||||
companyId: raceCompanyId,
|
||||
agentId: raceAgentId,
|
||||
status: "running",
|
||||
responsibleUserId: raceUserId,
|
||||
contextSnapshot: { issueId: raceIssueId },
|
||||
});
|
||||
const raceClaims: RuntimeToolsTokenClaims = {
|
||||
sub: raceAgentId,
|
||||
company_id: raceCompanyId,
|
||||
run_id: raceRunId,
|
||||
responsible_user_id: raceUserId,
|
||||
scope: "connection_intents",
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
instance_id: "test",
|
||||
};
|
||||
const completionDb = createDb(connectionString, { maxConnections: 1 });
|
||||
const revocationDb = createDb(connectionString, { maxConnections: 1 });
|
||||
const service = connectionIntentService(completionDb);
|
||||
const pending = await service.request(raceClaims, "notion");
|
||||
const [application] = await db.insert(toolApplications).values({
|
||||
companyId: raceClaims.company_id,
|
||||
applicationKey: `notion-race-${randomUUID()}`,
|
||||
name: "Notion",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
metadata: { sourceTemplateKey: "notion" },
|
||||
}).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({
|
||||
companyId: raceClaims.company_id,
|
||||
applicationId: application!.id,
|
||||
name: "Personal Notion OAuth",
|
||||
uid: `notion-race/${randomUUID()}`,
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "shared",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "ok",
|
||||
config: { sourceTemplateKey: "notion", connectionMethodKey: "mcp-oauth" },
|
||||
transportConfig: { sourceTemplateKey: "notion", connectionMethodKey: "mcp-oauth" },
|
||||
}).returning();
|
||||
const [secretDefinition] = await db.insert(userSecretDefinitions).values({
|
||||
companyId: raceClaims.company_id,
|
||||
key: `notion-oauth-${randomUUID()}`,
|
||||
name: "Notion OAuth access token",
|
||||
}).returning();
|
||||
const [accessSecret] = await db.insert(companySecrets).values({
|
||||
companyId: raceClaims.company_id,
|
||||
scope: "user",
|
||||
ownerUserId: raceClaims.responsible_user_id,
|
||||
userSecretDefinitionId: secretDefinition!.id,
|
||||
key: `notion-oauth-${randomUUID()}`,
|
||||
name: `Notion OAuth ${randomUUID()}`,
|
||||
}).returning();
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId: raceClaims.company_id,
|
||||
connectionId: connection!.id,
|
||||
kind: "user",
|
||||
subjectUserId: raceClaims.responsible_user_id,
|
||||
credentialSecretRefs: [{
|
||||
secretId: accessSecret!.id,
|
||||
versionSelector: "latest",
|
||||
configPath: "oauth.access_token",
|
||||
required: true,
|
||||
label: "OAuth access token",
|
||||
}],
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
});
|
||||
const expectNoOAuthCompletionWrites = async () => {
|
||||
await expect(db.select().from(toolConnections).where(
|
||||
eq(toolConnections.id, connection!.id),
|
||||
)).resolves.toEqual([expect.objectContaining({
|
||||
credentialPolicy: "shared",
|
||||
credentialRefs: [],
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
})]);
|
||||
await expect(db.select().from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.companyId, raceClaims.company_id),
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(companySecretBindings.targetId, connection!.id),
|
||||
))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(toolProfiles).where(and(
|
||||
eq(toolProfiles.companyId, raceClaims.company_id),
|
||||
eq(toolProfiles.profileKey, `app:${connection!.id}`),
|
||||
))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(toolProfileBindings).where(
|
||||
eq(toolProfileBindings.companyId, raceClaims.company_id),
|
||||
)).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(toolConnectionInstalls).where(
|
||||
eq(toolConnectionInstalls.connectionId, connection!.id),
|
||||
)).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(connectionGrantDelegations).where(
|
||||
eq(connectionGrantDelegations.companyId, raceClaims.company_id),
|
||||
)).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(issueThreadInteractions).where(
|
||||
eq(issueThreadInteractions.id, pending.interactionId!),
|
||||
)).resolves.toEqual([expect.objectContaining({ status: "pending" })]);
|
||||
};
|
||||
let releaseRevocation!: () => void;
|
||||
const revocationMayCommit = new Promise<void>((resolve) => {
|
||||
releaseRevocation = resolve;
|
||||
});
|
||||
let membershipLocked!: () => void;
|
||||
const membershipIsLocked = new Promise<void>((resolve) => {
|
||||
membershipLocked = resolve;
|
||||
});
|
||||
let revocation: Promise<void> | null = null;
|
||||
let completion: Promise<{ value: unknown; error: unknown }> | null = null;
|
||||
|
||||
try {
|
||||
revocation = revocationDb.transaction(async (tx) => {
|
||||
await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.companyId, raceClaims.company_id),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, raceClaims.responsible_user_id),
|
||||
))
|
||||
.for("update");
|
||||
membershipLocked();
|
||||
await revocationMayCommit;
|
||||
await tx
|
||||
.update(companyMemberships)
|
||||
.set({ membershipRole: "viewer", updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(companyMemberships.companyId, raceClaims.company_id),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, raceClaims.responsible_user_id),
|
||||
));
|
||||
});
|
||||
|
||||
await membershipIsLocked;
|
||||
completion = service.complete(
|
||||
pending.interactionId!,
|
||||
connection!.id,
|
||||
raceClaims.responsible_user_id,
|
||||
{ canManageOrganizationGrant: true },
|
||||
).then(
|
||||
(value) => ({ value, error: null }),
|
||||
(error: unknown) => ({ value: null, error }),
|
||||
);
|
||||
|
||||
expect(await waitForBlockedMembershipLock()).toBe(true);
|
||||
await expectNoOAuthCompletionWrites();
|
||||
|
||||
releaseRevocation();
|
||||
await revocation;
|
||||
const outcome = await completion;
|
||||
expect(outcome.value).toBeNull();
|
||||
expect(outcome.error).toMatchObject({
|
||||
status: 403,
|
||||
message: expect.stringContaining("no longer authorized"),
|
||||
});
|
||||
await expectNoOAuthCompletionWrites();
|
||||
} finally {
|
||||
releaseRevocation();
|
||||
await revocation?.catch(() => undefined);
|
||||
await completion?.catch(() => undefined);
|
||||
await completionDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
||||
await revocationDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("revalidates the responsible user's active write membership for every token use", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
try {
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(
|
||||
companyMemberships.principalId,
|
||||
claims.responsible_user_id,
|
||||
));
|
||||
await expect(service.search(claims, "notion"))
|
||||
.rejects.toThrow("no longer authorized for company write access");
|
||||
|
||||
await db.update(companyMemberships).set({ membershipRole: "member", status: "inactive" }).where(eq(
|
||||
companyMemberships.principalId,
|
||||
claims.responsible_user_id,
|
||||
));
|
||||
await expect(service.request(claims, "notion"))
|
||||
.rejects.toThrow("no longer authorized for company write access");
|
||||
} finally {
|
||||
await db.update(companyMemberships).set({ membershipRole: "member", status: "active" }).where(eq(
|
||||
companyMemberships.principalId,
|
||||
claims.responsible_user_id,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects every addressed-user mutation after write membership is revoked", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
const pending = await service.request(claims, "posthog");
|
||||
expect(pending.interactionId).toBeTruthy();
|
||||
try {
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(
|
||||
companyMemberships.principalId,
|
||||
claims.responsible_user_id,
|
||||
));
|
||||
await expect(service.updatePhase(
|
||||
pending.interactionId!,
|
||||
"authorizing",
|
||||
claims.responsible_user_id,
|
||||
)).rejects.toThrow("no longer authorized for company write access");
|
||||
await expect(service.decline(
|
||||
pending.interactionId!,
|
||||
claims.responsible_user_id,
|
||||
)).rejects.toThrow("no longer authorized for company write access");
|
||||
await expect(service.complete(
|
||||
pending.interactionId!,
|
||||
randomUUID(),
|
||||
claims.responsible_user_id,
|
||||
)).rejects.toThrow("no longer authorized for company write access");
|
||||
} finally {
|
||||
await db.update(companyMemberships).set({ membershipRole: "member", status: "active" }).where(eq(
|
||||
companyMemberships.principalId,
|
||||
claims.responsible_user_id,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects cross-company claims and tokens after the run ends", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
await expect(service.search({ ...claims, company_id: randomUUID() }, "notion"))
|
||||
.rejects.toThrow("does not match its heartbeat run");
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
||||
await expect(service.search(claims, "notion"))
|
||||
.rejects.toThrow("no longer active");
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
connectionGrants,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
|
|
@ -29,7 +30,7 @@ import {
|
|||
toolProfiles,
|
||||
toolRuntimeSlots,
|
||||
} from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -272,7 +273,7 @@ function installMcpOAuthFixture(options: FixtureOptions = {}) {
|
|||
}
|
||||
|
||||
async function createCompany(db: ReturnType<typeof createDb>) {
|
||||
return db
|
||||
const company = await db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: `Generic MCP ${randomUUID()}`,
|
||||
|
|
@ -280,6 +281,14 @@ async function createCompany(db: ReturnType<typeof createDb>) {
|
|||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: "board-user",
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
});
|
||||
return company;
|
||||
}
|
||||
|
||||
function createRouteApp(
|
||||
|
|
@ -349,6 +358,24 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function waitForBlockedMembershipUpdate() {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const [waiting] = await db.execute<{ waiting: boolean }>(sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_stat_activity
|
||||
WHERE state = 'active'
|
||||
AND wait_event_type = 'Lock'
|
||||
AND query ILIKE '%company_memberships%'
|
||||
AND query ILIKE '%for update%'
|
||||
) AS waiting
|
||||
`);
|
||||
if (waiting?.waiting) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
it("discovers every tool for a public unknown endpoint without activating the draft", async () => {
|
||||
installMcpOAuthFixture({ auth: "public" });
|
||||
const company = await createCompany(db);
|
||||
|
|
@ -359,7 +386,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
expect(result.auth ?? null).toBeNull();
|
||||
expect(result.actions.readOnly.map((action) => action.toolName)).toEqual(["list_insights"]);
|
||||
expect(result.actions.canMakeChanges.map((action) => action.toolName)).toEqual(["create_insight"]);
|
||||
expect(result.suggestedDefaults).toMatchObject({ askFirstRiskLevels: ["write", "destructive"] });
|
||||
expect(result.suggestedDefaults).toMatchObject({ askFirstRiskLevels: [] });
|
||||
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, result.connectionId));
|
||||
expect(connection).toMatchObject({ transport: "mcp_remote", authKind: "none", status: "draft" });
|
||||
|
|
@ -437,6 +464,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
|
||||
const response = await request(app)
|
||||
.get("/api/tools/oauth/client-metadata")
|
||||
.set("Host", "paperclip.example.test")
|
||||
.expect(422);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
|
|
@ -753,6 +781,189 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
.toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
||||
});
|
||||
|
||||
it("completes organization OAuth with a single database connection", async () => {
|
||||
const fixture = installMcpOAuthFixture({ auth: "oauth" });
|
||||
const company = await createCompany(db);
|
||||
const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
|
||||
const service = toolAccessService(callbackDb);
|
||||
let deadline: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
await callbackDb.execute(sql`select pg_backend_pid()`);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
link: MCP_URL,
|
||||
name: "Fixture single-pool OAuth",
|
||||
});
|
||||
const start = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: REDIRECT_URI,
|
||||
actor: { actorType: "user", actorId: "board-user" },
|
||||
});
|
||||
const authorizationUrl = new URL(start.authorizationUrl);
|
||||
const code = fixture.issueAuthorizationCode(start.authorizationUrl);
|
||||
|
||||
const completed = await Promise.race([
|
||||
service.completeOAuthCallback({
|
||||
state: authorizationUrl.searchParams.get("state")!,
|
||||
code,
|
||||
iss: ISSUER,
|
||||
redirectUri: REDIRECT_URI,
|
||||
actor: { actorType: "user", actorId: "board-user" },
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => {
|
||||
void callbackDb.$client.end({ timeout: 0 })
|
||||
.finally(() => reject(new Error("OAuth callback self-deadlocked with maxConnections=1")));
|
||||
}, 5_000);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(completed.connection).toMatchObject({ status: "active", enabled: true });
|
||||
} finally {
|
||||
if (deadline) clearTimeout(deadline);
|
||||
await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("rejects organization OAuth completion after the initiating user loses write access", async () => {
|
||||
const fixture = installMcpOAuthFixture({ auth: "oauth" });
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
const actor = { actorType: "user" as const, actorId: "board-user" };
|
||||
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
link: MCP_URL,
|
||||
name: "Fixture revoked organization OAuth",
|
||||
});
|
||||
const start = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: REDIRECT_URI,
|
||||
actor,
|
||||
});
|
||||
await db
|
||||
.update(companyMemberships)
|
||||
.set({ membershipRole: "viewer" })
|
||||
.where(eq(companyMemberships.companyId, company.id));
|
||||
|
||||
const authorizationUrl = new URL(start.authorizationUrl);
|
||||
const code = fixture.issueAuthorizationCode(start.authorizationUrl);
|
||||
await expect(service.completeOAuthCallback({
|
||||
state: authorizationUrl.searchParams.get("state")!,
|
||||
code,
|
||||
iss: ISSUER,
|
||||
redirectUri: REDIRECT_URI,
|
||||
actor,
|
||||
})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
message: expect.stringContaining("membership no longer permits connection changes"),
|
||||
});
|
||||
|
||||
const [connection] = await db
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(eq(toolConnections.id, connected.connectionId));
|
||||
expect(connection).toMatchObject({ status: "draft" });
|
||||
expect(connection!.credentialSecretRefs.some((ref) => ref.configPath === "oauth.access_token")).toBe(false);
|
||||
expect(connection!.credentialSecretRefs.some((ref) => ref.configPath === "oauth.refresh_token")).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes organization OAuth completion behind membership revocation", async () => {
|
||||
const fixture = installMcpOAuthFixture({ auth: "oauth" });
|
||||
const company = await createCompany(db);
|
||||
const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
|
||||
const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
|
||||
const service = toolAccessService(callbackDb);
|
||||
const actor = { actorType: "user" as const, actorId: "board-user" };
|
||||
let releaseRemoval!: () => void;
|
||||
const removalMayCommit = new Promise<void>((resolve) => {
|
||||
releaseRemoval = resolve;
|
||||
});
|
||||
let membershipLocked!: () => void;
|
||||
const membershipIsLocked = new Promise<void>((resolve) => {
|
||||
membershipLocked = resolve;
|
||||
});
|
||||
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
link: MCP_URL,
|
||||
name: "Fixture concurrent revocation OAuth",
|
||||
});
|
||||
const start = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: REDIRECT_URI,
|
||||
actor,
|
||||
});
|
||||
const authorizationUrl = new URL(start.authorizationUrl);
|
||||
const code = fixture.issueAuthorizationCode(start.authorizationUrl);
|
||||
const beforeSecrets = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id));
|
||||
const beforeVersions = await db.select().from(companySecretVersions);
|
||||
const beforeBindings = await db.select().from(companySecretBindings).where(eq(companySecretBindings.companyId, company.id));
|
||||
const beforeGrants = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, company.id),
|
||||
eq(connectionGrants.connectionId, connected.connectionId),
|
||||
));
|
||||
|
||||
const removal = removalDb.transaction(async (tx) => {
|
||||
await tx.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, company.id),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, "board-user"),
|
||||
)).for("update");
|
||||
membershipLocked();
|
||||
await removalMayCommit;
|
||||
await tx.update(companyMemberships).set({
|
||||
membershipRole: "viewer",
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(companyMemberships.companyId, company.id),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, "board-user"),
|
||||
));
|
||||
});
|
||||
|
||||
await membershipIsLocked;
|
||||
const completion = service.completeOAuthCallback({
|
||||
state: authorizationUrl.searchParams.get("state")!,
|
||||
code,
|
||||
iss: ISSUER,
|
||||
redirectUri: REDIRECT_URI,
|
||||
actor,
|
||||
}).then(
|
||||
(value) => ({ value, error: null }),
|
||||
(error: unknown) => ({ value: null, error }),
|
||||
);
|
||||
|
||||
try {
|
||||
expect(await waitForBlockedMembershipUpdate()).toBe(true);
|
||||
releaseRemoval();
|
||||
await removal;
|
||||
const outcome = await completion;
|
||||
expect(outcome.value).toBeNull();
|
||||
expect(outcome.error).toMatchObject({
|
||||
status: 403,
|
||||
message: expect.stringContaining("membership no longer permits connection changes"),
|
||||
});
|
||||
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(
|
||||
toolConnections.id,
|
||||
connected.connectionId,
|
||||
));
|
||||
expect(connection).toMatchObject({ status: "draft" });
|
||||
expect(connection!.credentialSecretRefs).toEqual([]);
|
||||
await expect(db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id)))
|
||||
.resolves.toHaveLength(beforeSecrets.length);
|
||||
await expect(db.select().from(companySecretVersions)).resolves.toHaveLength(beforeVersions.length);
|
||||
await expect(db.select().from(companySecretBindings).where(eq(companySecretBindings.companyId, company.id)))
|
||||
.resolves.toHaveLength(beforeBindings.length);
|
||||
const afterGrants = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, company.id),
|
||||
eq(connectionGrants.connectionId, connected.connectionId),
|
||||
));
|
||||
expect(afterGrants).toEqual(beforeGrants);
|
||||
} finally {
|
||||
releaseRemoval();
|
||||
await removal.catch(() => undefined);
|
||||
await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
||||
await removalDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("discovers a pathful issuer through the OIDC suffix form too", async () => {
|
||||
installMcpOAuthFixture({ auth: "oauth", wellKnownStyle: "oidc-suffix" });
|
||||
const company = await createCompany(db);
|
||||
|
|
|
|||
|
|
@ -2750,7 +2750,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
.where(eq(heartbeatRuns.id, runId));
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "in_review" })
|
||||
.set({ status: "in_progress" })
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
mockAdapterExecute.mockRejectedValueOnce(
|
||||
|
|
@ -2837,7 +2837,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue).toEqual({
|
||||
status: "in_review",
|
||||
status: "in_progress",
|
||||
executionRunId: retryRun?.id ?? null,
|
||||
});
|
||||
|
||||
|
|
@ -2986,7 +2986,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
.where(eq(heartbeatRuns.id, runId));
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "in_review" })
|
||||
.set({ status: "in_progress" })
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
|
@ -3030,7 +3030,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue).toEqual({
|
||||
status: "in_review",
|
||||
status: "in_progress",
|
||||
executionRunId: retryRun?.id ?? null,
|
||||
});
|
||||
|
||||
|
|
@ -3108,7 +3108,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
.where(eq(heartbeatRuns.id, runId));
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "in_review" })
|
||||
.set({ status: "in_progress" })
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
mockAdapterExecute.mockRejectedValueOnce(
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
|
|||
expect(first[0]).toMatchObject({
|
||||
name: "Installed MCP",
|
||||
connectionId: installedConnection!.id,
|
||||
url: expect.stringMatching(/^https:\/\/paperclip\.example\.test\/api\/tool-gateway\/gateways\/.+\/mcp$/),
|
||||
url: expect.stringMatching(/^https:\/\/paperclip\.example\.test\/mcp\/gateways\/gw_[a-f0-9]{32}$/),
|
||||
token: expect.stringMatching(/^pcgw_/),
|
||||
});
|
||||
expect(first.some((server) => server.connectionId === uninstalledConnection!.id)).toBe(false);
|
||||
|
|
@ -317,7 +317,11 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
|
|||
});
|
||||
|
||||
expect(config?.gateways).toHaveLength(1);
|
||||
expect(config?.gateways[0]).toMatchObject({ id: gateways[0]!.id, name: gateways[0]!.name });
|
||||
expect(config?.gateways[0]).toMatchObject({
|
||||
id: gateways[0]!.id,
|
||||
name: gateways[0]!.name,
|
||||
endpointPath: `/mcp/gateways/${gateways[0]!.gatewayPublicId}`,
|
||||
});
|
||||
expect(config?.gateways.some((gateway) => gateway.id === gateways[1]!.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
|
|||
connectionId: installed!.id,
|
||||
name: installed!.name,
|
||||
token: expect.stringMatching(/^pcgw_/),
|
||||
url: expect.stringContaining("/api/tool-gateway/gateways/"),
|
||||
url: expect.stringMatching(/\/mcp\/gateways\/gw_[a-f0-9]{32}$/),
|
||||
});
|
||||
expect(captured?.mcpServers.some((server) => server.connectionId === uninstalled!.id)).toBe(false);
|
||||
const bearer = captured?.mcpServers[0]?.token;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,12 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
|||
let db!: ReturnType<typeof createDb>;
|
||||
let heartbeat!: ReturnType<typeof heartbeatService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let beforeContinuationDispatchCheck:
|
||||
| ((input: { runId: string; issueId: string }) => Promise<void>)
|
||||
| null = null;
|
||||
let afterContinuationDispatchCheck:
|
||||
| ((input: { runId: string; issueId: string }) => Promise<void>)
|
||||
| null = null;
|
||||
|
||||
const countExecuteCallsForRun = (runId: string) =>
|
||||
mockAdapterExecute.mock.calls.filter(([context]) => context?.runId === runId).length;
|
||||
|
|
@ -145,11 +151,20 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
|||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-stale-queue-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
heartbeat = heartbeatService(db);
|
||||
heartbeat = heartbeatService(db, {
|
||||
beforeResolvedInteractionContinuationDispatchCheck: async (input) => {
|
||||
await beforeContinuationDispatchCheck?.(input);
|
||||
},
|
||||
afterResolvedInteractionContinuationDispatchCheck: async (input) => {
|
||||
await afterContinuationDispatchCheck?.(input);
|
||||
},
|
||||
});
|
||||
await ensureIssueRelationsTable(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
beforeContinuationDispatchCheck = null;
|
||||
afterContinuationDispatchCheck = null;
|
||||
mockAdapterExecute.mockReset();
|
||||
mockAdapterExecute.mockImplementation(async () => ({
|
||||
exitCode: 0,
|
||||
|
|
@ -331,6 +346,368 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
|||
expect(runRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("checks guarded issue status and assignee under the enqueue lock", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const parkedIssueId = randomUUID();
|
||||
const reassignedIssueId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: parkedIssueId,
|
||||
companyId,
|
||||
title: "Parked connection intent",
|
||||
status: "backlog" as const,
|
||||
priority: "medium" as const,
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: reassignedIssueId,
|
||||
companyId,
|
||||
title: "Reassigned connection intent",
|
||||
status: "in_progress" as const,
|
||||
priority: "medium" as const,
|
||||
assigneeAgentId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
for (const issueId of [parkedIssueId, reassignedIssueId]) {
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, interactionId: randomUUID() },
|
||||
contextSnapshot: { issueId, wakeReason: "issue_commented" },
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "responsible-user",
|
||||
issueStateGuard: {
|
||||
statuses: ["in_progress"],
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
});
|
||||
expect(run).toBeNull();
|
||||
}
|
||||
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
|
||||
expect(await db.select({ status: agentWakeupRequests.status, reason: agentWakeupRequests.reason })
|
||||
.from(agentWakeupRequests)).toEqual([
|
||||
{ status: "skipped", reason: "issue_state_guard_mismatch" },
|
||||
{ status: "skipped", reason: "issue_state_guard_mismatch" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels a resolved connection-intent wake parked before queued-run claim", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Connection intent parked after enqueue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const { runId, wakeupRequestId } = await seedQueuedRun({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
wakeReason: "issue_commented",
|
||||
invocationSource: "automation",
|
||||
contextExtras: {
|
||||
interactionId: randomUUID(),
|
||||
interactionKind: "connection_intent",
|
||||
interactionStatus: "accepted",
|
||||
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
|
||||
mutation: "interaction",
|
||||
source: "connection_intent.resolved",
|
||||
},
|
||||
});
|
||||
|
||||
await db.update(issues).set({ status: "backlog" }).where(eq(issues.id, issueId));
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
|
||||
const [run, wakeup, issue] = await Promise.all([
|
||||
db.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({ status: issues.status }).from(issues).where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
|
||||
expect(wakeup).toMatchObject({ status: "skipped", error: expect.stringContaining("no longer in_progress") });
|
||||
expect(issue?.status).toBe("backlog");
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not re-open a resolved connection-intent issue parked after claim", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Connection intent parked between claim and checkout",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const { runId, wakeupRequestId } = await seedQueuedRun({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
wakeReason: "issue_commented",
|
||||
invocationSource: "automation",
|
||||
contextExtras: {
|
||||
interactionId: randomUUID(),
|
||||
interactionKind: "connection_intent",
|
||||
interactionStatus: "accepted",
|
||||
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
|
||||
mutation: "interaction",
|
||||
source: "connection_intent.resolved",
|
||||
},
|
||||
});
|
||||
|
||||
await db.execute(sql.raw(`
|
||||
CREATE OR REPLACE FUNCTION park_connection_intent_after_claim()
|
||||
RETURNS trigger AS $trigger$
|
||||
BEGIN
|
||||
IF NEW.id = '${runId}'::uuid AND NEW.status = 'running' THEN
|
||||
UPDATE issues SET status = 'backlog' WHERE id = '${issueId}'::uuid;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$trigger$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER park_connection_intent_after_claim
|
||||
AFTER UPDATE OF status ON heartbeat_runs
|
||||
FOR EACH ROW EXECUTE FUNCTION park_connection_intent_after_claim();
|
||||
`));
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "cancelled";
|
||||
});
|
||||
|
||||
const [run, wakeup, issue] = await Promise.all([
|
||||
db.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({ status: issues.status }).from(issues).where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
|
||||
expect(wakeup).toMatchObject({ status: "skipped", error: expect.stringContaining("no longer in_progress") });
|
||||
expect(issue?.status).toBe("backlog");
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
mutation: "parked",
|
||||
expectedErrorCode: "issue_not_in_progress",
|
||||
expectedError: "no longer in_progress",
|
||||
},
|
||||
{
|
||||
mutation: "reassigned",
|
||||
expectedErrorCode: "issue_assignee_changed",
|
||||
expectedError: "changed assignee",
|
||||
},
|
||||
])(
|
||||
"cancels a resolved connection-intent wake $mutation after checkout but before adapter dispatch",
|
||||
async ({ mutation, expectedErrorCode, expectedError }) => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const replacementAgentId = randomUUID();
|
||||
if (mutation === "reassigned") {
|
||||
await db.insert(agents).values({
|
||||
id: replacementAgentId,
|
||||
companyId,
|
||||
name: "ReplacementCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
|
||||
permissions: {},
|
||||
});
|
||||
}
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: `Connection intent ${mutation} at final dispatch`,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const { runId, wakeupRequestId } = await seedQueuedRun({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
wakeReason: "issue_commented",
|
||||
invocationSource: "automation",
|
||||
contextExtras: {
|
||||
interactionId: randomUUID(),
|
||||
interactionKind: "connection_intent",
|
||||
interactionStatus: "accepted",
|
||||
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
|
||||
mutation: "interaction",
|
||||
source: "connection_intent.resolved",
|
||||
},
|
||||
});
|
||||
beforeContinuationDispatchCheck = async ({ runId: guardedRunId, issueId: guardedIssueId }) => {
|
||||
expect(guardedRunId).toBe(runId);
|
||||
expect(guardedIssueId).toBe(issueId);
|
||||
await db
|
||||
.update(issues)
|
||||
.set(mutation === "parked"
|
||||
? { status: "backlog", updatedAt: new Date() }
|
||||
: { assigneeAgentId: replacementAgentId, updatedAt: new Date() })
|
||||
.where(eq(issues.id, issueId));
|
||||
};
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "cancelled";
|
||||
});
|
||||
|
||||
const [run, wakeup, issue] = await Promise.all([
|
||||
db.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
expect(run).toMatchObject({ status: "cancelled", errorCode: expectedErrorCode });
|
||||
expect(wakeup).toMatchObject({ status: "skipped", error: expect.stringContaining(expectedError) });
|
||||
expect(issue).toMatchObject(mutation === "parked"
|
||||
? { status: "backlog", assigneeAgentId: agentId }
|
||||
: { status: "in_progress", assigneeAgentId: replacementAgentId });
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("releases the final continuation gate at non-process adapter dispatch", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Connection intent parked at the atomic dispatch gate",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const { runId } = await seedQueuedRun({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
wakeReason: "issue_commented",
|
||||
invocationSource: "automation",
|
||||
contextExtras: {
|
||||
interactionId: randomUUID(),
|
||||
interactionKind: "connection_intent",
|
||||
interactionStatus: "accepted",
|
||||
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
|
||||
mutation: "interaction",
|
||||
source: "connection_intent.resolved",
|
||||
},
|
||||
});
|
||||
|
||||
const ordering: string[] = [];
|
||||
let parkPromise: Promise<unknown> | null = null;
|
||||
afterContinuationDispatchCheck = async ({ runId: guardedRunId, issueId: guardedIssueId }) => {
|
||||
expect(guardedRunId).toBe(runId);
|
||||
expect(guardedIssueId).toBe(issueId);
|
||||
ordering.push("validated");
|
||||
parkPromise = Promise.resolve(
|
||||
db
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "backlog",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId))
|
||||
.returning({ id: issues.id }),
|
||||
).then((rows) => {
|
||||
expect(rows).toHaveLength(1);
|
||||
ordering.push("parked");
|
||||
});
|
||||
// Give the concurrent update a chance to reach the row lock. It must
|
||||
// remain blocked until the adapter reports actual remote dispatch.
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
expect(ordering).toEqual(["validated"]);
|
||||
};
|
||||
mockAdapterExecute.mockImplementation(async (context) => {
|
||||
ordering.push("preparing");
|
||||
// Model asynchronous adapter setup before the child process exists.
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
expect(ordering).toEqual(["validated", "preparing"]);
|
||||
ordering.push("dispatched");
|
||||
context.onDispatch?.();
|
||||
await waitForCondition(async () => ordering.includes("parked"));
|
||||
expect(ordering).toEqual(["validated", "preparing", "dispatched", "parked"]);
|
||||
ordering.push("settled");
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Atomic continuation dispatch test run.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "succeeded";
|
||||
});
|
||||
await parkPromise;
|
||||
|
||||
const issue = await db
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("backlog");
|
||||
expect(ordering).toEqual(["validated", "preparing", "dispatched", "parked", "settled"]);
|
||||
expect(countExecuteCallsForRun(runId)).toBe(1);
|
||||
});
|
||||
|
||||
it("rate-limits skipped generic timer wakes by advancing the timer baseline", async () => {
|
||||
const { agentId } = await seedCompanyAndAgent({
|
||||
heartbeatConfig: {
|
||||
|
|
|
|||
|
|
@ -123,6 +123,118 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it("creates idempotent, human-addressed connection intents and supersedes older runs", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Connection intent");
|
||||
const agentId = randomUUID();
|
||||
const firstRunId = randomUUID();
|
||||
const secondRunId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Researcher",
|
||||
role: "researcher",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: firstRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
responsibleUserId: "user-board",
|
||||
contextSnapshot: { issueId },
|
||||
},
|
||||
{
|
||||
id: secondRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
responsibleUserId: "user-board",
|
||||
contextSnapshot: { issueId },
|
||||
},
|
||||
]);
|
||||
const payload = {
|
||||
version: 1 as const,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
serviceLogoUrl: null,
|
||||
requestingAgentId: agentId,
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "requested" as const,
|
||||
};
|
||||
const first = await interactionsSvc.createConnectionIntent(
|
||||
{ id: issueId, companyId },
|
||||
{
|
||||
payload,
|
||||
sourceRunId: firstRunId,
|
||||
addresseeUserId: "user-board",
|
||||
idempotencyKey: `connection-intent:${firstRunId}:notion`,
|
||||
},
|
||||
);
|
||||
expect(first).toMatchObject({
|
||||
kind: "connection_intent",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
addresseeUserId: "user-board",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
payload,
|
||||
});
|
||||
const repeated = await interactionsSvc.createConnectionIntent(
|
||||
{ id: issueId, companyId },
|
||||
{
|
||||
payload,
|
||||
sourceRunId: firstRunId,
|
||||
addresseeUserId: "user-board",
|
||||
idempotencyKey: `connection-intent:${firstRunId}:notion`,
|
||||
},
|
||||
);
|
||||
expect(repeated.id).toBe(first.id);
|
||||
|
||||
const newer = await interactionsSvc.createConnectionIntent(
|
||||
{ id: issueId, companyId },
|
||||
{
|
||||
payload,
|
||||
sourceRunId: secondRunId,
|
||||
addresseeUserId: "user-board",
|
||||
idempotencyKey: `connection-intent:${secondRunId}:notion`,
|
||||
},
|
||||
);
|
||||
const superseded = await interactionsSvc.getById(first.id);
|
||||
expect(superseded).toMatchObject({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded",
|
||||
supersededByInteractionId: newer.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [expiredByComment] = await interactionsSvc.expireRequestConfirmationsSupersededByComment(
|
||||
{ id: issueId, companyId },
|
||||
{
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(Date.now() + 1_000),
|
||||
authorUserId: "user-board",
|
||||
createdByRunId: null,
|
||||
},
|
||||
{ userId: "user-board" },
|
||||
);
|
||||
expect(expiredByComment).toMatchObject({
|
||||
id: newer.id,
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "expired",
|
||||
reason: "Superseded by a newer user comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("persists addressees without allowing them to bypass human-only governance", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Agent-addressed interaction");
|
||||
const creatorAgentId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const apiPrefixes: Record<string, string> = {
|
|||
"companies.ts": "/api/companies",
|
||||
"company-skills.ts": "/api",
|
||||
"company-skill-policy.ts": "/api",
|
||||
"connection-intents.ts": "/api",
|
||||
"costs.ts": "/api",
|
||||
"dashboard.ts": "/api",
|
||||
"decision-queues.ts": "/api",
|
||||
|
|
@ -108,6 +109,12 @@ function resolveMountedPath(file: string, prefix: string, routePath: string) {
|
|||
if (file === "tool-gateway.ts" && routePath.startsWith("/mcp/gateways/")) {
|
||||
return routePath;
|
||||
}
|
||||
if (
|
||||
file === "connection-intents.ts"
|
||||
&& (routePath.startsWith("/mcp/") || routePath.startsWith("/runtime-tools/"))
|
||||
) {
|
||||
return routePath;
|
||||
}
|
||||
if ((file === "companies.ts" || file === "health.ts") && routePath === "/") {
|
||||
return prefix;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -40,6 +40,8 @@ import {
|
|||
toolStdioCommandTemplates,
|
||||
toolRuntimeSlots,
|
||||
secretAccessEvents,
|
||||
userSecretDeclarations,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js";
|
||||
|
|
@ -556,9 +558,11 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
|
|||
await db.delete(toolConnections);
|
||||
await db.delete(toolApplications);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(userSecretDeclarations);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(userSecretDefinitions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
|
|
@ -1557,6 +1561,7 @@ rl.on("line", (line) => {
|
|||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
await createActiveMember(db, company.id, "alice");
|
||||
await createActiveMember(db, company.id, "bob");
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
||||
const values = {
|
||||
organization: `organization-${randomUUID()}`,
|
||||
|
|
@ -1569,16 +1574,18 @@ rl.on("line", (line) => {
|
|||
provider: "local_encrypted",
|
||||
value: values.organization,
|
||||
});
|
||||
const aliceSecret = await secretService(db).create(company.id, {
|
||||
name: `Alice stdio token ${randomUUID()}`,
|
||||
key: `alice_stdio_${randomUUID().replace(/-/g, "")}`,
|
||||
const secrets = secretService(db);
|
||||
const identityDefinition = await secrets.createUserSecretDefinition(company.id, {
|
||||
name: `Personal stdio token ${randomUUID()}`,
|
||||
key: `personal_stdio_${randomUUID().replace(/-/g, "")}`,
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
const aliceSecret = await secrets.createCurrentUserSecretValue(company.id, "alice", {
|
||||
definitionId: identityDefinition.id,
|
||||
value: values.alice,
|
||||
});
|
||||
const bobSecret = await secretService(db).create(company.id, {
|
||||
name: `Bob stdio token ${randomUUID()}`,
|
||||
key: `bob_stdio_${randomUUID().replace(/-/g, "")}`,
|
||||
provider: "local_encrypted",
|
||||
const bobSecret = await secrets.createCurrentUserSecretValue(company.id, "bob", {
|
||||
definitionId: identityDefinition.id,
|
||||
value: values.bob,
|
||||
});
|
||||
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
||||
|
|
@ -1611,6 +1618,19 @@ rl.on("line", (line) => {
|
|||
});
|
||||
`,
|
||||
});
|
||||
await secrets.syncUserSecretDeclarationsForTarget(
|
||||
company.id,
|
||||
{ targetType: "tool_connection", targetId: localTool.connection.id },
|
||||
[{
|
||||
definitionKey: identityDefinition.key,
|
||||
configPath: "env.IDENTITY_TOKEN",
|
||||
envKey: "IDENTITY_TOKEN",
|
||||
versionSelector: "latest",
|
||||
required: true,
|
||||
label: "Personal identity",
|
||||
}],
|
||||
{ replaceAll: true },
|
||||
);
|
||||
const grantRef = (secretId: string, label: string) => ({
|
||||
secretId,
|
||||
versionSelector: "latest" as const,
|
||||
|
|
@ -1695,6 +1715,94 @@ rl.on("line", (line) => {
|
|||
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses the signed-in tester's personal grant for Test-tab calls", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const userId = `test-user-${randomUUID()}`;
|
||||
await createActiveMember(db, company.id, userId);
|
||||
const personalValue = `personal-${randomUUID()}`;
|
||||
const definitionKey = `personal_test_${randomUUID().replace(/-/g, "")}`;
|
||||
const [definition] = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: definitionKey,
|
||||
name: `Personal Test token ${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
}).returning();
|
||||
const personalSecret = await secretService(db).createCurrentUserSecretValue(company.id, userId, {
|
||||
definitionId: definition.id,
|
||||
value: personalValue,
|
||||
});
|
||||
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
||||
applicationKey: "personal-test-tab",
|
||||
toolName: "identity",
|
||||
title: "Personal test identity",
|
||||
envKeys: ["IDENTITY_TOKEN"],
|
||||
stdioScript: `
|
||||
const readline = require("node:readline");
|
||||
const expected = ${JSON.stringify(personalValue)};
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "test-identity", version: "0.0.0" } } }) + "\\n");
|
||||
return;
|
||||
}
|
||||
if (message.method === "tools/call") {
|
||||
const identity = process.env.IDENTITY_TOKEN === expected ? "personal" : "wrong";
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n");
|
||||
}
|
||||
});
|
||||
`,
|
||||
});
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(
|
||||
toolConnections.id,
|
||||
localTool.connection.id,
|
||||
));
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: localTool.connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: userId,
|
||||
credentialSecretRefs: [{
|
||||
secretId: personalSecret.id,
|
||||
versionSelector: "latest",
|
||||
configPath: "env.IDENTITY_TOKEN",
|
||||
required: true,
|
||||
label: "Personal identity",
|
||||
}],
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
});
|
||||
await secretService(db).syncUserSecretDeclarationsForTarget(
|
||||
company.id,
|
||||
{ targetType: "tool_connection", targetId: localTool.connection.id },
|
||||
[{
|
||||
definitionKey,
|
||||
configPath: "env.IDENTITY_TOKEN",
|
||||
envKey: "IDENTITY_TOKEN",
|
||||
versionSelector: "latest",
|
||||
required: true,
|
||||
label: "Personal identity",
|
||||
}],
|
||||
{ replaceAll: true },
|
||||
);
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
||||
|
||||
await expect(gateway.executeTestCall({
|
||||
companyId: company.id,
|
||||
connectionId: localTool.connection.id,
|
||||
agentId: agent.id,
|
||||
userId,
|
||||
toolName: "identity",
|
||||
parameters: {},
|
||||
})).resolves.toMatchObject({
|
||||
decision: "allowed",
|
||||
result: { data: { structuredContent: { identity: "personal" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps connected remote MCP gateway names collision-safe and excludes inactive catalog sources", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
|
@ -1991,7 +2099,14 @@ rl.on("line", (line) => {
|
|||
toolName: "whoami",
|
||||
riskLevel: "read",
|
||||
});
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
|
||||
// Personal credentials live on grants, so a company-level health probe
|
||||
// may be unable to authenticate even though this user's grant is valid.
|
||||
// The cached catalog must remain visible so grant resolution can happen.
|
||||
await db.update(toolConnections).set({
|
||||
credentialPolicy: "per_user",
|
||||
healthStatus: "error",
|
||||
healthMessage: "This app needs you to sign in.",
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
|
|
@ -2044,6 +2159,9 @@ rl.on("line", (line) => {
|
|||
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
||||
expect(result).toMatchObject({ status: "completed", result: { content: "connected" } });
|
||||
expect(fake.requests).toHaveLength(1);
|
||||
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
|
||||
eq(toolConnections.id, connection.id),
|
||||
)).resolves.toEqual([{ healthStatus: "ok" }]);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
|
|
@ -4106,6 +4224,7 @@ rl.on("line", (line) => {
|
|||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { project, issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "runtime-owner" }).where(eq(heartbeatRuns.id, run.id));
|
||||
const profile = await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:runtime_status"]);
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const namedGateway = await gateway.createNamedGateway({
|
||||
|
|
@ -4140,6 +4259,7 @@ rl.on("line", (line) => {
|
|||
runId: run.id,
|
||||
issueId: issue.id,
|
||||
projectId: project.id,
|
||||
responsibleUserId: "runtime-owner",
|
||||
});
|
||||
|
||||
await request(app)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONNECTION_INTENT_AGENT_GUIDANCE } from "@paperclipai/shared";
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -6,6 +7,64 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("http adapter execute", () => {
|
||||
it("delivers the complete runtime connection descriptor and shared guidance", async () => {
|
||||
const onDispatch = vi.fn();
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(onDispatch).toHaveBeenCalledOnce();
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
expect(body.paperclipRuntimeTools).toEqual({
|
||||
version: 1,
|
||||
guidance: CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
mcpEndpoint: "https://paperclip.test/mcp/runtime-tools",
|
||||
rest: {
|
||||
connectionsSearch: "https://paperclip.test/runtime-tools/connections/search",
|
||||
connectionRequest: "https://paperclip.test/runtime-tools/connections/request",
|
||||
},
|
||||
bearerToken: "run-token",
|
||||
expiresAt: "2026-08-26T15:00:00.000Z",
|
||||
tools: ["connections_search", "connection_request"],
|
||||
});
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Agent",
|
||||
adapterType: "http",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: { url: "https://example.test/webhook" },
|
||||
context: {},
|
||||
runtimeTools: {
|
||||
version: 1,
|
||||
guidance: CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
mcpEndpoint: "https://paperclip.test/mcp/runtime-tools",
|
||||
rest: {
|
||||
connectionsSearch: "https://paperclip.test/runtime-tools/connections/search",
|
||||
connectionRequest: "https://paperclip.test/runtime-tools/connections/request",
|
||||
},
|
||||
bearerToken: "run-token",
|
||||
expiresAt: "2026-08-26T15:00:00.000Z",
|
||||
tools: ["connections_search", "connection_request"],
|
||||
},
|
||||
onLog: async () => {},
|
||||
onDispatch,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(onDispatch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports configured request timeout as timed_out", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
|
|
|||
|
|
@ -10,12 +10,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const timeoutMs = asNumber(config.timeoutMs, 0);
|
||||
const headers = parseObject(config.headers) as Record<string, string>;
|
||||
const payloadTemplate = parseObject(config.payloadTemplate);
|
||||
const body = { ...payloadTemplate, agentId: agent.id, runId, context };
|
||||
const body = {
|
||||
...payloadTemplate,
|
||||
agentId: agent.id,
|
||||
runId,
|
||||
context,
|
||||
...(ctx.runtimeTools ? { paperclipRuntimeTools: ctx.runtimeTools } : {}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
|
||||
try {
|
||||
// HTTP adapters have no child-process spawn event. Signal immediately
|
||||
// before starting the remote request so dispatch gates can release without
|
||||
// waiting for the endpoint to respond.
|
||||
ctx.onDispatch?.();
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { testEnvironment } from "./test.js";
|
|||
|
||||
export const httpAdapter: ServerAdapterModule = {
|
||||
type: "http",
|
||||
runtimeToolDelivery: "invocation_context",
|
||||
execute,
|
||||
testEnvironment,
|
||||
models: [],
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export type {
|
|||
AdapterRuntimeEvent,
|
||||
AdapterRuntimeMcpServer,
|
||||
AdapterRuntimeMcpAccess,
|
||||
AdapterRuntimeToolAccess,
|
||||
AdapterRuntimeToolDelivery,
|
||||
AdapterModelProfileDefinition,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
isForbiddenConfigEnvKey,
|
||||
isPaperclipRuntimeEnvKey,
|
||||
buildInvocationEnvForLogs,
|
||||
|
|
@ -23,6 +24,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {
|
||||
...buildPaperclipEnv(agent),
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v !== "string") continue;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { testEnvironment } from "./test.js";
|
|||
|
||||
export const processAdapter: ServerAdapterModule = {
|
||||
type: "process",
|
||||
runtimeToolDelivery: "environment",
|
||||
execute,
|
||||
testEnvironment,
|
||||
models: [],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { assertValidAdapterLoginCapability } from "@paperclipai/adapter-utils";
|
||||
import { requireServerAdapter } from "./registry.js";
|
||||
import { listServerAdapters, requireServerAdapter } from "./registry.js";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
|
||||
|
||||
// The registry registers a login capability for the two built-in interactive
|
||||
// adapters. The test checks the scalar values and the presence of the required
|
||||
|
|
@ -45,3 +46,36 @@ describe("built-in adapter login capabilities", () => {
|
|||
expect(() => assertValidAdapterLoginCapability(capability, "claude_local")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in runtime connection tool delivery", () => {
|
||||
const expectedStrategies = new Map([
|
||||
["acpx_local", "environment"],
|
||||
["claude_local", "native_mcp"],
|
||||
["codex_local", "native_mcp"],
|
||||
["cursor_cloud", "invocation_context"],
|
||||
["cursor", "environment"],
|
||||
["gemini_local", "environment"],
|
||||
["grok_local", "environment"],
|
||||
["hermes_gateway", "invocation_context"],
|
||||
["hermes_local", "environment"],
|
||||
["kimi_local", "environment"],
|
||||
["openclaw_gateway", "invocation_context"],
|
||||
["opencode_local", "environment"],
|
||||
["paperclip_runner", "environment"],
|
||||
["pi_local", "environment"],
|
||||
["process", "environment"],
|
||||
["http", "invocation_context"],
|
||||
] as const);
|
||||
|
||||
it("requires every built-in adapter to declare its expected delivery strategy", () => {
|
||||
const builtIns = listServerAdapters().filter((adapter) => BUILTIN_ADAPTER_TYPES.has(adapter.type));
|
||||
expect(new Set(builtIns.map((adapter) => adapter.type))).toEqual(BUILTIN_ADAPTER_TYPES);
|
||||
expect(new Map(builtIns.map((adapter) => [adapter.type, adapter.runtimeToolDelivery]))).toEqual(
|
||||
expectedStrategies,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([...expectedStrategies])("delivers %s runtime tools through %s", (type, strategy) => {
|
||||
expect(requireServerAdapter(type).runtimeToolDelivery).toBe(strategy);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ const grokLoginCapability: AdapterLoginCapability = {
|
|||
|
||||
const claudeLocalAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
runtimeToolDelivery: "native_mcp",
|
||||
execute: stampClaudeAgentIdHeader(claudeExecute),
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
acp: {
|
||||
|
|
@ -288,6 +289,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
|||
|
||||
const acpxLocalAdapter: ServerAdapterModule = {
|
||||
type: "acpx_local",
|
||||
runtimeToolDelivery: "environment",
|
||||
async execute(ctx) {
|
||||
await ctx.onLog("stderr", `${retiredAcpxMessage}\n`);
|
||||
await ctx.onMeta?.({
|
||||
|
|
@ -330,6 +332,7 @@ const acpxLocalAdapter: ServerAdapterModule = {
|
|||
|
||||
const codexLocalAdapter: ServerAdapterModule = {
|
||||
type: "codex_local",
|
||||
runtimeToolDelivery: "native_mcp",
|
||||
execute: codexExecute,
|
||||
testEnvironment: codexTestEnvironment,
|
||||
acp: {
|
||||
|
|
@ -361,6 +364,7 @@ const codexLocalAdapter: ServerAdapterModule = {
|
|||
|
||||
const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
type: "paperclip_runner",
|
||||
runtimeToolDelivery: "environment",
|
||||
async execute(ctx) {
|
||||
const message = "paperclip_runner requires the native runner coordinator";
|
||||
await ctx.onLog("stderr", `${message}\n`);
|
||||
|
|
@ -407,6 +411,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
|||
|
||||
const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
runtimeToolDelivery: "environment",
|
||||
execute: cursorExecute,
|
||||
testEnvironment: cursorTestEnvironment,
|
||||
listSkills: listCursorSkills,
|
||||
|
|
@ -426,6 +431,7 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
|||
|
||||
const cursorCloudAdapter: ServerAdapterModule = {
|
||||
type: "cursor_cloud",
|
||||
runtimeToolDelivery: "invocation_context",
|
||||
execute: cursorCloudExecute,
|
||||
testEnvironment: cursorCloudTestEnvironment,
|
||||
sessionCodec: cursorCloudSessionCodec,
|
||||
|
|
@ -441,6 +447,7 @@ const cursorCloudAdapter: ServerAdapterModule = {
|
|||
|
||||
const geminiLocalAdapter: ServerAdapterModule = {
|
||||
type: "gemini_local",
|
||||
runtimeToolDelivery: "environment",
|
||||
execute: geminiExecute,
|
||||
testEnvironment: geminiTestEnvironment,
|
||||
acp: {
|
||||
|
|
@ -469,6 +476,7 @@ const geminiLocalAdapter: ServerAdapterModule = {
|
|||
|
||||
const grokLocalAdapter: ServerAdapterModule = {
|
||||
type: "grok_local",
|
||||
runtimeToolDelivery: "environment",
|
||||
execute: grokExecute,
|
||||
testEnvironment: grokTestEnvironment,
|
||||
listSkills: listGrokSkills,
|
||||
|
|
@ -491,6 +499,7 @@ const grokLocalAdapter: ServerAdapterModule = {
|
|||
|
||||
const kimiLocalAdapter: ServerAdapterModule = {
|
||||
type: "kimi_local",
|
||||
runtimeToolDelivery: "environment",
|
||||
execute: kimiExecute,
|
||||
testEnvironment: kimiTestEnvironment,
|
||||
acp: {
|
||||
|
|
@ -515,12 +524,19 @@ const kimiLocalAdapter: ServerAdapterModule = {
|
|||
agentConfigurationDoc: kimiAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const hermesGatewayAdapter = createHermesGatewayServerAdapter();
|
||||
const hermesGatewayAdapter: ServerAdapterModule = {
|
||||
...createHermesGatewayServerAdapter(),
|
||||
runtimeToolDelivery: "invocation_context",
|
||||
};
|
||||
|
||||
const hermesLocalAdapter = createHermesLocalServerAdapter();
|
||||
const hermesLocalAdapter: ServerAdapterModule = {
|
||||
...createHermesLocalServerAdapter(),
|
||||
runtimeToolDelivery: "environment",
|
||||
};
|
||||
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
runtimeToolDelivery: "invocation_context",
|
||||
execute: openclawGatewayExecute,
|
||||
testEnvironment: openclawGatewayTestEnvironment,
|
||||
models: openclawGatewayModels,
|
||||
|
|
@ -532,6 +548,7 @@ const openclawGatewayAdapter: ServerAdapterModule = {
|
|||
|
||||
const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
runtimeToolDelivery: "environment",
|
||||
execute: openCodeExecute,
|
||||
testEnvironment: openCodeTestEnvironment,
|
||||
listSkills: listOpenCodeSkills,
|
||||
|
|
@ -551,6 +568,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
|
|||
|
||||
const piLocalAdapter: ServerAdapterModule = {
|
||||
type: "pi_local",
|
||||
runtimeToolDelivery: "environment",
|
||||
execute: piExecute,
|
||||
testEnvironment: piTestEnvironment,
|
||||
listSkills: listPiSkills,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export type {
|
|||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
AdapterRuntimeToolAccess,
|
||||
AdapterRuntimeToolDelivery,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestStatus,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export const resolvePathValue = serverUtils.resolvePathValue;
|
|||
export const renderTemplate = serverUtils.renderTemplate;
|
||||
export const redactEnvForLogs = serverUtils.redactEnvForLogs;
|
||||
export const buildPaperclipEnv = serverUtils.buildPaperclipEnv;
|
||||
export const buildRuntimeToolsEnv = serverUtils.buildRuntimeToolsEnv;
|
||||
export const isPaperclipRuntimeEnvKey = serverUtils.isPaperclipRuntimeEnvKey;
|
||||
export const isForbiddenConfigEnvKey = serverUtils.isForbiddenConfigEnvKey;
|
||||
export const defaultPathForPlatform = serverUtils.defaultPathForPlatform;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ import { assetRoutes } from "./routes/assets.js";
|
|||
import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
|
||||
import {
|
||||
connectionIntentBoardRoutes,
|
||||
runtimeConnectionIntentRoutes,
|
||||
} from "./routes/connection-intents.js";
|
||||
import { adapterRoutes } from "./routes/adapters.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
|
||||
|
|
@ -100,6 +104,7 @@ import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
|||
import { pluginJobStore } from "./services/plugin-job-store.js";
|
||||
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
||||
import { createToolGatewayService } from "./services/tool-gateway.js";
|
||||
import { heartbeatService } from "./services/heartbeat.js";
|
||||
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
||||
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
||||
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
|
||||
|
|
@ -356,6 +361,10 @@ export async function createApp(
|
|||
bindHost: opts.bindHost,
|
||||
}),
|
||||
);
|
||||
// Connection-intent tools carry their own short-lived, run-bound bearer and
|
||||
// must be reachable by remote adapters that intentionally do not receive an
|
||||
// agent API key. Every request revalidates the active heartbeat row.
|
||||
app.use(runtimeConnectionIntentRoutes(db));
|
||||
app.use(
|
||||
actorMiddleware(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
|
|
@ -574,13 +583,18 @@ export async function createApp(
|
|||
approveToolActionRequest: (input) => toolGateway.approveActionRequest(input),
|
||||
}));
|
||||
app.use(mcpGatewayProtocolRoutes(toolGateway));
|
||||
const connectionIntentHeartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: workerManager,
|
||||
});
|
||||
api.use(toolAccessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
authPublicBaseUrl: opts.authPublicBaseUrl,
|
||||
trustedLocalStdioRuntimeHost,
|
||||
toolGateway,
|
||||
connectionIntentHeartbeat,
|
||||
}));
|
||||
api.use(connectionIntentBoardRoutes(db, connectionIntentHeartbeat));
|
||||
api.use(smokeLabRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ interface ActorMiddlewareOptions {
|
|||
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
|
||||
}
|
||||
|
||||
const publicMcpGatewayProtocolPath = /^\/mcp\/gateways\/gw_[a-f0-9]{32}\/?$/i;
|
||||
|
||||
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
|
||||
const boardAuth = boardAuthService(db);
|
||||
return async (req, _res, next) => {
|
||||
|
|
@ -230,6 +232,19 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
|||
|
||||
const authHeader = req.header("authorization");
|
||||
const hasBearerCredentials = /^bearer(?:\s|$)/i.test(authHeader ?? "");
|
||||
|
||||
// Public MCP gateway protocol requests carry a pcgw_* bearer that is
|
||||
// validated by the gateway service itself. Do not interpret that bearer as
|
||||
// a board key or agent JWT here: doing so rejects the MCP handshake before
|
||||
// the protocol route can verify its run-scoped credential. Keep this bypass
|
||||
// restricted to the unguessable public gateway path; all /api routes retain
|
||||
// the normal actor authentication path below.
|
||||
if (hasBearerCredentials && publicMcpGatewayProtocolPath.test(req.path)) {
|
||||
if (runIdHeader) req.actor.runId = runIdHeader;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasBearerCredentials) {
|
||||
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
|
||||
const cloudTenantActor = await resolveCloudTenantActor(db, req);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
CONNECTION_RUNTIME_TOOL_NAMES,
|
||||
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
RUNTIME_CONNECTION_TOOL_DEFINITIONS,
|
||||
wakeConnectionIntentAfterResolution,
|
||||
} from "./connection-intents.js";
|
||||
|
||||
describe("runtime connection MCP contract", () => {
|
||||
it("advertises both canonical tools with the shared descriptions and narrow schemas", () => {
|
||||
expect(
|
||||
RUNTIME_CONNECTION_TOOL_DEFINITIONS.map((tool) => tool.name),
|
||||
).toEqual(CONNECTION_RUNTIME_TOOL_NAMES);
|
||||
expect(RUNTIME_CONNECTION_TOOL_DEFINITIONS).toEqual([
|
||||
{
|
||||
name: "connections_search",
|
||||
description: CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "connection_request",
|
||||
description: CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { service: { type: "string" } },
|
||||
required: ["service"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not accept run identity, task identity, users, or credentials from tool input", () => {
|
||||
const serialized = JSON.stringify(
|
||||
RUNTIME_CONNECTION_TOOL_DEFINITIONS.map(
|
||||
(definition) => definition.inputSchema,
|
||||
),
|
||||
);
|
||||
expect(serialized).not.toMatch(
|
||||
/companyId|agentId|runId|issueId|responsibleUserId|credential|token/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection intent continuation wake contract", () => {
|
||||
it.each([
|
||||
["accepted", "connected"],
|
||||
["rejected", "declined"],
|
||||
])(
|
||||
"emits one idempotent continuation wake for %s intents",
|
||||
async (status) => {
|
||||
const wakeup = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await wakeConnectionIntentAfterResolution({ wakeup } as never, {
|
||||
loaded: {
|
||||
issue: {
|
||||
id: "issue-123",
|
||||
assigneeAgentId: "agent-123",
|
||||
status: "in_progress",
|
||||
},
|
||||
interaction: { id: "interaction-123" },
|
||||
},
|
||||
status,
|
||||
actorId: "user-123",
|
||||
});
|
||||
|
||||
expect(wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(wakeup).toHaveBeenCalledWith(
|
||||
"agent-123",
|
||||
expect.objectContaining({
|
||||
idempotencyKey: `interaction:interaction-123:${status}`,
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-123",
|
||||
contextSnapshot: expect.objectContaining({
|
||||
issueId: "issue-123",
|
||||
interactionId: "interaction-123",
|
||||
interactionStatus: status,
|
||||
forceFreshSession: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["backlog", "todo", "in_review", "done", "blocked", "cancelled"])(
|
||||
"does not wake a parked or closed %s task",
|
||||
async (issueStatus) => {
|
||||
const wakeup = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await wakeConnectionIntentAfterResolution({ wakeup } as never, {
|
||||
loaded: {
|
||||
issue: {
|
||||
id: "issue-closed",
|
||||
assigneeAgentId: "agent-123",
|
||||
status: issueStatus,
|
||||
},
|
||||
interaction: { id: "interaction-123" },
|
||||
},
|
||||
status: "accepted",
|
||||
actorId: "user-123",
|
||||
});
|
||||
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
completeConnectionIntentSchema,
|
||||
connectionRequestInputSchema,
|
||||
connectionsSearchInputSchema,
|
||||
declineConnectionIntentSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
import { verifyRuntimeToolsToken } from "../runtime-tools-token.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import type { heartbeatService } from "../services/heartbeat.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
|
||||
function bearer(req: Request) {
|
||||
const value = req.header("authorization") ?? "";
|
||||
return /^Bearer\s+/i.test(value) ? value.replace(/^Bearer\s+/i, "").trim() : "";
|
||||
}
|
||||
|
||||
function runtimeClaims(req: Request) {
|
||||
const claims = verifyRuntimeToolsToken(bearer(req));
|
||||
if (!claims) throw unauthorized("Runtime tools token is missing, invalid, or expired");
|
||||
return claims;
|
||||
}
|
||||
|
||||
function resultContent(value: unknown) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(value) }],
|
||||
structuredContent: value,
|
||||
};
|
||||
}
|
||||
|
||||
export const RUNTIME_CONNECTION_TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: "connections_search",
|
||||
description: CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "connection_request",
|
||||
description: CONNECTION_REQUEST_TOOL_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { service: { type: "string" } },
|
||||
required: ["service"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Public, token-authenticated routes mounted before the general actor middleware. */
|
||||
export function runtimeConnectionIntentRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const service = connectionIntentService(db);
|
||||
|
||||
router.get("/mcp/runtime-tools", async (req, res) => {
|
||||
await service.validate(runtimeClaims(req));
|
||||
res.json({ name: "paperclip-runtime-tools", protocolVersion: "2025-03-26" });
|
||||
});
|
||||
|
||||
router.post("/mcp/runtime-tools", async (req, res) => {
|
||||
const claims = runtimeClaims(req);
|
||||
// Streamable HTTP lifecycle calls are token uses too. Revalidate the bound
|
||||
// run before initialize/list as well as before an actual tool call so an
|
||||
// ended heartbeat cannot keep probing the endpoint with a once-valid token.
|
||||
await service.validate(claims);
|
||||
const request = req.body as { jsonrpc?: string; id?: unknown; method?: string; params?: unknown };
|
||||
const id = request.id ?? null;
|
||||
if (request.method === "initialize") {
|
||||
res.json({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "paperclip-runtime-tools", version: "1" },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "notifications/initialized") {
|
||||
res.status(202).end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "tools/list") {
|
||||
res.json({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: {
|
||||
tools: RUNTIME_CONNECTION_TOOL_DEFINITIONS,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "tools/call") {
|
||||
const params = request.params && typeof request.params === "object"
|
||||
? request.params as { name?: unknown; arguments?: unknown }
|
||||
: {};
|
||||
const name = typeof params.name === "string" ? params.name : "";
|
||||
if (name === "connections_search") {
|
||||
const input = connectionsSearchInputSchema.parse(params.arguments ?? {});
|
||||
const result = await service.search(claims, input.query);
|
||||
res.json({ jsonrpc: "2.0", id, result: resultContent(result) });
|
||||
return;
|
||||
}
|
||||
if (name === "connection_request") {
|
||||
const input = connectionRequestInputSchema.parse(params.arguments ?? {});
|
||||
const result = await service.request(claims, input.service);
|
||||
res.json({ jsonrpc: "2.0", id, result: resultContent(result) });
|
||||
return;
|
||||
}
|
||||
res.status(404).json({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32601, message: `Unknown tool: ${name || "missing"}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(404).json({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32601, message: `Unknown method: ${request.method ?? "missing"}` },
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/runtime-tools/connections/search", async (req, res) => {
|
||||
const input = connectionsSearchInputSchema.parse(req.body ?? {});
|
||||
res.json(await service.search(runtimeClaims(req), input.query));
|
||||
});
|
||||
router.post("/runtime-tools/connections/request", async (req, res) => {
|
||||
const input = connectionRequestInputSchema.parse(req.body ?? {});
|
||||
res.json(await service.request(runtimeClaims(req), input.service));
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
type Heartbeat = ReturnType<typeof heartbeatService>;
|
||||
|
||||
export async function wakeConnectionIntentAfterResolution(
|
||||
heartbeat: Pick<Heartbeat, "wakeup">,
|
||||
input: {
|
||||
loaded: {
|
||||
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||
interaction: { id: string; resolvedAt?: string | Date | null };
|
||||
};
|
||||
status: string;
|
||||
actorId: string;
|
||||
},
|
||||
) {
|
||||
const agentId = input.loaded.issue.assigneeAgentId;
|
||||
if (!agentId || input.loaded.issue.status !== "in_progress") return;
|
||||
const resolvedAt = input.loaded.interaction.resolvedAt;
|
||||
const interactionResolvedAt = resolvedAt instanceof Date ? resolvedAt.toISOString() : resolvedAt;
|
||||
await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: {
|
||||
issueId: input.loaded.issue.id,
|
||||
interactionId: input.loaded.interaction.id,
|
||||
interactionKind: "connection_intent",
|
||||
interactionStatus: input.status,
|
||||
mutation: "interaction",
|
||||
},
|
||||
idempotencyKey: `interaction:${input.loaded.interaction.id}:${input.status}`,
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: input.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: input.loaded.issue.id,
|
||||
taskId: input.loaded.issue.id,
|
||||
interactionId: input.loaded.interaction.id,
|
||||
interactionKind: "connection_intent",
|
||||
interactionStatus: input.status,
|
||||
mutation: "interaction",
|
||||
wakeReason: "issue_commented",
|
||||
source: "connection_intent.resolved",
|
||||
...(interactionResolvedAt
|
||||
? { interactionResolvedAt }
|
||||
: {}),
|
||||
forceFreshSession: true,
|
||||
},
|
||||
issueStateGuard: {
|
||||
statuses: ["in_progress"],
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function connectionIntentBoardRoutes(db: Db, heartbeat: Heartbeat) {
|
||||
const router = Router();
|
||||
const service = connectionIntentService(db);
|
||||
const access = accessService(db);
|
||||
|
||||
function bypassCurrentMembershipCheck(req: Request) {
|
||||
return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true;
|
||||
}
|
||||
|
||||
async function canManageCompanyConnections(req: Request, companyId: string) {
|
||||
if (bypassCurrentMembershipCheck(req)) return true;
|
||||
return Boolean(req.actor.userId && await access.hasPermission(
|
||||
companyId,
|
||||
"user",
|
||||
req.actor.userId,
|
||||
"tools:manage_connections",
|
||||
));
|
||||
}
|
||||
|
||||
async function addressedIntent(req: Request) {
|
||||
assertBoard(req);
|
||||
const loaded = await service.loadIntent(req.params.interactionId as string);
|
||||
assertCompanyAccess(req, loaded.issue.companyId);
|
||||
const userId = req.actor.userId ?? "local-board";
|
||||
if (loaded.interaction.addresseeUserId !== userId) {
|
||||
throw forbidden("Only the addressed user can act on this connection request");
|
||||
}
|
||||
return { loaded, userId };
|
||||
}
|
||||
|
||||
async function wakeAfterResolution(input: {
|
||||
loaded: Awaited<ReturnType<typeof service.loadIntent>>;
|
||||
status: string;
|
||||
actorId: string;
|
||||
}) {
|
||||
// The operator may park or reassign the issue while the connection work
|
||||
// and activity write are in flight. Re-read immediately before enqueueing
|
||||
// so the wake decision is not made from addressedIntent's stale snapshot.
|
||||
const current = await service.loadIntent(input.loaded.interaction.id);
|
||||
await wakeConnectionIntentAfterResolution(heartbeat, {
|
||||
...input,
|
||||
loaded: current,
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/connection-intents/:interactionId/setup-options", async (req, res) => {
|
||||
await addressedIntent(req);
|
||||
res.json(await service.setupOptions(req.params.interactionId as string));
|
||||
});
|
||||
|
||||
router.post("/connection-intents/:interactionId/phase", async (req, res) => {
|
||||
const { userId } = await addressedIntent(req);
|
||||
const phase = req.body?.phase;
|
||||
if (phase !== "requested" && phase !== "authorizing" && phase !== "needs_retry") {
|
||||
res.status(422).json({ error: "phase must be requested, authorizing, or needs_retry" });
|
||||
return;
|
||||
}
|
||||
res.json(await service.updatePhase(req.params.interactionId as string, phase, userId, {
|
||||
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
|
||||
}));
|
||||
});
|
||||
|
||||
router.post("/connection-intents/:interactionId/complete", async (req, res) => {
|
||||
const { loaded, userId } = await addressedIntent(req);
|
||||
const input = completeConnectionIntentSchema.parse(req.body);
|
||||
const interaction = await service.complete(loaded.interaction.id, input.connectionId, userId, {
|
||||
canManageOrganizationGrant: await canManageCompanyConnections(req, loaded.issue.companyId),
|
||||
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: loaded.issue.companyId,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "issue.connection_intent_connected",
|
||||
entityType: "issue",
|
||||
entityId: loaded.issue.id,
|
||||
details: {
|
||||
interactionId: interaction.id,
|
||||
connectionId: interaction.result?.connectionId ?? null,
|
||||
requestingAgentId: interaction.payload.requestingAgentId,
|
||||
},
|
||||
});
|
||||
await wakeAfterResolution({ loaded, status: interaction.status, actorId: userId });
|
||||
res.json(interaction);
|
||||
});
|
||||
|
||||
router.post("/connection-intents/:interactionId/decline", async (req, res) => {
|
||||
const { loaded, userId } = await addressedIntent(req);
|
||||
const input = declineConnectionIntentSchema.parse(req.body ?? {});
|
||||
const interaction = await service.decline(loaded.interaction.id, userId, input.reason, {
|
||||
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: loaded.issue.companyId,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "issue.connection_intent_declined",
|
||||
entityType: "issue",
|
||||
entityId: loaded.issue.id,
|
||||
details: { interactionId: interaction.id, reason: input.reason ?? null },
|
||||
});
|
||||
await wakeAfterResolution({ loaded, status: interaction.status, actorId: userId });
|
||||
res.json(interaction);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -11347,6 +11347,9 @@ export function issueRoutes(
|
|||
);
|
||||
if (!authorizedResolution) return;
|
||||
const { interactionSvc, current, resolutionAuthorization } = authorizedResolution;
|
||||
if (current.kind === "connection_intent") {
|
||||
throw unprocessable("Connection intents must be resolved through the connection intent endpoints");
|
||||
}
|
||||
const suggestedTaskEffectsAuthorized = current.kind === "suggest_tasks"
|
||||
? await assertSuggestedTaskEffectsAllowed(
|
||||
req,
|
||||
|
|
@ -11595,7 +11598,10 @@ export function issueRoutes(
|
|||
interactionId,
|
||||
);
|
||||
if (!authorizedResolution) return;
|
||||
const { interactionSvc, resolutionAuthorization } = authorizedResolution;
|
||||
const { interactionSvc, current, resolutionAuthorization } = authorizedResolution;
|
||||
if (current.kind === "connection_intent") {
|
||||
throw unprocessable("Connection intents must be resolved through the connection intent endpoints");
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const interaction = await interactionSvc.rejectInteraction(issue, interactionId, req.body, {
|
||||
|
|
|
|||
|
|
@ -204,8 +204,10 @@ import {
|
|||
startConnectionAuthorizationSchema,
|
||||
createToolStdioCommandTemplateSchema,
|
||||
disableToolStdioCommandTemplateSchema,
|
||||
finalizeOAuthAccessSchema,
|
||||
finishToolAppSchema,
|
||||
reconnectToolAppSchema,
|
||||
startToolOAuthSchema,
|
||||
updateToolConnectionSchema,
|
||||
putToolConnectionInstallsSchema,
|
||||
toolConnectionTestCallSchema,
|
||||
|
|
@ -227,6 +229,10 @@ import {
|
|||
importMcpJsonSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
createToolMcpGatewaySchema,
|
||||
completeConnectionIntentSchema,
|
||||
connectionRequestInputSchema,
|
||||
connectionsSearchInputSchema,
|
||||
declineConnectionIntentSchema,
|
||||
startClaudeSetupTokenSessionRequestSchema,
|
||||
submitBrowserCodeRequestSchema,
|
||||
claudeSetupTokenSessionResponseSchema,
|
||||
|
|
@ -784,6 +790,7 @@ function registerCurrentRoute(input: {
|
|||
|
||||
type OpenApiAuthLevel =
|
||||
| "public"
|
||||
| "runtime_tools"
|
||||
| "authenticated"
|
||||
| "board"
|
||||
| "instance_admin";
|
||||
|
|
@ -791,6 +798,7 @@ type OpenApiAuthLevel =
|
|||
const BOARD_SESSION_AUTH_SCHEME = "BoardSessionAuth";
|
||||
const BOARD_API_KEY_AUTH_SCHEME = "BoardApiKeyAuth";
|
||||
const AGENT_BEARER_AUTH_SCHEME = "AgentBearerAuth";
|
||||
const RUNTIME_TOOLS_BEARER_AUTH_SCHEME = "RuntimeToolsBearerAuth";
|
||||
|
||||
function securityRequirement(name: string): Record<string, string[]> {
|
||||
return { [name]: [] };
|
||||
|
|
@ -806,6 +814,17 @@ const AUTHENTICATED_SECURITY: Array<Record<string, string[]>> = [
|
|||
securityRequirement(AGENT_BEARER_AUTH_SCHEME),
|
||||
];
|
||||
|
||||
const RUNTIME_TOOLS_SECURITY: Array<Record<string, string[]>> = [
|
||||
securityRequirement(RUNTIME_TOOLS_BEARER_AUTH_SCHEME),
|
||||
];
|
||||
|
||||
const RUNTIME_TOOLS_OPERATIONS = new Set([
|
||||
"GET /mcp/runtime-tools",
|
||||
"POST /mcp/runtime-tools",
|
||||
"POST /runtime-tools/connections/search",
|
||||
"POST /runtime-tools/connections/request",
|
||||
]);
|
||||
|
||||
const PUBLIC_OPERATIONS = new Set([
|
||||
"GET /api/health",
|
||||
"GET /api/openapi.json",
|
||||
|
|
@ -899,7 +918,9 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
|||
"POST /api/issues/{id}/interactions/{interactionId}/respond",
|
||||
"POST /api/issues/{id}/interactions/{interactionId}/withdraw",
|
||||
"GET /api/companies/{companyId}/tools/gallery",
|
||||
"GET /api/companies/{companyId}/tools/apps/{galleryKey}/preflight",
|
||||
"POST /api/companies/{companyId}/tools/apps/connect",
|
||||
"POST /api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access",
|
||||
"POST /api/companies/{companyId}/tools/apps/{connectionId}/finish",
|
||||
"GET /api/companies/{companyId}/tools/apps/attention",
|
||||
"GET /api/companies/{companyId}/tools/action-requests",
|
||||
|
|
@ -934,6 +955,10 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
|||
"POST /api/agents/me/connections/{connectionId}/token",
|
||||
"POST /api/tools/oauth/{connectionId}/start",
|
||||
"GET /api/tools/oauth/callback",
|
||||
"GET /api/connection-intents/{interactionId}/setup-options",
|
||||
"POST /api/connection-intents/{interactionId}/phase",
|
||||
"POST /api/connection-intents/{interactionId}/complete",
|
||||
"POST /api/connection-intents/{interactionId}/decline",
|
||||
"GET /api/companies/{companyId}/tools/profiles",
|
||||
"POST /api/companies/{companyId}/tools/profiles",
|
||||
"GET /api/companies/{companyId}/tools/profiles/effective/agents/{agentId}",
|
||||
|
|
@ -1069,6 +1094,7 @@ function isBoardOnlyOperation(method: string, path: string) {
|
|||
function resolveOperationAuthLevel(method: string, path: string): OpenApiAuthLevel {
|
||||
const key = operationKey(method, path);
|
||||
if (PUBLIC_OPERATIONS.has(key)) return "public";
|
||||
if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools";
|
||||
if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin";
|
||||
if (isBoardOnlyOperation(method, path)) return "board";
|
||||
return "authenticated";
|
||||
|
|
@ -1108,6 +1134,13 @@ function applyDocumentFixups(document: any): any {
|
|||
description:
|
||||
"Agent API key or Paperclip-issued local agent JWT presented in the Authorization bearer header.",
|
||||
},
|
||||
[RUNTIME_TOOLS_BEARER_AUTH_SCHEME]: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "Heartbeat-bound runtime tools token",
|
||||
description:
|
||||
"Short-lived token bound to an active heartbeat run and presented in the Authorization bearer header.",
|
||||
},
|
||||
};
|
||||
document.security = AUTHENTICATED_SECURITY;
|
||||
|
||||
|
|
@ -1116,6 +1149,8 @@ function applyDocumentFixups(document: any): any {
|
|||
const authLevel = resolveOperationAuthLevel(method, path);
|
||||
if (authLevel === "public") {
|
||||
operation.security = [];
|
||||
} else if (authLevel === "runtime_tools") {
|
||||
operation.security = RUNTIME_TOOLS_SECURITY;
|
||||
} else if (authLevel === "authenticated") {
|
||||
operation.security = AUTHENTICATED_SECURITY;
|
||||
} else {
|
||||
|
|
@ -1127,6 +1162,8 @@ function applyDocumentFixups(document: any): any {
|
|||
? { actor: "board", instanceAdmin: true }
|
||||
: authLevel === "board"
|
||||
? { actor: "board" }
|
||||
: authLevel === "runtime_tools"
|
||||
? { actor: "runtime_tools", heartbeatBound: true }
|
||||
: authLevel === "authenticated"
|
||||
? { actor: "board_or_agent" }
|
||||
: { actor: "public" };
|
||||
|
|
@ -7143,6 +7180,79 @@ for (const route of [
|
|||
});
|
||||
}
|
||||
|
||||
// --- Connection intents ------------------------------------------------------
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/mcp/runtime-tools",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Inspect the heartbeat-bound runtime tools MCP endpoint",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/mcp/runtime-tools",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Call the heartbeat-bound runtime tools MCP endpoint",
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
202: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/runtime-tools/connections/search",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Search connections available to the active heartbeat run",
|
||||
body: connectionsSearchInputSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/runtime-tools/connections/request",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Request a connection for the active heartbeat run",
|
||||
body: connectionRequestInputSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/connection-intents/{interactionId}/setup-options",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Get setup options for an addressed connection request",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/connection-intents/{interactionId}/phase",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Update the setup phase for an addressed connection request",
|
||||
body: z.object({
|
||||
phase: z.enum(["requested", "authorizing", "needs_retry"]),
|
||||
}).strict(),
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/connection-intents/{interactionId}/complete",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Complete an addressed connection request",
|
||||
body: completeConnectionIntentSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/connection-intents/{interactionId}/decline",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Decline an addressed connection request",
|
||||
body: declineConnectionIntentSchema,
|
||||
});
|
||||
|
||||
// --- Tool access -------------------------------------------------------------
|
||||
|
||||
registerCurrentRoute({
|
||||
|
|
@ -7152,6 +7262,13 @@ registerCurrentRoute({
|
|||
summary: "List tool app gallery entries",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/tools/apps/{galleryKey}/preflight",
|
||||
tags: ["tool-access"],
|
||||
summary: "Inspect a curated app's public MCP and OAuth metadata without credentials or registration",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/tools/apps/connect",
|
||||
|
|
@ -7170,6 +7287,15 @@ registerCurrentRoute({
|
|||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access",
|
||||
tags: ["tool-access"],
|
||||
summary: "Choose personal or company-wide access after OAuth sign-in",
|
||||
body: finalizeOAuthAccessSchema,
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/tools/apps/attention",
|
||||
|
|
@ -7453,6 +7579,7 @@ registerCurrentRoute({
|
|||
path: "/api/tools/oauth/{connectionId}/start",
|
||||
tags: ["tool-access"],
|
||||
summary: "Start OAuth sign-in for a tool connection",
|
||||
body: startToolOAuthSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { connectionIntentOAuthOutcomeHtml } from "./tool-access.js";
|
||||
|
||||
describe("connection intent OAuth callback document", () => {
|
||||
it.each(["connected", "declined", "failed"] as const)(
|
||||
"posts only the interaction id and %s outcome to the same-origin opener",
|
||||
(outcome) => {
|
||||
const html = connectionIntentOAuthOutcomeHtml({
|
||||
interactionId: "interaction-123",
|
||||
issueId: "issue-456",
|
||||
outcome,
|
||||
});
|
||||
|
||||
expect(html).toContain(
|
||||
"window.opener.postMessage(message,window.location.origin)",
|
||||
);
|
||||
expect(html).toContain('"interactionId":"interaction-123"');
|
||||
expect(html).toContain(`"outcome":"${outcome}"`);
|
||||
expect(html).toContain('"type":"paperclip.connection-intent.oauth"');
|
||||
expect(html).not.toMatch(
|
||||
/connectionId|authorizationUrl|bearer|token|credential/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("closes the popup when an opener exists and otherwise returns to the same task", () => {
|
||||
const html = connectionIntentOAuthOutcomeHtml({
|
||||
interactionId: "interaction-123",
|
||||
issueId: "issue with/slash",
|
||||
outcome: "connected",
|
||||
});
|
||||
|
||||
expect(html).toContain("window.close()");
|
||||
expect(html).toContain(
|
||||
'window.location.replace("/issues/issue%20with%2Fslash")',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes script-significant interaction ids", () => {
|
||||
const html = connectionIntentOAuthOutcomeHtml({
|
||||
interactionId: "</script><script>alert(1)</script>",
|
||||
issueId: null,
|
||||
outcome: "failed",
|
||||
});
|
||||
|
||||
expect(html).not.toContain("</script><script>alert(1)</script>");
|
||||
expect(html).toContain("\\u003c/script>");
|
||||
expect(html).toContain('window.location.replace("/issues")');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companies, connectionGrants, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { agents, companies, connectionGrants, issueThreadInteractions, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import {
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
|
|
@ -24,6 +24,8 @@ import {
|
|||
disableToolStdioCommandTemplateSchema,
|
||||
duplicateToolProfileSchema,
|
||||
finishToolAppSchema,
|
||||
finalizeOAuthAccessSchema,
|
||||
startToolOAuthSchema,
|
||||
reconnectToolAppSchema,
|
||||
replaceConnectionGrantMembersSchema,
|
||||
reviewToolProfileNewToolsSchema,
|
||||
|
|
@ -54,9 +56,14 @@ import {
|
|||
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
|
||||
oauthClientIdMetadataDocument,
|
||||
} from "../services/tool-access.js";
|
||||
import { isLoopbackHost } from "../url-utils.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
import { wakeConnectionIntentAfterResolution } from "./connection-intents.js";
|
||||
import type { heartbeatService } from "../services/heartbeat.js";
|
||||
|
||||
const COMPANY_INSTALL_DENIAL_REASON =
|
||||
"Only someone who can configure this connection can choose this.";
|
||||
type Heartbeat = ReturnType<typeof heartbeatService>;
|
||||
|
||||
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
|
||||
function allowlistIds(config: Record<string, unknown> | null | undefined): string[] {
|
||||
|
|
@ -125,6 +132,26 @@ export function filterVisibleToolConnections<T extends {
|
|||
|| Boolean(actor.userId && connection.createdByUserId === actor.userId));
|
||||
}
|
||||
|
||||
export function connectionIntentOAuthOutcomeHtml(input: {
|
||||
interactionId: string;
|
||||
issueId: string | null;
|
||||
outcome: "connected" | "declined" | "failed";
|
||||
}) {
|
||||
// The callback window is only a signal. Connection identity and every
|
||||
// authorization URL stay server-side; the opener refreshes the task from the
|
||||
// interaction id instead of trusting provider-window data.
|
||||
const message = JSON.stringify({
|
||||
type: "paperclip.connection-intent.oauth",
|
||||
interactionId: input.interactionId,
|
||||
outcome: input.outcome,
|
||||
}).replace(/</g, "\\u003c");
|
||||
const issuePath = input.issueId
|
||||
? `/issues/${encodeURIComponent(input.issueId)}`
|
||||
: "/issues";
|
||||
const fallback = JSON.stringify(issuePath);
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Connection authorization</title></head><body><p>Returning to Paperclip…</p><script>const message=${message};if(window.opener&&window.opener!==window){window.opener.postMessage(message,window.location.origin);window.close();}else{window.location.replace(${fallback});}</script></body></html>`;
|
||||
}
|
||||
|
||||
export function toolAccessRoutes(
|
||||
db: Db,
|
||||
options: {
|
||||
|
|
@ -137,11 +164,77 @@ export function toolAccessRoutes(
|
|||
remoteHttpEndpointLookup?: NonNullable<Parameters<typeof toolAccessService>[1]>["remoteHttpEndpointLookup"];
|
||||
remoteHttpRequest?: NonNullable<Parameters<typeof toolAccessService>[1]>["remoteHttpRequest"];
|
||||
composioClientFactory?: (apiKey: string) => ComposioClient;
|
||||
connectionIntentHeartbeat?: Pick<Heartbeat, "wakeup">;
|
||||
} = {},
|
||||
) {
|
||||
const router = Router();
|
||||
const svc = toolAccessService(db, options);
|
||||
const policySvc = toolAccessPolicyService(db);
|
||||
const connectionIntents = connectionIntentService(db);
|
||||
|
||||
async function isConnectionIntent(interactionId: string | null | undefined) {
|
||||
if (!interactionId) return false;
|
||||
const row = await db
|
||||
.select({ kind: issueThreadInteractions.kind })
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row?.kind === "connection_intent";
|
||||
}
|
||||
|
||||
function bypassCurrentMembershipCheck(req: Request) {
|
||||
return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true;
|
||||
}
|
||||
|
||||
async function finishConnectionIntentOAuth(input: {
|
||||
interactionId: string;
|
||||
connectionId?: string;
|
||||
userId: string;
|
||||
outcome: "connected" | "declined" | "failed";
|
||||
canManageOrganizationGrant: boolean;
|
||||
bypassCurrentMembershipCheck: boolean;
|
||||
}) {
|
||||
const loaded = await connectionIntents.loadIntent(input.interactionId);
|
||||
if (loaded.interaction.addresseeUserId !== input.userId) {
|
||||
throw forbidden("OAuth callback user does not match the connection request");
|
||||
}
|
||||
if (loaded.interaction.status !== "pending") return loaded.interaction;
|
||||
const interaction = input.outcome === "connected" && input.connectionId
|
||||
? await connectionIntents.complete(input.interactionId, input.connectionId, input.userId, {
|
||||
canManageOrganizationGrant: input.canManageOrganizationGrant,
|
||||
bypassCurrentMembershipCheck: input.bypassCurrentMembershipCheck,
|
||||
})
|
||||
: input.outcome === "declined"
|
||||
? await connectionIntents.decline(
|
||||
input.interactionId,
|
||||
input.userId,
|
||||
"Authorization was declined in the provider window",
|
||||
{ bypassCurrentMembershipCheck: input.bypassCurrentMembershipCheck },
|
||||
)
|
||||
: await connectionIntents.updatePhase(input.interactionId, "needs_retry", input.userId, {
|
||||
bypassCurrentMembershipCheck: input.bypassCurrentMembershipCheck,
|
||||
});
|
||||
if (input.outcome !== "failed" && options.connectionIntentHeartbeat) {
|
||||
await wakeConnectionIntentAfterResolution(options.connectionIntentHeartbeat, {
|
||||
loaded,
|
||||
status: interaction.status,
|
||||
actorId: input.userId,
|
||||
});
|
||||
}
|
||||
return interaction;
|
||||
}
|
||||
|
||||
function sendConnectionIntentOAuthOutcome(
|
||||
res: import("express").Response,
|
||||
input: {
|
||||
interactionId: string;
|
||||
issueId: string | null;
|
||||
outcome: "connected" | "declined" | "failed";
|
||||
},
|
||||
) {
|
||||
res.type("html").send(connectionIntentOAuthOutcomeHtml(input));
|
||||
}
|
||||
|
||||
function configuredPublicBaseUrl() {
|
||||
const raw = (
|
||||
|
|
@ -160,15 +253,34 @@ export function toolAccessRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
function oauthRedirectUri() {
|
||||
const configured = configuredPublicBaseUrl();
|
||||
if (!configured) {
|
||||
function requestLoopbackBaseUrl(req: Request) {
|
||||
const host = req.get("host")?.trim();
|
||||
if (!host) return null;
|
||||
try {
|
||||
const parsed = new URL(`${req.protocol}://${host}`);
|
||||
if (
|
||||
(parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| !isLoopbackHost(parsed.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function oauthRedirectUri(req: Request) {
|
||||
const baseUrl = configuredPublicBaseUrl() ?? requestLoopbackBaseUrl(req);
|
||||
if (!baseUrl) {
|
||||
throw unprocessable(
|
||||
"This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.",
|
||||
{ code: "oauth_redirect_origin_unsupported" },
|
||||
);
|
||||
}
|
||||
return new URL("/api/tools/oauth/callback", configured).toString();
|
||||
return new URL("/api/tools/oauth/callback", baseUrl).toString();
|
||||
}
|
||||
|
||||
async function oauthAppPath(
|
||||
|
|
@ -184,6 +296,7 @@ export function toolAccessRoutes(
|
|||
if (!company) throw new Error("OAuth callback connection belongs to a missing company");
|
||||
return `/${company.issuePrefix}/apps/${connectionId}/${tab}`;
|
||||
}
|
||||
|
||||
const access = accessService(db);
|
||||
|
||||
async function assertBoardToolPermission(req: Request, companyId: string, permissionKey: PermissionKey) {
|
||||
|
|
@ -212,9 +325,9 @@ export function toolAccessRoutes(
|
|||
}
|
||||
|
||||
async function isToolConnectionManager(req: Request, companyId: string) {
|
||||
const membership = activeToolMembership(req, companyId);
|
||||
if (!membership) return true;
|
||||
if (membership.membershipRole === "owner" || membership.membershipRole === "admin") return true;
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
|
||||
return Boolean(req.actor.userId && await access.hasPermission(
|
||||
companyId,
|
||||
"user",
|
||||
|
|
@ -227,6 +340,7 @@ export function toolAccessRoutes(
|
|||
req: Request,
|
||||
connection: { companyId: string; createdByUserId?: string | null },
|
||||
) {
|
||||
activeToolMembership(req, connection.companyId);
|
||||
if (await isToolConnectionManager(req, connection.companyId)) return;
|
||||
if (req.actor.userId && connection.createdByUserId === req.actor.userId) return;
|
||||
throw forbidden(
|
||||
|
|
@ -436,7 +550,7 @@ export function toolAccessRoutes(
|
|||
subjectUserId: req.body.subjectUserId,
|
||||
scopes: req.body.scopes,
|
||||
returnTo: req.body.returnTo,
|
||||
redirectUri: oauthRedirectUri(),
|
||||
redirectUri: oauthRedirectUri(req),
|
||||
});
|
||||
res.json({ url: result.authorizationUrl });
|
||||
});
|
||||
|
|
@ -499,6 +613,14 @@ export function toolAccessRoutes(
|
|||
});
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/tools/apps/:galleryKey/preflight", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const methodKey = typeof req.query.methodKey === "string" ? req.query.methodKey.trim() || null : null;
|
||||
res.json(await svc.preflightGalleryAppMetadata(req.params.galleryKey as string, methodKey));
|
||||
});
|
||||
|
||||
/**
|
||||
* Paperclip's Client ID Metadata Document (PAP-17087).
|
||||
*
|
||||
|
|
@ -510,7 +632,7 @@ export function toolAccessRoutes(
|
|||
* data of any kind.
|
||||
*/
|
||||
router.get(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH.replace(/^\/api/, ""), (_req, res) => {
|
||||
const redirectUri = oauthRedirectUri();
|
||||
const redirectUri = oauthRedirectUri(_req);
|
||||
const clientId = new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, new URL(redirectUri).origin).toString();
|
||||
res.type("application/json").json(oauthClientIdMetadataDocument({ clientId, redirectUri }));
|
||||
});
|
||||
|
|
@ -529,9 +651,10 @@ export function toolAccessRoutes(
|
|||
// so this cannot start consent on someone else's behalf.
|
||||
const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null;
|
||||
const start = await svc.startOAuth(companyId, result.connectionId, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
redirectUri: oauthRedirectUri(req),
|
||||
actor: getActorInfo(req),
|
||||
...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}),
|
||||
...(req.body.interactionId ? { interactionId: req.body.interactionId } : {}),
|
||||
});
|
||||
result.auth.startUrl = start.authorizationUrl;
|
||||
result.auth.issuer = start.issuer ?? result.auth.issuer ?? null;
|
||||
|
|
@ -583,7 +706,7 @@ export function toolAccessRoutes(
|
|||
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
|
||||
await assertToolConnectionAccess(req, existing);
|
||||
const result = await svc.startOAuth(companyId, existing.id, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
redirectUri: oauthRedirectUri(req),
|
||||
actor: getActorInfo(req),
|
||||
subjectUserId: req.body.subjectUserId,
|
||||
scopes: req.body.scopes,
|
||||
|
|
@ -593,13 +716,27 @@ export function toolAccessRoutes(
|
|||
},
|
||||
);
|
||||
|
||||
router.post("/tools/oauth/:connectionId/start", async (req, res) => {
|
||||
router.post("/tools/oauth/:connectionId/start", validate(startToolOAuthSchema), async (req, res) => {
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
const subjectUserId = req.body?.asCurrentUser === true ? req.actor.userId ?? null : null;
|
||||
if (req.body?.asCurrentUser === true && !subjectUserId) {
|
||||
throw forbidden("Connecting an app as yourself requires a signed-in user");
|
||||
}
|
||||
if (subjectUserId && existing.credentialPolicy === "per_user") {
|
||||
// Personal reconnect is consent owned by the fixed subject, not a manager
|
||||
// configuration action. Membership blocks viewers; the service then
|
||||
// proves this caller is the connection's retained subject before it can
|
||||
// create OAuth state. Shared reconnects keep the stricter configure gate.
|
||||
activeToolMembership(req, existing.companyId);
|
||||
} else {
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
}
|
||||
const result = await svc.startOAuth(existing.companyId, existing.id, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
redirectUri: oauthRedirectUri(req),
|
||||
actor: getActorInfo(req),
|
||||
...(subjectUserId ? { subjectUserId } : {}),
|
||||
...(req.body?.interactionId ? { interactionId: req.body.interactionId } : {}),
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
|
@ -614,6 +751,7 @@ export function toolAccessRoutes(
|
|||
throw badRequest("Invalid or expired OAuth state");
|
||||
}
|
||||
const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId);
|
||||
const pendingConnectionIntent = await isConnectionIntent(pendingState.interactionId);
|
||||
if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) {
|
||||
await assertToolConnectionAccess(req, pendingConnection);
|
||||
} else {
|
||||
|
|
@ -636,6 +774,22 @@ export function toolAccessRoutes(
|
|||
entityId: result.connection.id,
|
||||
details: { applicationId: result.application.id, catalogEntryCount: result.catalog.length, provider: "gmail" },
|
||||
});
|
||||
if (acceptsHtml && pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
|
||||
await finishConnectionIntentOAuth({
|
||||
interactionId: pendingState.interactionId,
|
||||
connectionId: result.connection.id,
|
||||
userId: req.actor.userId,
|
||||
outcome: "connected",
|
||||
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
|
||||
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
|
||||
});
|
||||
sendConnectionIntentOAuthOutcome(res, {
|
||||
interactionId: pendingState.interactionId,
|
||||
issueId: pendingState.issueId,
|
||||
outcome: "connected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (acceptsHtml) {
|
||||
const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test");
|
||||
res.redirect(303, `${testPath}?success=1`);
|
||||
|
|
@ -647,6 +801,22 @@ export function toolAccessRoutes(
|
|||
const details = callbackError instanceof HttpError && callbackError.details && typeof callbackError.details === "object"
|
||||
? callbackError.details as Record<string, unknown>
|
||||
: null;
|
||||
if (pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
|
||||
const outcome = details?.code === "oauth_authorization_denied" ? "declined" : "failed";
|
||||
await finishConnectionIntentOAuth({
|
||||
interactionId: pendingState.interactionId,
|
||||
userId: req.actor.userId,
|
||||
outcome,
|
||||
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
|
||||
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
|
||||
});
|
||||
sendConnectionIntentOAuthOutcome(res, {
|
||||
interactionId: pendingState.interactionId,
|
||||
issueId: pendingState.issueId,
|
||||
outcome,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ oauth: details?.code === "oauth_authorization_denied" ? "denied" : "failed" });
|
||||
if (typeof details?.code === "string") params.set("code", details.code);
|
||||
const setupPath = await oauthAppPath(pendingState.companyId, pendingState.connectionId, "setup");
|
||||
|
|
@ -668,6 +838,7 @@ export function toolAccessRoutes(
|
|||
throw badRequest("Invalid or expired OAuth state");
|
||||
}
|
||||
const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId);
|
||||
const pendingConnectionIntent = await isConnectionIntent(pendingState.interactionId);
|
||||
if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) {
|
||||
await assertToolConnectionAccess(req, pendingConnection);
|
||||
} else {
|
||||
|
|
@ -684,7 +855,7 @@ export function toolAccessRoutes(
|
|||
// A provider denial is bound and consumed by state alone. Avoid
|
||||
// requiring this deployment's callback origin just to record that the
|
||||
// user declined; successful code exchange still validates the origin.
|
||||
redirectUri: error ? "" : oauthRedirectUri(),
|
||||
redirectUri: error ? "" : oauthRedirectUri(req),
|
||||
actor: getActorInfo(req),
|
||||
});
|
||||
} catch (callbackError) {
|
||||
|
|
@ -696,10 +867,46 @@ export function toolAccessRoutes(
|
|||
? callbackError.details as Record<string, unknown>
|
||||
: null;
|
||||
const callbackErrorCode = typeof details?.code === "string" ? details.code : null;
|
||||
const callbackFailureCode = callbackErrorCode
|
||||
?? (callbackError instanceof HttpError ? `oauth_callback_http_${callbackError.status}` : "oauth_callback_failed");
|
||||
await logActivity(db, {
|
||||
companyId: pendingState.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "tool_app.oauth_failed",
|
||||
entityType: "tool_connection",
|
||||
entityId: pendingState.connectionId,
|
||||
details: {
|
||||
code: callbackFailureCode,
|
||||
status: callbackError instanceof HttpError ? callbackError.status : 500,
|
||||
// HttpError messages are Paperclip-authored. Provider-authored
|
||||
// error_description/error_uri values are never read above and cannot
|
||||
// be reflected into the activity stream.
|
||||
message: callbackError instanceof HttpError
|
||||
? callbackError.message
|
||||
: "OAuth callback failed unexpectedly.",
|
||||
},
|
||||
});
|
||||
if (pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
|
||||
const outcome = callbackErrorCode === "oauth_authorization_denied" ? "declined" : "failed";
|
||||
await finishConnectionIntentOAuth({
|
||||
interactionId: pendingState.interactionId,
|
||||
userId: req.actor.userId,
|
||||
outcome,
|
||||
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
|
||||
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
|
||||
});
|
||||
sendConnectionIntentOAuthOutcome(res, {
|
||||
interactionId: pendingState.interactionId,
|
||||
issueId: pendingState.issueId,
|
||||
outcome,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
oauth: callbackErrorCode === "oauth_authorization_denied" ? "denied" : "failed",
|
||||
});
|
||||
if (callbackErrorCode) params.set("code", callbackErrorCode);
|
||||
params.set("code", callbackFailureCode);
|
||||
const setupPath = await oauthAppPath(pendingState.companyId, pendingState.connectionId, "setup");
|
||||
res.redirect(303, `${setupPath}?${params.toString()}`);
|
||||
return;
|
||||
|
|
@ -716,6 +923,22 @@ export function toolAccessRoutes(
|
|||
catalogEntryCount: result.catalog.length,
|
||||
},
|
||||
});
|
||||
if (acceptsHtml && pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
|
||||
await finishConnectionIntentOAuth({
|
||||
interactionId: pendingState.interactionId,
|
||||
connectionId: result.connection.id,
|
||||
userId: req.actor.userId,
|
||||
outcome: "connected",
|
||||
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
|
||||
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
|
||||
});
|
||||
sendConnectionIntentOAuthOutcome(res, {
|
||||
interactionId: pendingState.interactionId,
|
||||
issueId: pendingState.issueId,
|
||||
outcome: "connected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (acceptsHtml) {
|
||||
const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test");
|
||||
res.redirect(303, `${testPath}?success=1`);
|
||||
|
|
@ -724,6 +947,31 @@ export function toolAccessRoutes(
|
|||
res.json(result);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/tools/apps/:connectionId/finalize-oauth-access",
|
||||
validate(finalizeOAuthAccessSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
const result = await svc.finalizeOAuthAccess(companyId, existing.id, req.body, getActorInfo(req));
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "tool_app.oauth_access_finalized",
|
||||
entityType: "tool_connection",
|
||||
entityId: result.connection.id,
|
||||
details: {
|
||||
grantKind: req.body.grantKind,
|
||||
profileId: result.profile.id,
|
||||
profileEntryCount: result.profileEntries.length,
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeToolsToken, verifyRuntimeToolsToken } from "./runtime-tools-token.js";
|
||||
|
||||
describe("runtime connection tools token", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("binds the token to company, agent, run, responsible user, and scope", () => {
|
||||
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "test-runtime-tools-secret");
|
||||
vi.setSystemTime(new Date("2026-08-26T12:00:00.000Z"));
|
||||
const minted = createRuntimeToolsToken({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
responsibleUserId: "user-1",
|
||||
});
|
||||
expect(minted).not.toBeNull();
|
||||
expect(verifyRuntimeToolsToken(minted!.token)).toMatchObject({
|
||||
sub: "agent-1",
|
||||
company_id: "company-1",
|
||||
run_id: "run-1",
|
||||
responsible_user_id: "user-1",
|
||||
scope: "connection_intents",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects tampering and expiry", () => {
|
||||
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "test-runtime-tools-secret");
|
||||
vi.setSystemTime(new Date("2026-08-26T12:00:00.000Z"));
|
||||
const minted = createRuntimeToolsToken({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
responsibleUserId: "user-1",
|
||||
})!;
|
||||
expect(verifyRuntimeToolsToken(`${minted.token.slice(0, -1)}x`)).toBeNull();
|
||||
vi.setSystemTime(new Date("2026-08-26T13:00:01.000Z"));
|
||||
expect(verifyRuntimeToolsToken(minted.token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { resolvePaperclipInstanceId } from "./home-paths.js";
|
||||
|
||||
export interface RuntimeToolsTokenClaims {
|
||||
sub: string;
|
||||
company_id: string;
|
||||
run_id: string;
|
||||
responsible_user_id: string;
|
||||
scope: "connection_intents";
|
||||
iat: number;
|
||||
exp: number;
|
||||
instance_id: string;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_SECONDS = 60 * 60;
|
||||
|
||||
function secret() {
|
||||
return process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()
|
||||
|| process.env.BETTER_AUTH_SECRET?.trim()
|
||||
|| null;
|
||||
}
|
||||
|
||||
function encode(value: unknown) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function sign(value: string, companyId: string, instanceId: string) {
|
||||
const master = secret();
|
||||
if (!master) return null;
|
||||
const key = createHmac("sha256", master)
|
||||
.update(`runtime-tools:${instanceId}:${companyId}`)
|
||||
.digest();
|
||||
return createHmac("sha256", key).update(value).digest("base64url");
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string) {
|
||||
const a = Buffer.from(left);
|
||||
const b = Buffer.from(right);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function createRuntimeToolsToken(input: {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
runId: string;
|
||||
responsibleUserId: string;
|
||||
}) {
|
||||
if (!secret()) return null;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
const claims: RuntimeToolsTokenClaims = {
|
||||
sub: input.agentId,
|
||||
company_id: input.companyId,
|
||||
run_id: input.runId,
|
||||
responsible_user_id: input.responsibleUserId,
|
||||
scope: "connection_intents",
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_SECONDS,
|
||||
instance_id: instanceId,
|
||||
};
|
||||
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
|
||||
const signature = sign(signingInput, input.companyId, instanceId);
|
||||
return signature
|
||||
? { token: `${signingInput}.${signature}`, expiresAt: new Date(claims.exp * 1000).toISOString() }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
let header: Record<string, unknown>;
|
||||
let claims: Record<string, unknown>;
|
||||
try {
|
||||
header = JSON.parse(Buffer.from(parts[0]!, "base64url").toString("utf8"));
|
||||
claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (header.alg !== "HS256") return null;
|
||||
const companyId = typeof claims.company_id === "string" ? claims.company_id : null;
|
||||
const instanceId = typeof claims.instance_id === "string" ? claims.instance_id : null;
|
||||
if (!companyId || !instanceId || instanceId !== resolvePaperclipInstanceId()) return null;
|
||||
const expected = sign(`${parts[0]}.${parts[1]}`, companyId, instanceId);
|
||||
if (!expected || !safeEqual(parts[2]!, expected)) return null;
|
||||
if (
|
||||
typeof claims.sub !== "string"
|
||||
|| typeof claims.run_id !== "string"
|
||||
|| typeof claims.responsible_user_id !== "string"
|
||||
|| claims.scope !== "connection_intents"
|
||||
|| typeof claims.iat !== "number"
|
||||
|| typeof claims.exp !== "number"
|
||||
|| claims.exp <= Math.floor(Date.now() / 1000)
|
||||
) return null;
|
||||
return claims as unknown as RuntimeToolsTokenClaims;
|
||||
}
|
||||
|
|
@ -351,7 +351,7 @@ function agentIsInSubtree(
|
|||
return false;
|
||||
}
|
||||
|
||||
async function loadCompanyAgentHierarchy(db: Db, companyId: string) {
|
||||
async function loadCompanyAgentHierarchy(db: Db | DbTransaction, companyId: string) {
|
||||
const rows = await db
|
||||
.select({ id: agents.id, reportsTo: agents.reportsTo })
|
||||
.from(agents)
|
||||
|
|
@ -359,7 +359,12 @@ async function loadCompanyAgentHierarchy(db: Db, companyId: string) {
|
|||
return new Map(rows.map((agent) => [agent.id, agent]));
|
||||
}
|
||||
|
||||
async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) {
|
||||
async function isAgentInSubtree(
|
||||
db: Db | DbTransaction,
|
||||
companyId: string,
|
||||
rootAgentId: string,
|
||||
targetAgentId: string,
|
||||
) {
|
||||
return agentIsInSubtree(
|
||||
await loadCompanyAgentHierarchy(db, companyId),
|
||||
rootAgentId,
|
||||
|
|
@ -368,7 +373,7 @@ async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string,
|
|||
}
|
||||
|
||||
async function scopeAllows(
|
||||
db: Db,
|
||||
db: Db | DbTransaction,
|
||||
companyId: string,
|
||||
grantScope: Record<string, unknown> | null,
|
||||
requestedScope: Record<string, unknown> | null | undefined,
|
||||
|
|
@ -526,7 +531,9 @@ export function authorizationDeniedDetails(decision: AuthorizationDecision) {
|
|||
};
|
||||
}
|
||||
|
||||
export function authorizationService(db: Db) {
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export function authorizationService(db: Db | DbTransaction) {
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companyMemberships,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
connectionIntentPayloadSchema,
|
||||
getAvailableConnectionMethods,
|
||||
getConnectableAppDefinition,
|
||||
type ConnectionIntentInteraction,
|
||||
type ConnectionIntentSetupOptions,
|
||||
type ConnectionRequestResult,
|
||||
type ConnectionsSearchResult,
|
||||
type ToolApplication,
|
||||
type ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
|
||||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||||
import { toolAccessService } from "./tool-access.js";
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function sourceSlugForApplication(application: ToolApplication | undefined) {
|
||||
return text(application?.metadata?.sourceTemplateKey) ?? text(application?.metadata?.galleryKey);
|
||||
}
|
||||
|
||||
function sourceSlugForConnection(
|
||||
connection: ToolConnection,
|
||||
applications: ReadonlyMap<string, ToolApplication>,
|
||||
) {
|
||||
return text(connection.config?.sourceTemplateKey)
|
||||
?? text(connection.transportConfig?.sourceTemplateKey)
|
||||
?? sourceSlugForApplication(applications.get(connection.applicationId));
|
||||
}
|
||||
|
||||
function displayDescription(app: (typeof CONNECTABLE_APP_DEFINITIONS)[number]) {
|
||||
return text(app.description) ?? null;
|
||||
}
|
||||
|
||||
export function connectionIntentService(db: Db) {
|
||||
const interactions = issueThreadInteractionService(db);
|
||||
const access = toolAccessService(db);
|
||||
|
||||
async function assertCurrentUserWriteAccess(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
bypassCurrentMembershipCheck = false,
|
||||
) {
|
||||
if (bypassCurrentMembershipCheck) return;
|
||||
const membership = await db
|
||||
.select({
|
||||
status: companyMemberships.status,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!membership
|
||||
|| membership.status !== "active"
|
||||
|| !membership.membershipRole
|
||||
|| membership.membershipRole === "viewer"
|
||||
) {
|
||||
throw forbidden("Addressed user is no longer authorized for company write access");
|
||||
}
|
||||
}
|
||||
|
||||
async function lockCurrentUserWriteAccess(
|
||||
tx: DbTransaction,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
bypassCurrentMembershipCheck = false,
|
||||
) {
|
||||
if (bypassCurrentMembershipCheck) return;
|
||||
const membership = await tx
|
||||
.select({
|
||||
status: companyMemberships.status,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId),
|
||||
))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!membership
|
||||
|| membership.status !== "active"
|
||||
|| !membership.membershipRole
|
||||
|| membership.membershipRole === "viewer"
|
||||
) {
|
||||
throw forbidden("Addressed user is no longer authorized for company write access");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRunContext(claims: RuntimeToolsTokenClaims) {
|
||||
const run = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, claims.run_id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!run
|
||||
|| run.companyId !== claims.company_id
|
||||
|| run.agentId !== claims.sub
|
||||
|| run.responsibleUserId !== claims.responsible_user_id
|
||||
) throw forbidden("Runtime tool token does not match its heartbeat run");
|
||||
if (run.status !== "running") throw forbidden("Runtime tool token is no longer active");
|
||||
const snapshot = record(run.contextSnapshot);
|
||||
const issueId = text(snapshot?.issueId) ?? text(snapshot?.taskId);
|
||||
if (!issueId) throw unprocessable("Connection requests require a task-bound heartbeat run");
|
||||
const [issue, agent, responsibleMembership] = await Promise.all([
|
||||
db.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
}).from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).then((rows) => rows[0] ?? null),
|
||||
db.select({ id: agents.id, companyId: agents.companyId, name: agents.name })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({
|
||||
status: companyMemberships.status,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
}).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, run.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, run.responsibleUserId!),
|
||||
)).then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
if (!issue || !agent) throw notFound("Runtime task or agent was not found");
|
||||
if (
|
||||
!responsibleMembership
|
||||
|| responsibleMembership.status !== "active"
|
||||
|| !responsibleMembership.membershipRole
|
||||
|| responsibleMembership.membershipRole === "viewer"
|
||||
) {
|
||||
throw forbidden("Responsible user is no longer authorized for company write access");
|
||||
}
|
||||
if (issue.status === "done" || issue.status === "cancelled") {
|
||||
throw conflict("Connection requests cannot be created on a closed task");
|
||||
}
|
||||
return { run, issue, agent };
|
||||
}
|
||||
|
||||
async function connectionInventory(companyId: string) {
|
||||
const [applications, connections] = await Promise.all([
|
||||
access.listApplications(companyId),
|
||||
access.listConnections(companyId),
|
||||
]);
|
||||
return {
|
||||
applications,
|
||||
connections,
|
||||
applicationsById: new Map(applications.map((application) => [application.id, application] as const)),
|
||||
};
|
||||
}
|
||||
|
||||
async function usableConnectionForAgent(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
responsibleUserId: string;
|
||||
serviceSlug: string;
|
||||
inventory?: Awaited<ReturnType<typeof connectionInventory>>;
|
||||
}) {
|
||||
const inventory = input.inventory ?? await connectionInventory(input.companyId);
|
||||
const matching = inventory.connections.filter((connection) =>
|
||||
sourceSlugForConnection(connection, inventory.applicationsById) === input.serviceSlug
|
||||
&& connection.status === "active"
|
||||
&& connection.enabled
|
||||
);
|
||||
if (matching.length === 0) return null;
|
||||
const effective = await access.getEffectiveProfilesForAgent(input.companyId, input.agentId);
|
||||
const installedIds = new Set(effective.installedConnections.map((connection) => connection.id));
|
||||
for (const connection of matching) {
|
||||
if (!installedIds.has(connection.id)) continue;
|
||||
const { grants } = await access.listConnectionGrants(connection.id, input.companyId);
|
||||
const usable = grants.some((grant) => {
|
||||
if (grant.status !== "active") return false;
|
||||
if (grant.kind === "organization") return true;
|
||||
return grant.subjectUserId === input.responsibleUserId
|
||||
&& grant.delegations.some((delegation) => delegation.agentId === input.agentId);
|
||||
});
|
||||
if (usable) return connection;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function search(claims: RuntimeToolsTokenClaims, query: string): Promise<ConnectionsSearchResult> {
|
||||
const { run, agent } = await loadRunContext(claims);
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
const inventory = await connectionInventory(run.companyId);
|
||||
const results = await Promise.all(CONNECTABLE_APP_DEFINITIONS
|
||||
.filter((app) => !normalized
|
||||
|| app.slug.toLocaleLowerCase().includes(normalized)
|
||||
|| app.name.toLocaleLowerCase().includes(normalized)
|
||||
|| displayDescription(app)?.toLocaleLowerCase().includes(normalized))
|
||||
.map(async (app) => {
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
const matching = inventory.connections.filter((connection) =>
|
||||
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
|
||||
&& connection.status !== "archived"
|
||||
);
|
||||
const ready = await usableConnectionForAgent({
|
||||
companyId: run.companyId,
|
||||
agentId: agent.id,
|
||||
responsibleUserId: run.responsibleUserId!,
|
||||
serviceSlug: app.slug,
|
||||
inventory,
|
||||
});
|
||||
return {
|
||||
service: app.slug,
|
||||
name: app.name,
|
||||
description: displayDescription(app),
|
||||
logoUrl: app.branding.logoUrl ?? null,
|
||||
methods: methods.map((method) => ({
|
||||
key: method.key,
|
||||
label: method.label ?? method.key,
|
||||
auth: method.auth,
|
||||
})),
|
||||
state: app.availability?.available === false || methods.length === 0
|
||||
? "unavailable" as const
|
||||
: ready
|
||||
? "ready" as const
|
||||
: matching.length > 0
|
||||
? "needs_user_action" as const
|
||||
: "available" as const,
|
||||
connectionId: ready?.id ?? null,
|
||||
};
|
||||
}));
|
||||
return { version: 1, query, results };
|
||||
}
|
||||
|
||||
async function request(
|
||||
claims: RuntimeToolsTokenClaims,
|
||||
serviceSlug: string,
|
||||
): Promise<ConnectionRequestResult> {
|
||||
const context = await loadRunContext(claims);
|
||||
const app = getConnectableAppDefinition(serviceSlug);
|
||||
if (!app || app.availability?.available === false || getAvailableConnectionMethods(app).length === 0) {
|
||||
throw unprocessable(`Connection service ${serviceSlug} is not available`);
|
||||
}
|
||||
const ready = await usableConnectionForAgent({
|
||||
companyId: context.run.companyId,
|
||||
agentId: context.agent.id,
|
||||
responsibleUserId: context.run.responsibleUserId!,
|
||||
serviceSlug: app.slug,
|
||||
});
|
||||
if (ready) {
|
||||
return {
|
||||
version: 1,
|
||||
service: app.slug,
|
||||
state: "ready",
|
||||
connectionId: ready.id,
|
||||
interactionId: null,
|
||||
instruction: `${app.name} is connected and available in this run.`,
|
||||
};
|
||||
}
|
||||
const interaction = await interactions.createConnectionIntent(
|
||||
context.issue,
|
||||
{
|
||||
payload: {
|
||||
version: 1,
|
||||
serviceSlug: app.slug,
|
||||
serviceName: app.name,
|
||||
serviceLogoUrl: app.branding.logoUrl ?? null,
|
||||
serviceDarkLogoUrl: app.branding.darkLogoUrl ?? null,
|
||||
requestingAgentId: context.agent.id,
|
||||
requestingAgentName: context.agent.name,
|
||||
phase: "requested",
|
||||
},
|
||||
sourceRunId: context.run.id,
|
||||
addresseeUserId: context.run.responsibleUserId!,
|
||||
idempotencyKey: `connection-intent:${context.run.id}:${app.slug}`,
|
||||
},
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
service: app.slug,
|
||||
state: "needs_user_action",
|
||||
connectionId: null,
|
||||
interactionId: interaction.id,
|
||||
instruction: `A connection card was sent to the responsible user. End this run and wait for continuation.`,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadIntent(interactionId: string) {
|
||||
const row = await db
|
||||
.select({ interaction: issueThreadInteractions, issue: issues })
|
||||
.from(issueThreadInteractions)
|
||||
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row || row.interaction.kind !== "connection_intent") throw notFound("Connection intent not found");
|
||||
const interaction = await interactions.getForIssue(row.issue, interactionId) as ConnectionIntentInteraction;
|
||||
return { ...row, interaction };
|
||||
}
|
||||
|
||||
async function setupOptions(interactionId: string): Promise<ConnectionIntentSetupOptions> {
|
||||
const loaded = await loadIntent(interactionId);
|
||||
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
|
||||
const app = getConnectableAppDefinition(payload.serviceSlug);
|
||||
if (!app) throw notFound("Connection service is no longer available");
|
||||
const inventory = await connectionInventory(loaded.issue.companyId);
|
||||
const matchingConnections = inventory.connections.filter((connection) =>
|
||||
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
|
||||
&& connection.status === "active"
|
||||
&& connection.enabled
|
||||
);
|
||||
const existingConnections = (await Promise.all(matchingConnections.map(async (connection) => {
|
||||
const { grants } = await access.listConnectionGrants(connection.id, loaded.issue.companyId);
|
||||
const eligible = grants.some((grant) =>
|
||||
grant.status === "active"
|
||||
&& (grant.kind === "organization" || grant.subjectUserId === loaded.interaction.addresseeUserId)
|
||||
);
|
||||
return eligible ? connection : null;
|
||||
}))).filter((connection): connection is ToolConnection => connection !== null);
|
||||
return {
|
||||
version: 1,
|
||||
interaction: loaded.interaction,
|
||||
service: {
|
||||
service: app.slug,
|
||||
name: app.name,
|
||||
description: displayDescription(app),
|
||||
logoUrl: app.branding.logoUrl ?? null,
|
||||
methods: getAvailableConnectionMethods(app).map((method) => ({
|
||||
key: method.key,
|
||||
label: method.label ?? method.key,
|
||||
auth: method.auth,
|
||||
})),
|
||||
state: existingConnections.length > 0 ? "needs_user_action" : "available",
|
||||
connectionId: null,
|
||||
},
|
||||
existingConnections,
|
||||
requestedAgentId: payload.requestingAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function complete(
|
||||
interactionId: string,
|
||||
connectionId: string,
|
||||
userId: string,
|
||||
options: {
|
||||
canManageOrganizationGrant?: boolean;
|
||||
bypassCurrentMembershipCheck?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const loaded = await loadIntent(interactionId);
|
||||
if (loaded.interaction.status !== "pending") throw conflict("Connection intent is already resolved");
|
||||
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can connect this service");
|
||||
await assertCurrentUserWriteAccess(
|
||||
loaded.issue.companyId,
|
||||
userId,
|
||||
options.bypassCurrentMembershipCheck,
|
||||
);
|
||||
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
|
||||
return db.transaction(async (tx) => {
|
||||
// Membership downgrade/removal takes the same row lock. Whichever side
|
||||
// commits first is authoritative: a completed revocation makes this
|
||||
// revalidation fail, while completion holds authority through OAuth
|
||||
// finalization, every install/delegation, and intent resolution.
|
||||
await lockCurrentUserWriteAccess(
|
||||
tx,
|
||||
loaded.issue.companyId,
|
||||
userId,
|
||||
options.bypassCurrentMembershipCheck,
|
||||
);
|
||||
const txDb = tx as unknown as Db;
|
||||
const txAccess = toolAccessService(txDb);
|
||||
const txInteractions = issueThreadInteractionService(txDb);
|
||||
let selectedConnection = await txAccess.getConnection(connectionId, loaded.issue.companyId);
|
||||
const selectedApplication = await txAccess.getApplication(
|
||||
selectedConnection.applicationId,
|
||||
loaded.issue.companyId,
|
||||
);
|
||||
if (sourceSlugForConnection(
|
||||
selectedConnection,
|
||||
new Map([[selectedApplication.id, selectedApplication]]),
|
||||
) !== payload.serviceSlug) {
|
||||
throw notFound("Connection does not match this intent");
|
||||
}
|
||||
if (selectedConnection.status !== "active" || !selectedConnection.enabled) {
|
||||
throw conflict("Finish and test this connection before using it for the task");
|
||||
}
|
||||
|
||||
let { grants } = await txAccess.listConnectionGrants(
|
||||
selectedConnection.id,
|
||||
loaded.issue.companyId,
|
||||
);
|
||||
const pendingPersonalGrant = grants.find((grant) =>
|
||||
grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId
|
||||
);
|
||||
if (selectedConnection.authKind === "oauth" && pendingPersonalGrant) {
|
||||
// txAccess is bound to the outer transaction. Its internal transactions
|
||||
// become savepoints, so activation, credential bindings, the all-agents
|
||||
// profile, and the company install roll back with any later failure.
|
||||
await txAccess.finalizeOAuthAccess(
|
||||
loaded.issue.companyId,
|
||||
selectedConnection.id,
|
||||
{ grantKind: "user" },
|
||||
{ actorType: "user", actorId: userId },
|
||||
);
|
||||
selectedConnection = await txAccess.getConnection(
|
||||
selectedConnection.id,
|
||||
loaded.issue.companyId,
|
||||
);
|
||||
({ grants } = await txAccess.listConnectionGrants(
|
||||
selectedConnection.id,
|
||||
loaded.issue.companyId,
|
||||
));
|
||||
}
|
||||
const personalGrant = grants.find((grant) =>
|
||||
grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId
|
||||
);
|
||||
const organizationGrant = grants.find((grant) =>
|
||||
grant.kind === "organization" && grant.status === "active"
|
||||
);
|
||||
if (!personalGrant && !organizationGrant) {
|
||||
throw conflict("This connection has no usable identity grant");
|
||||
}
|
||||
if (!personalGrant && !options.canManageOrganizationGrant) {
|
||||
throw forbidden("Sharing a company connection requires connection-management authority");
|
||||
}
|
||||
|
||||
if (personalGrant) {
|
||||
await txAccess.createConnectionGrantDelegation(
|
||||
selectedConnection.id,
|
||||
personalGrant.id,
|
||||
payload.requestingAgentId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
const installs = await txAccess.listConnectionInstalls(
|
||||
selectedConnection.id,
|
||||
loaded.issue.companyId,
|
||||
);
|
||||
const requestedInstall = { targetType: "agent" as const, targetId: payload.requestingAgentId };
|
||||
const additiveInstalls = installs.some((install) =>
|
||||
install.targetType === requestedInstall.targetType && install.targetId === requestedInstall.targetId
|
||||
) ? installs : [...installs, requestedInstall];
|
||||
await txAccess.putConnectionInstalls(selectedConnection.id, { installs: additiveInstalls }, {
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
});
|
||||
|
||||
return txInteractions.resolveConnectionIntent(
|
||||
loaded.issue,
|
||||
interactionId,
|
||||
{ version: 1, outcome: "connected", connectionId: selectedConnection.id },
|
||||
{ userId },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function decline(
|
||||
interactionId: string,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
options: { bypassCurrentMembershipCheck?: boolean } = {},
|
||||
) {
|
||||
const loaded = await loadIntent(interactionId);
|
||||
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can decline this request");
|
||||
await assertCurrentUserWriteAccess(
|
||||
loaded.issue.companyId,
|
||||
userId,
|
||||
options.bypassCurrentMembershipCheck,
|
||||
);
|
||||
return interactions.resolveConnectionIntent(
|
||||
loaded.issue,
|
||||
interactionId,
|
||||
{ version: 1, outcome: "declined", reason: reason?.trim() || null },
|
||||
{ userId },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
validate: loadRunContext,
|
||||
search,
|
||||
request,
|
||||
loadIntent,
|
||||
setupOptions,
|
||||
complete,
|
||||
decline,
|
||||
updatePhase: async (
|
||||
interactionId: string,
|
||||
phase: "requested" | "authorizing" | "needs_retry",
|
||||
userId: string,
|
||||
options: { bypassCurrentMembershipCheck?: boolean } = {},
|
||||
) => {
|
||||
const loaded = await loadIntent(interactionId);
|
||||
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can update this request");
|
||||
await assertCurrentUserWriteAccess(
|
||||
loaded.issue.companyId,
|
||||
userId,
|
||||
options.bypassCurrentMembershipCheck,
|
||||
);
|
||||
return interactions.updateConnectionIntentPhase(loaded.issue, interactionId, phase, { userId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import { and, asc, desc, eq, getTableColumns, gt, gte, inArray, isNull, lt, lte,
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
CONNECTION_RUNTIME_TOOL_NAMES,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
ISSUE_DISPOSITION_REPAIR_RETRY_REASON,
|
||||
MAX_TASK_DRAIN_TTL_MS,
|
||||
|
|
@ -99,10 +101,12 @@ import type {
|
|||
AdapterRuntimeEvent,
|
||||
AdapterRuntimeMcpAccess,
|
||||
AdapterRuntimeMcpServer,
|
||||
AdapterRuntimeToolAccess,
|
||||
AdapterSessionCodec,
|
||||
UsageSummary,
|
||||
} from "../adapters/index.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithByteCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry";
|
||||
|
|
@ -2749,6 +2753,10 @@ interface WakeupOptions {
|
|||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
issueStateGuard?: {
|
||||
statuses: string[];
|
||||
assigneeAgentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
type UsageTotals = {
|
||||
|
|
@ -3467,12 +3475,19 @@ type ManagedMcpGatewayRunConfig = {
|
|||
}>;
|
||||
};
|
||||
|
||||
function paperclipApiBaseUrl(): string {
|
||||
function configuredPaperclipApiBaseUrl(): string | null {
|
||||
const configured = readNonEmptyString(process.env.PAPERCLIP_API_URL);
|
||||
return configured
|
||||
? configured.replace(/\/+$/, "").replace(/\/api$/, "")
|
||||
: null;
|
||||
}
|
||||
|
||||
function paperclipApiBaseUrl(): string {
|
||||
const configured = configuredPaperclipApiBaseUrl();
|
||||
if (!configured) {
|
||||
throw new Error("PAPERCLIP_API_URL is required to deliver managed runtime MCP servers");
|
||||
}
|
||||
return configured.replace(/\/+$/, "").replace(/\/api$/, "");
|
||||
return configured;
|
||||
}
|
||||
|
||||
export async function revokeHeartbeatRunGatewayTokens(input: {
|
||||
|
|
@ -3607,7 +3622,11 @@ export async function buildPaperclipRuntimeMcpServers(input: {
|
|||
});
|
||||
servers.push({
|
||||
name: connection.name,
|
||||
url: `${paperclipApiBaseUrl()}/api/tool-gateway/gateways/${gateway.id}/mcp`,
|
||||
// Runtime MCP clients authenticate with a short-lived gateway bearer, not
|
||||
// a Paperclip agent JWT. Route them through the public gateway protocol
|
||||
// endpoint mounted ahead of the API auth middleware; the gateway service
|
||||
// still validates the bearer and its run binding on every request.
|
||||
url: `${paperclipApiBaseUrl()}/mcp/gateways/${gateway.gatewayPublicId}`,
|
||||
token: token.token,
|
||||
connectionId: connection.id,
|
||||
});
|
||||
|
|
@ -3633,6 +3652,40 @@ function createAdapterRuntimeMcpAccess(
|
|||
});
|
||||
}
|
||||
|
||||
function createAdapterRuntimeToolAccess(input: {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
runId: string;
|
||||
responsibleUserId: string | null;
|
||||
}): AdapterRuntimeToolAccess | undefined {
|
||||
if (!input.responsibleUserId) return undefined;
|
||||
const minted = createRuntimeToolsToken({
|
||||
agentId: input.agentId,
|
||||
companyId: input.companyId,
|
||||
runId: input.runId,
|
||||
responsibleUserId: input.responsibleUserId,
|
||||
});
|
||||
if (!minted) return undefined;
|
||||
// The normal server bootstrap always exports PAPERCLIP_API_URL. Some service
|
||||
// tests invoke heartbeat execution without booting an HTTP server, however;
|
||||
// in that context there is no reachable endpoint to advertise and runtime
|
||||
// tools should simply remain unavailable instead of failing the run.
|
||||
const baseUrl = configuredPaperclipApiBaseUrl();
|
||||
if (!baseUrl) return undefined;
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
guidance: CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
mcpEndpoint: `${baseUrl}/mcp/runtime-tools`,
|
||||
rest: {
|
||||
connectionsSearch: `${baseUrl}/runtime-tools/connections/search`,
|
||||
connectionRequest: `${baseUrl}/runtime-tools/connections/request`,
|
||||
},
|
||||
bearerToken: minted.token,
|
||||
expiresAt: minted.expiresAt,
|
||||
tools: CONNECTION_RUNTIME_TOOL_NAMES,
|
||||
});
|
||||
}
|
||||
|
||||
const MANAGED_MCP_LOCAL_ADAPTERS = new Set(["codex_local"]);
|
||||
|
||||
function adapterSupportsManagedMcpConfig(adapterType: string): boolean {
|
||||
|
|
@ -3787,7 +3840,9 @@ export async function createManagedMcpRunConfig(input: {
|
|||
managedGateways.push({
|
||||
id: gateway.id,
|
||||
name: gateway.name,
|
||||
endpointPath: `/api/tool-gateway/gateways/${gateway.id}/mcp`,
|
||||
// This path must bypass the normal /api agent-JWT middleware. The MCP
|
||||
// gateway performs its own bearer validation for the run-scoped token.
|
||||
endpointPath: `/mcp/gateways/${gateway.gatewayPublicId}`,
|
||||
bearerToken: token.token,
|
||||
tokenPrefix: token.tokenPrefix,
|
||||
});
|
||||
|
|
@ -6909,6 +6964,16 @@ export interface HeartbeatServiceOptions {
|
|||
pluginWorkerManager?: PluginWorkerManager;
|
||||
environmentRuntime?: HeartbeatEnvironmentRuntime;
|
||||
runtimeEnv?: Record<string, string | undefined>;
|
||||
/** Test seam for changing a continuation issue at the final pre-dispatch boundary. */
|
||||
beforeResolvedInteractionContinuationDispatchCheck?: (input: {
|
||||
runId: string;
|
||||
issueId: string;
|
||||
}) => Promise<void>;
|
||||
/** Test seam for racing an issue mutation after validation while its row lock is held. */
|
||||
afterResolvedInteractionContinuationDispatchCheck?: (input: {
|
||||
runId: string;
|
||||
issueId: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
type WorkspaceReadyCommentWriter = {
|
||||
|
|
@ -12920,7 +12985,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
|
||||
const staleness = await evaluateQueuedRunStaleness(run, issueId, context);
|
||||
if (staleness.stale) {
|
||||
await cancelQueuedRunForStaleIssue(run, issueId, staleness);
|
||||
await cancelRunForStaleIssue(run, issueId, staleness);
|
||||
logger.info(
|
||||
{ runId: run.id, issueId, errorCode: staleness.errorCode },
|
||||
"claimQueuedRun: cancelled stale queued run",
|
||||
|
|
@ -13212,8 +13277,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
run: typeof heartbeatRuns.$inferSelect,
|
||||
issueId: string,
|
||||
context: Record<string, unknown>,
|
||||
dbOrTx: Db = db,
|
||||
): Promise<QueuedRunStaleness> {
|
||||
const issue = await db
|
||||
const issue = await dbOrTx
|
||||
.select({
|
||||
id: issues.id,
|
||||
status: issues.status,
|
||||
|
|
@ -13241,6 +13307,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
const retryReason = readNonEmptyString(context.retryReason) ?? run.scheduledRetryReason ?? null;
|
||||
const interactionResolvedAt = readNonEmptyString(context.interactionResolvedAt);
|
||||
const hasResolvedInteractionEvidence = interactionResolvedAt !== null && !Number.isNaN(Date.parse(interactionResolvedAt));
|
||||
const isResolvedInteractionContinuation = isResolvedInteractionContinuationWakeContext(context);
|
||||
|
||||
if (isResolvedInteractionContinuation && issue.status !== "in_progress") {
|
||||
return {
|
||||
stale: true,
|
||||
errorCode: "issue_not_in_progress",
|
||||
reason: `Cancelled because resolved-interaction continuation issue is no longer in_progress (current status: ${issue.status}) before the queued run could start`,
|
||||
details: { issueId, currentStatus: issue.status, requiredStatus: "in_progress" },
|
||||
};
|
||||
}
|
||||
|
||||
if (isResolvedInteractionContinuation && issue.assigneeAgentId !== run.agentId) {
|
||||
return {
|
||||
stale: true,
|
||||
errorCode: "issue_assignee_changed",
|
||||
reason:
|
||||
"Cancelled because resolved-interaction continuation issue changed assignee before the queued run could start",
|
||||
details: {
|
||||
issueId,
|
||||
previousAssigneeAgentId: run.agentId,
|
||||
currentAssigneeAgentId: issue.assigneeAgentId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
issue.status === "in_progress" &&
|
||||
|
|
@ -13254,7 +13344,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
readNonEmptyString(parseObject(queuedWake.continuationSummary).body);
|
||||
const currentContinuationSummary = queuedContinuationSummary
|
||||
? null
|
||||
: await getIssueContinuationSummaryDocument(db, issueId);
|
||||
: await getIssueContinuationSummaryDocument(dbOrTx, issueId);
|
||||
const continuationSummaryBody = queuedContinuationSummary ?? currentContinuationSummary?.body ?? null;
|
||||
if (continuationSummaryParksExecutor(continuationSummaryBody)) {
|
||||
return {
|
||||
|
|
@ -13281,7 +13371,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
|
||||
const recoveryActionId = readNonEmptyString(context.recoveryActionId);
|
||||
const authorizedSourceScopedRecovery = wakeReason === "source_scoped_recovery_action" && recoveryActionId
|
||||
? await db
|
||||
? await dbOrTx
|
||||
.select({ id: issueRecoveryActions.id })
|
||||
.from(issueRecoveryActions)
|
||||
.where(and(
|
||||
|
|
@ -13373,7 +13463,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
return { stale: false };
|
||||
}
|
||||
|
||||
async function cancelQueuedRunForStaleIssue(
|
||||
async function cancelRunForStaleIssue(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
issueId: string,
|
||||
staleness: Extract<QueuedRunStaleness, { stale: true }>,
|
||||
|
|
@ -14560,9 +14650,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
const issueDependencyReadiness = issueId
|
||||
? await issuesSvc.listDependencyReadiness(agent.companyId, [issueId]).then((rows) => rows.get(issueId) ?? null)
|
||||
: null;
|
||||
if (issueId && issueContext && isResolvedInteractionContinuationWakeContext(context)) {
|
||||
try {
|
||||
// Claim the issue under the same in_progress predicate used by the
|
||||
// queued-run staleness gate. This is the final atomic guard before
|
||||
// dispatch: an operator parking the issue after claim but before this
|
||||
// checkout must not be overwritten by the continuation.
|
||||
await issuesSvc.checkout(issueId, agent.id, ["in_progress"], run.id);
|
||||
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true;
|
||||
} catch (error) {
|
||||
if (!isCheckoutConflictError(error)) throw error;
|
||||
const staleness = await evaluateQueuedRunStaleness(run, issueId, context);
|
||||
if (staleness.stale) {
|
||||
await cancelRunForStaleIssue(run, issueId, staleness);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
issueContext = await getIssueExecutionContext(agent.companyId, issueId);
|
||||
}
|
||||
if (
|
||||
issueId &&
|
||||
issueContext &&
|
||||
!isResolvedInteractionContinuationWakeContext(context) &&
|
||||
shouldAutoCheckoutIssueForWake({
|
||||
contextSnapshot: context,
|
||||
issueStatus: issueContext.status,
|
||||
|
|
@ -16375,6 +16485,76 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
executionTarget,
|
||||
});
|
||||
const adapter = getServerAdapter(agent.adapterType);
|
||||
const dispatchResolvedInteractionContinuationWithAtomicGate = async <T>(
|
||||
dispatch: (markDispatchStarted: () => void) => Promise<T>,
|
||||
): Promise<
|
||||
| { dispatched: true; resultPromise: Promise<T> }
|
||||
| { dispatched: false }
|
||||
> => {
|
||||
if (!issueId || !isResolvedInteractionContinuationWakeContext(context)) {
|
||||
return { dispatched: true, resultPromise: dispatch(() => {}) };
|
||||
}
|
||||
await options.beforeResolvedInteractionContinuationDispatchCheck?.({ runId: run.id, issueId });
|
||||
|
||||
const gate = await db.transaction(async (tx) => {
|
||||
const lockedIssue = await tx
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const staleness = await evaluateQueuedRunStaleness(
|
||||
run,
|
||||
issueId,
|
||||
context,
|
||||
tx as unknown as Db,
|
||||
);
|
||||
if (staleness.stale) {
|
||||
return { dispatched: false as const, staleness };
|
||||
}
|
||||
if (lockedIssue?.executionRunId !== run.id) {
|
||||
return {
|
||||
dispatched: false as const,
|
||||
staleness: {
|
||||
stale: true as const,
|
||||
errorCode: "issue_execution_lock_changed" as const,
|
||||
reason:
|
||||
"Cancelled because resolved-interaction continuation no longer owns the issue execution lock before adapter dispatch",
|
||||
details: {
|
||||
issueId,
|
||||
expectedExecutionRunId: run.id,
|
||||
currentExecutionRunId: lockedIssue?.executionRunId ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await options.afterResolvedInteractionContinuationDispatchCheck?.({ runId: run.id, issueId });
|
||||
let dispatchStarted = false;
|
||||
let resolveDispatchStarted!: () => void;
|
||||
const dispatchStartedPromise = new Promise<void>((resolve) => {
|
||||
resolveDispatchStarted = resolve;
|
||||
});
|
||||
const markDispatchStarted = () => {
|
||||
if (dispatchStarted) return;
|
||||
dispatchStarted = true;
|
||||
resolveDispatchStarted();
|
||||
};
|
||||
|
||||
// Keep the issue row locked through the adapter's asynchronous
|
||||
// preparation and release it only once the adapter reports an
|
||||
// actual process spawn. If preparation fails or returns without a
|
||||
// spawn, settling the adapter promise also releases the gate.
|
||||
const resultPromise = dispatch(markDispatchStarted);
|
||||
void resultPromise.then(markDispatchStarted, markDispatchStarted);
|
||||
await dispatchStartedPromise;
|
||||
return { dispatched: true as const, resultPromise };
|
||||
});
|
||||
|
||||
if (gate.dispatched) return gate;
|
||||
await cancelRunForStaleIssue(run, issueId, gate.staleness);
|
||||
return { dispatched: false };
|
||||
};
|
||||
const localAgentJwtScope =
|
||||
issueRef?.workMode === "skill_test"
|
||||
? { kind: "skill_test" as const, issueId: issueRef.id }
|
||||
|
|
@ -16617,7 +16797,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
promptMetrics: { promptChars: prompt.length },
|
||||
context: { provider: "codex", protocolVersion: 1 },
|
||||
});
|
||||
adapterResult = await executeNativeCodexRunner({
|
||||
const guardedDispatch = await dispatchResolvedInteractionContinuationWithAtomicGate((markDispatchStarted) =>
|
||||
executeNativeCodexRunner({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
|
|
@ -16636,16 +16817,50 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
timeoutMs,
|
||||
environment,
|
||||
onLog,
|
||||
onSpawn,
|
||||
});
|
||||
onSpawn: async (meta) => {
|
||||
markDispatchStarted();
|
||||
await onSpawn(meta);
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!guardedDispatch.dispatched) return;
|
||||
adapterResult = await guardedDispatch.resultPromise;
|
||||
} else {
|
||||
const adapterContext = { ...context };
|
||||
const runtimeTools = createAdapterRuntimeToolAccess({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
responsibleUserId: run.responsibleUserId,
|
||||
});
|
||||
if (!runtimeTools) {
|
||||
logger.warn(
|
||||
{
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
},
|
||||
"runtime connection tools could not be delivered",
|
||||
);
|
||||
}
|
||||
const runtimeMcpServers = await buildPaperclipRuntimeMcpServers({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
});
|
||||
const runtimeToolDelivery = adapter.runtimeToolDelivery ?? "invocation_context";
|
||||
if (runtimeTools && runtimeToolDelivery === "native_mcp") {
|
||||
runtimeMcpServers.unshift({
|
||||
name: "Paperclip connections",
|
||||
url: runtimeTools.mcpEndpoint,
|
||||
token: runtimeTools.bearerToken,
|
||||
connectionId: "paperclip-runtime-tools",
|
||||
});
|
||||
}
|
||||
const runtimeMcp = createAdapterRuntimeMcpAccess(runtimeMcpServers);
|
||||
if (runtimeTools && runtimeToolDelivery === "invocation_context") {
|
||||
adapterContext.paperclipRuntimeTools = runtimeTools;
|
||||
}
|
||||
const managedMcpConfig = await createManagedMcpRunConfig({
|
||||
db,
|
||||
agent,
|
||||
|
|
@ -16657,7 +16872,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
if (managedMcpConfig) {
|
||||
adapterContext.paperclipManagedMcp = managedMcpConfig;
|
||||
}
|
||||
adapterResult = await adapter.execute({
|
||||
const guardedDispatch = await dispatchResolvedInteractionContinuationWithAtomicGate((markDispatchStarted) =>
|
||||
adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
|
|
@ -16669,6 +16885,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
runtimeMcp,
|
||||
runtimeTools,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onEvent: onAdapterEvent,
|
||||
|
|
@ -16680,9 +16897,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
onRuntimeProgress: async (progress) => {
|
||||
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
|
||||
},
|
||||
onSpawn,
|
||||
onDispatch: markDispatchStarted,
|
||||
onSpawn: async (meta) => {
|
||||
markDispatchStarted();
|
||||
await onSpawn(meta);
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (!guardedDispatch.dispatched) return;
|
||||
adapterResult = await guardedDispatch.resultPromise;
|
||||
}
|
||||
// Adapter returned cleanly, which means its workspace-restore finally
|
||||
// block also ran without throwing. Record the workspace_finalize
|
||||
|
|
@ -18603,6 +18827,40 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
return { kind: "skipped" as const };
|
||||
}
|
||||
|
||||
const issueStateGuard = opts.issueStateGuard;
|
||||
if (
|
||||
issueStateGuard
|
||||
&& (
|
||||
!issueStateGuard.statuses.includes(issue.status)
|
||||
|| issue.assigneeAgentId !== issueStateGuard.assigneeAgentId
|
||||
)
|
||||
) {
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail,
|
||||
reason: "issue_state_guard_mismatch",
|
||||
payload: {
|
||||
...(payload ?? {}),
|
||||
heartbeatSkip: {
|
||||
reason: "Issue status or assignee changed before the wake could be queued.",
|
||||
issueId: issue.id,
|
||||
expectedStatuses: issueStateGuard.statuses,
|
||||
actualStatus: issue.status,
|
||||
expectedAssigneeAgentId: issueStateGuard.assigneeAgentId,
|
||||
actualAssigneeAgentId: issue.assigneeAgentId,
|
||||
},
|
||||
},
|
||||
status: "skipped",
|
||||
requestedByActorType: opts.requestedByActorType ?? null,
|
||||
requestedByActorId: opts.requestedByActorId ?? null,
|
||||
idempotencyKey: opts.idempotencyKey ?? null,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
return { kind: "skipped" as const };
|
||||
}
|
||||
|
||||
if (worktreeExecutionCutoff && issue.createdAt < worktreeExecutionCutoff) {
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
companyId: agent.companyId,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
issueThreadInteractions,
|
||||
issues,
|
||||
toolActionRequests,
|
||||
toolOauthStates,
|
||||
} from "@paperclipai/db";
|
||||
import { trackInteractionCreated, trackInteractionResolved } from "@paperclipai/shared/telemetry";
|
||||
import type {
|
||||
|
|
@ -20,6 +21,7 @@ import type {
|
|||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
CancelIssueThreadInteraction,
|
||||
ConnectionIntentInteraction,
|
||||
CreateIssueThreadInteraction,
|
||||
InteractionResolverGovernance,
|
||||
IssueReviewPolicy,
|
||||
|
|
@ -47,6 +49,8 @@ import {
|
|||
askUserQuestionsPayloadSchema,
|
||||
askUserQuestionsResultSchema,
|
||||
cancelIssueThreadInteractionSchema,
|
||||
connectionIntentPayloadSchema,
|
||||
connectionIntentResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
legacyIssueThreadInteractionResolverPolicyAlias,
|
||||
normalizeIssueThreadInteractionResolverPolicy,
|
||||
|
|
@ -254,6 +258,7 @@ export const DEFAULT_RESOLVER_POLICY_BY_KIND: Record<
|
|||
request_confirmation: "anyone",
|
||||
request_checkbox_confirmation: "anyone",
|
||||
request_item_verdicts: "anyone",
|
||||
connection_intent: "human_only",
|
||||
};
|
||||
|
||||
const RESOLVER_POLICY_RESTRICTION_RANK: Record<IssueThreadInteractionCanonicalResolverPolicy, number> = {
|
||||
|
|
@ -387,11 +392,13 @@ type TargetBoundInteraction =
|
|||
const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
|
||||
...TARGET_BOUND_INTERACTION_KINDS,
|
||||
"ask_user_questions",
|
||||
"connection_intent",
|
||||
] as const;
|
||||
type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number];
|
||||
type UserCommentSupersedableInteraction =
|
||||
| TargetBoundInteraction
|
||||
| AskUserQuestionsInteraction;
|
||||
| AskUserQuestionsInteraction
|
||||
| ConnectionIntentInteraction;
|
||||
|
||||
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
|
||||
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
|
||||
|
|
@ -529,6 +536,13 @@ function hydrateInteraction(
|
|||
payload: requestItemVerdictsPayloadSchema.parse(row.payload),
|
||||
result: parseStoredInteractionResult(requestItemVerdictsResultSchema, row.result, row),
|
||||
} satisfies RequestItemVerdictsInteraction;
|
||||
case "connection_intent":
|
||||
return {
|
||||
...base,
|
||||
kind: "connection_intent",
|
||||
payload: connectionIntentPayloadSchema.parse(row.payload),
|
||||
result: parseStoredInteractionResult(connectionIntentResultSchema, row.result, row),
|
||||
} satisfies ConnectionIntentInteraction;
|
||||
default:
|
||||
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
|
||||
}
|
||||
|
|
@ -608,6 +622,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
|||
}
|
||||
|
||||
function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) {
|
||||
if (interaction.kind === "connection_intent") return true;
|
||||
return interaction.payload.supersedeOnUserComment === true;
|
||||
}
|
||||
|
||||
|
|
@ -651,6 +666,13 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C
|
|||
}
|
||||
|
||||
function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentId: string) {
|
||||
if (row.kind === "connection_intent") {
|
||||
return {
|
||||
version: 1,
|
||||
outcome: "expired",
|
||||
reason: "Superseded by a newer user comment",
|
||||
} as const;
|
||||
}
|
||||
if (row.kind === "ask_user_questions") {
|
||||
return {
|
||||
version: 1,
|
||||
|
|
@ -729,6 +751,9 @@ function buildAdministrativeOutcomeResult(
|
|||
outcome: "withdrawn" | "issue_closed" | "addressee_deleted",
|
||||
reason: string | null = null,
|
||||
) {
|
||||
if (row.kind === "connection_intent") {
|
||||
return { version: 1, outcome: "expired", reason } as const;
|
||||
}
|
||||
if (row.kind === "ask_user_questions") {
|
||||
return { version: 1, outcome, reason, answers: [], summaryMarkdown: null } as const;
|
||||
}
|
||||
|
|
@ -909,6 +934,9 @@ function deriveResolutionReason(interaction: IssueThreadInteraction) {
|
|||
case "cancelled":
|
||||
return "cancelled";
|
||||
case "expired": {
|
||||
if (interaction.kind === "connection_intent") {
|
||||
return interaction.result?.outcome ?? "expired";
|
||||
}
|
||||
if (interaction.kind === "ask_user_questions") {
|
||||
return interaction.result?.expirationReason ?? "expired";
|
||||
}
|
||||
|
|
@ -1007,7 +1035,7 @@ async function emitInteractionResolvedTelemetry(
|
|||
: undefined;
|
||||
|
||||
trackInteractionResolved(telemetryClient, {
|
||||
interactionKind: interaction.kind,
|
||||
interactionKind: interaction.kind === "connection_intent" ? "other" : interaction.kind,
|
||||
status: interaction.status,
|
||||
resolvedByKind: resolveActorKind(interaction),
|
||||
resolutionReason: deriveResolutionReason(interaction),
|
||||
|
|
@ -1034,7 +1062,10 @@ function emitInteractionCreatedTelemetry(args: {
|
|||
if (!telemetryClient) return;
|
||||
|
||||
try {
|
||||
trackInteractionCreated(telemetryClient, args);
|
||||
trackInteractionCreated(telemetryClient, {
|
||||
...args,
|
||||
interactionKind: args.interactionKind === "connection_intent" ? "other" : args.interactionKind,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[paperclip] Failed to emit interaction.created telemetry", error);
|
||||
}
|
||||
|
|
@ -1853,6 +1884,181 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
|
||||
return {
|
||||
getForIssue,
|
||||
createConnectionIntent: async (
|
||||
issue: { id: string; companyId: string },
|
||||
input: {
|
||||
payload: ConnectionIntentInteraction["payload"];
|
||||
sourceRunId: string;
|
||||
addresseeUserId: string;
|
||||
idempotencyKey: string;
|
||||
},
|
||||
) => {
|
||||
const payload = connectionIntentPayloadSchema.parse(input.payload);
|
||||
const existing = await getIdempotentInteraction({
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
});
|
||||
if (existing) {
|
||||
if (
|
||||
existing.kind !== "connection_intent"
|
||||
|| existing.sourceRunId !== input.sourceRunId
|
||||
|| existing.addresseeUserId !== input.addresseeUserId
|
||||
|| !isDeepStrictEqual(existing.payload, payload)
|
||||
) {
|
||||
throw conflict("Interaction idempotency key already exists for a different request", {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
});
|
||||
}
|
||||
return hydrateInteraction(existing) as ConnectionIntentInteraction;
|
||||
}
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const issueRow = await tx
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issueRow || isTerminalIssueStatus(issueRow.status)) {
|
||||
throw conflict("Cannot create an interaction on a closed issue");
|
||||
}
|
||||
|
||||
const [row] = await tx
|
||||
.insert(issueThreadInteractions)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
kind: "connection_intent",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "governed_action",
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
sourceRunId: input.sourceRunId,
|
||||
title: `Connect ${payload.serviceName}`,
|
||||
summary: `${payload.requestingAgentName} needs this connection to continue.`,
|
||||
createdByAgentId: payload.requestingAgentId,
|
||||
addresseeUserId: input.addresseeUserId,
|
||||
payload,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const olderPending = await tx
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.kind, "connection_intent"),
|
||||
eq(issueThreadInteractions.createdByAgentId, payload.requestingAgentId),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
ne(issueThreadInteractions.id, row.id),
|
||||
));
|
||||
const supersededIds = olderPending
|
||||
.filter((candidate) => {
|
||||
const candidatePayload = connectionIntentPayloadSchema.safeParse(candidate.payload);
|
||||
return candidatePayload.success && candidatePayload.data.serviceSlug === payload.serviceSlug;
|
||||
})
|
||||
.map((candidate) => candidate.id);
|
||||
if (supersededIds.length > 0) {
|
||||
await tx
|
||||
.delete(toolOauthStates)
|
||||
.where(inArray(toolOauthStates.interactionId, supersededIds));
|
||||
await tx
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded",
|
||||
supersededByInteractionId: row.id,
|
||||
},
|
||||
resolvedAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(inArray(issueThreadInteractions.id, supersededIds));
|
||||
}
|
||||
await touchIssue(tx, issue.id);
|
||||
return row;
|
||||
});
|
||||
|
||||
const interaction = hydrateInteraction(created) as ConnectionIntentInteraction;
|
||||
emitInteractionCreatedTelemetry({
|
||||
interactionKind: "connection_intent",
|
||||
usedDeprecatedResolverPolicyAlias: false,
|
||||
});
|
||||
return interaction;
|
||||
},
|
||||
updateConnectionIntentPhase: async (
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
phase: ConnectionIntentInteraction["payload"]["phase"],
|
||||
actor: InteractionActor,
|
||||
) => {
|
||||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
if (current.kind !== "connection_intent") {
|
||||
throw unprocessable("Only connection_intent interactions have a connection phase");
|
||||
}
|
||||
assertInteractionResolutionAllowed(current, actor);
|
||||
const payload = connectionIntentPayloadSchema.parse(current.payload);
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ payload: { ...payload, phase }, updatedAt: now() })
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, interactionId),
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
if (!updated) throw interactionAlreadyResolvedError();
|
||||
await touchIssue(db, issue.id);
|
||||
return hydrateInteraction(updated) as ConnectionIntentInteraction;
|
||||
},
|
||||
resolveConnectionIntent: async (
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
resultInput: ConnectionIntentInteraction["result"] extends infer T ? NonNullable<T> : never,
|
||||
actor: InteractionActor,
|
||||
) => {
|
||||
const result = connectionIntentResultSchema.parse(resultInput);
|
||||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
if (current.kind !== "connection_intent") {
|
||||
throw unprocessable("Only connection_intent interactions can be resolved by this operation");
|
||||
}
|
||||
if (!actor.userId) throw forbidden("Connection intents require a human resolver");
|
||||
assertInteractionResolutionAllowed(current, actor);
|
||||
const status = result.outcome === "connected"
|
||||
? "accepted"
|
||||
: result.outcome === "declined"
|
||||
? "rejected"
|
||||
: "expired";
|
||||
const resolvedAt = now();
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status,
|
||||
result,
|
||||
resolvedByUserId: actor.userId,
|
||||
resolvedAt,
|
||||
updatedAt: resolvedAt,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, interactionId),
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
if (!updated) throw interactionAlreadyResolvedError();
|
||||
await touchIssue(db, issue.id);
|
||||
const interaction = hydrateInteraction(updated) as ConnectionIntentInteraction;
|
||||
await emitInteractionResolvedTelemetry(db, interaction);
|
||||
return interaction;
|
||||
},
|
||||
sweepMergedPullRequestConfirmations: async () => {
|
||||
const rows = await db
|
||||
.select({
|
||||
|
|
@ -2928,21 +3134,26 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
const now = new Date();
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
for (const row of superseded) {
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: buildSupersededByCommentResult(row, comment.id),
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByUserId: actor.userId ?? null,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
if (row.kind === "connection_intent") {
|
||||
await tx.delete(toolOauthStates).where(eq(toolOauthStates.interactionId, row.id));
|
||||
}
|
||||
return tx
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: buildSupersededByCommentResult(row, comment.id),
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByUserId: actor.userId ?? null,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
});
|
||||
if (updated) expired.push(hydrateInteraction(updated));
|
||||
}
|
||||
|
||||
|
|
@ -3025,6 +3236,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.map((row) => row.id);
|
||||
const itemVerdictRows = commentRows
|
||||
.filter((row) => row.kind === "request_item_verdicts");
|
||||
const connectionIntentRows = commentRows
|
||||
.filter((row) => row.kind === "connection_intent");
|
||||
|
||||
if (questionRowIds.length > 0) {
|
||||
const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions");
|
||||
|
|
@ -3086,6 +3299,28 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
if (updated) expired.push(hydrateInteraction(updated));
|
||||
}
|
||||
|
||||
for (const row of connectionIntentRows) {
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
await tx.delete(toolOauthStates).where(eq(toolOauthStates.interactionId, row.id));
|
||||
return tx
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: buildSupersededByCommentResult(row, comment.id),
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: comment.authorUserId,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
});
|
||||
if (updated) expired.push(hydrateInteraction(updated));
|
||||
}
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
|
|
@ -3194,6 +3429,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
// expires and the execution result lands on it via the gateway's
|
||||
// lifecycle reflection.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
if (row.kind === "connection_intent") {
|
||||
await tx
|
||||
.delete(toolOauthStates)
|
||||
.where(eq(toolOauthStates.interactionId, row.id));
|
||||
}
|
||||
await resolveLinkedToolActionRequests(tx, row, {
|
||||
status: "expired",
|
||||
fromStatuses: ["pending", "approved"],
|
||||
|
|
|
|||
|
|
@ -918,7 +918,7 @@ function assertSelectableProviderConfig(config: {
|
|||
}
|
||||
}
|
||||
|
||||
export function secretService(db: Db) {
|
||||
export function secretService(db: Db | DbTransaction) {
|
||||
const authorization = authorizationService(db);
|
||||
|
||||
type NormalizeEnvOptions = {
|
||||
|
|
@ -1629,7 +1629,7 @@ export function secretService(db: Db) {
|
|||
accessContext,
|
||||
});
|
||||
await context.registerForRedaction(resolution.value);
|
||||
await logActivity(db, {
|
||||
await logActivity(db as Db, {
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: context.agentId,
|
||||
|
|
@ -1651,7 +1651,7 @@ export function secretService(db: Db) {
|
|||
};
|
||||
} catch (error) {
|
||||
const errorCode = secretResolutionErrorCode(error);
|
||||
await logActivity(db, {
|
||||
await logActivity(db as Db, {
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: context.agentId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,13 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, inArray, isNull, lte, ne, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySecrets,
|
||||
connectionGrantMembers,
|
||||
connectionGrantDelegations,
|
||||
connectionGrants,
|
||||
|
|
@ -208,6 +209,8 @@ export interface ToolGatewaySession {
|
|||
gatewayTokenAllowedActions?: ToolMcpGatewayTokenAction[];
|
||||
actorType?: "agent" | "user" | "system" | "plugin";
|
||||
actorId?: string | null;
|
||||
/** Human whose personal connection grant applies to this execution. */
|
||||
responsibleUserId?: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
|
@ -922,7 +925,15 @@ export function createToolGatewayService(
|
|||
inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]),
|
||||
eq(toolConnections.status, "active"),
|
||||
eq(toolConnections.enabled, true),
|
||||
inArray(toolConnections.healthStatus, ["ok", "healthy"]),
|
||||
// A personal connection has no company-level credential to probe. A
|
||||
// credential-less health sweep can therefore mark it as errored even
|
||||
// while the responsible user's grant is valid. Keep its cached active
|
||||
// catalog discoverable; execution resolves and validates that user's
|
||||
// grant, and a successful call restores the shared health indicator.
|
||||
or(
|
||||
inArray(toolConnections.healthStatus, ["ok", "healthy"]),
|
||||
eq(toolConnections.credentialPolicy, "per_user"),
|
||||
),
|
||||
eq(toolApplications.companyId, companyId),
|
||||
inArray(toolApplications.type, ["mcp_http", "mcp_stdio"]),
|
||||
eq(toolApplications.status, "active"),
|
||||
|
|
@ -2446,6 +2457,73 @@ export function createToolGatewayService(
|
|||
);
|
||||
}
|
||||
|
||||
async function resolveGrantSecretValue(
|
||||
session: ToolGatewaySession,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
grant: typeof connectionGrants.$inferSelect,
|
||||
ref: ToolCredentialSecretRef,
|
||||
configPath = ref.configPath,
|
||||
): Promise<string> {
|
||||
const accessContext = {
|
||||
consumerType: "tool_connection" as const,
|
||||
consumerId: connection.id,
|
||||
configPath,
|
||||
actorType: "system" as const,
|
||||
actorId: session.agentId,
|
||||
responsibleUserId: grant.subjectUserId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
};
|
||||
if (grant.kind !== "user") {
|
||||
return secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
ref.secretId,
|
||||
ref.versionSelector ?? "latest",
|
||||
{ accessContext },
|
||||
);
|
||||
}
|
||||
if (!grant.subjectUserId) {
|
||||
throw new ToolGatewayHttpError(422, "Personal authorization has no owner", "grant_owner_missing", {
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
});
|
||||
}
|
||||
const [secret] = await db.select({
|
||||
scope: companySecrets.scope,
|
||||
ownerUserId: companySecrets.ownerUserId,
|
||||
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
||||
}).from(companySecrets).where(and(
|
||||
eq(companySecrets.id, ref.secretId),
|
||||
eq(companySecrets.companyId, connection.companyId),
|
||||
)).limit(1);
|
||||
if (
|
||||
!secret
|
||||
|| secret.scope !== "user"
|
||||
|| secret.ownerUserId !== grant.subjectUserId
|
||||
|| !secret.userSecretDefinitionId
|
||||
) {
|
||||
throw new ToolGatewayHttpError(422, "Personal authorization has an invalid credential", "grant_credential_invalid", {
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
credential: configPath,
|
||||
});
|
||||
}
|
||||
const resolved = await secrets.resolveUserSecretValue(connection.companyId, {
|
||||
definitionId: secret.userSecretDefinitionId,
|
||||
responsibleUserId: grant.subjectUserId,
|
||||
version: ref.versionSelector ?? "latest",
|
||||
required: ref.required ?? true,
|
||||
}, accessContext);
|
||||
if (!resolved) {
|
||||
throw new ToolGatewayHttpError(422, "Personal credential is not configured", "user_secret_missing", {
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
credential: configPath,
|
||||
});
|
||||
}
|
||||
return resolved.value;
|
||||
}
|
||||
|
||||
async function maybeRefreshPaperclipIdGmailGrant(
|
||||
session: ToolGatewaySession,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
|
|
@ -2480,22 +2558,7 @@ export function createToolGatewayService(
|
|||
grantId: grant.id,
|
||||
});
|
||||
}
|
||||
const refreshToken = await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
refreshRef.secretId,
|
||||
refreshRef.versionSelector ?? "latest",
|
||||
{
|
||||
accessContext: {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: refreshRef.configPath,
|
||||
actorType: "system",
|
||||
actorId: session.agentId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const refreshToken = await resolveGrantSecretValue(session, connection, grant, refreshRef);
|
||||
try {
|
||||
const credentials = await gmailConnector.refresh({
|
||||
subject: grant.subjectUserId,
|
||||
|
|
@ -2562,17 +2625,13 @@ export function createToolGatewayService(
|
|||
const grantRef = grantRefForHeader(grant, ref);
|
||||
if (!grantRef) continue;
|
||||
try {
|
||||
const value = await secrets.resolveSecretValue(connection.companyId, grantRef.secretId, grantRef.versionSelector ?? "latest", {
|
||||
accessContext: {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: `credentials.${ref.name}`,
|
||||
actorType: "system",
|
||||
actorId: session.agentId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
},
|
||||
});
|
||||
const value = await resolveGrantSecretValue(
|
||||
session,
|
||||
connection,
|
||||
grant,
|
||||
grantRef,
|
||||
`credentials.${ref.name}`,
|
||||
);
|
||||
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
|
||||
} catch {
|
||||
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
|
||||
|
|
@ -2587,22 +2646,7 @@ export function createToolGatewayService(
|
|||
const oauthAccessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
|
||||
if (oauthAccessRef && headers.Authorization === undefined) {
|
||||
try {
|
||||
const value = await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
oauthAccessRef.secretId,
|
||||
oauthAccessRef.versionSelector ?? "latest",
|
||||
{
|
||||
accessContext: {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: oauthAccessRef.configPath,
|
||||
actorType: "system",
|
||||
actorId: session.agentId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const value = await resolveGrantSecretValue(session, connection, grant, oauthAccessRef);
|
||||
headers.Authorization = `Bearer ${value}`;
|
||||
} catch {
|
||||
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
|
||||
|
|
@ -2852,7 +2896,7 @@ export function createToolGatewayService(
|
|||
eq(heartbeatRuns.companyId, session.companyId),
|
||||
)).limit(1)
|
||||
: [];
|
||||
const actingUserId = run?.responsibleUserId ?? null;
|
||||
const actingUserId = run?.responsibleUserId ?? session.responsibleUserId ?? null;
|
||||
const autonomous = run?.invocationSource === "automation" || run?.invocationSource === "timer";
|
||||
const findUserGrant = async () => {
|
||||
if (!actingUserId) return undefined;
|
||||
|
|
@ -3077,22 +3121,7 @@ export function createToolGatewayService(
|
|||
const grantRef = grant.credentialSecretRefs.find((ref) => ref.configPath === `env.${key}`);
|
||||
if (!grantRef) continue;
|
||||
try {
|
||||
env[key] = await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
grantRef.secretId,
|
||||
grantRef.versionSelector ?? "latest",
|
||||
{
|
||||
accessContext: {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: grantRef.configPath,
|
||||
actorType: "system",
|
||||
actorId: session.agentId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
env[key] = await resolveGrantSecretValue(session, connection, grant, grantRef);
|
||||
} catch {
|
||||
await markRemoteConnectionHealth(connection, "missing_secret", "A configured local stdio credential could not be resolved.");
|
||||
throw new ToolGatewayHttpError(
|
||||
|
|
@ -4171,6 +4200,7 @@ export function createToolGatewayService(
|
|||
}
|
||||
let agentId = row.gateway.agentId;
|
||||
let runId: string | null = null;
|
||||
let responsibleUserId: string | null = null;
|
||||
let issueId = row.gateway.issueId;
|
||||
let projectId = row.gateway.projectId;
|
||||
if (row.token.subjectType === "heartbeat_run") {
|
||||
|
|
@ -4189,6 +4219,7 @@ export function createToolGatewayService(
|
|||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, tokenRunId))
|
||||
|
|
@ -4230,6 +4261,7 @@ export function createToolGatewayService(
|
|||
});
|
||||
agentId = run.agentId;
|
||||
runId = tokenRunId;
|
||||
responsibleUserId = run.responsibleUserId;
|
||||
issueId = runContext.issueId;
|
||||
projectId = runContext.projectId;
|
||||
} catch {
|
||||
|
|
@ -4262,6 +4294,7 @@ export function createToolGatewayService(
|
|||
gatewayTokenAllowedActions: normalizeGatewayTokenActions(row.token.allowedActions),
|
||||
actorType: runId ? "agent" : "system",
|
||||
actorId: runId ? agentId : row.token.id,
|
||||
responsibleUserId,
|
||||
createdAt: row.token.createdAt,
|
||||
expiresAt: row.token.expiresAt ?? new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
|
@ -4504,6 +4537,7 @@ export function createToolGatewayService(
|
|||
projectId: null,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
responsibleUserId: userId,
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
||||
};
|
||||
|
|
@ -5536,6 +5570,7 @@ export function createToolGatewayService(
|
|||
projectId: null,
|
||||
actorType: "user",
|
||||
actorId: input.userId,
|
||||
responsibleUserId: input.userId,
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import type {
|
||||
ConnectionIntentInteraction,
|
||||
ConnectionIntentSetupOptions,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const connectionIntentsApi = {
|
||||
setupOptions: (interactionId: string) =>
|
||||
api.get<ConnectionIntentSetupOptions>(
|
||||
`/connection-intents/${interactionId}/setup-options`,
|
||||
),
|
||||
complete: (interactionId: string, connectionId: string) =>
|
||||
api.post<ConnectionIntentInteraction>(
|
||||
`/connection-intents/${interactionId}/complete`,
|
||||
{ connectionId },
|
||||
),
|
||||
decline: (interactionId: string, reason?: string) =>
|
||||
api.post<ConnectionIntentInteraction>(
|
||||
`/connection-intents/${interactionId}/decline`,
|
||||
reason ? { reason } : {},
|
||||
),
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
} from "@/pages/apps/composio-services";
|
||||
import type {
|
||||
ToolApplication,
|
||||
ConnectToolApp,
|
||||
ToolConnection,
|
||||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
|
|
@ -267,17 +268,13 @@ export const toolsApi = {
|
|||
// --- Applications ---
|
||||
listGallery: (companyId: string) =>
|
||||
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),
|
||||
connectApp: (companyId: string, input: {
|
||||
galleryKey?: string;
|
||||
link?: string;
|
||||
name?: string;
|
||||
credentialValues?: Record<string, string>;
|
||||
configValues?: Record<string, unknown>;
|
||||
applicationId?: string;
|
||||
}) =>
|
||||
connectApp: (companyId: string, input: ConnectToolApp) =>
|
||||
api.post<ConnectToolAppResult>(`/companies/${companyId}/tools/apps/connect`, input),
|
||||
startOAuth: (connectionId: string) =>
|
||||
api.post<ToolOAuthStartResult>(`/tools/oauth/${connectionId}/start`, {}),
|
||||
startOAuth: (connectionId: string, interactionId?: string) =>
|
||||
api.post<ToolOAuthStartResult>(
|
||||
`/tools/oauth/${connectionId}/start`,
|
||||
interactionId ? { interactionId } : {},
|
||||
),
|
||||
finishApp: (companyId: string, connectionId: string, input: {
|
||||
enabledCatalogEntryIds: string[];
|
||||
askFirstCatalogEntryIds: string[];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, Clock, Loader2, Plug, XCircle } from "lucide-react";
|
||||
import type {
|
||||
ConnectionIntentInteraction,
|
||||
ConnectionIntentSetupOptions,
|
||||
} from "@paperclipai/shared";
|
||||
import { connectionIntentsApi } from "@/api/connection-intents";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { appSourceConnectHref, isMcpDirectOAuthConnectSlug } from "@/pages/apps/app-connect-policy";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export interface ConnectionIntentInteractionBodyProps {
|
||||
interaction: ConnectionIntentInteraction;
|
||||
currentUserId?: string | null;
|
||||
addresseeLabel: string;
|
||||
}
|
||||
|
||||
export function ConnectionIntentInteractionBody({
|
||||
interaction,
|
||||
currentUserId,
|
||||
addresseeLabel,
|
||||
}: ConnectionIntentInteractionBodyProps) {
|
||||
const [current, setCurrent] = useState(interaction);
|
||||
const [options, setOptions] = useState<ConnectionIntentSetupOptions | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setCurrent(interaction), [interaction]);
|
||||
|
||||
const isAddressee = Boolean(currentUserId && current.addresseeUserId === currentUserId);
|
||||
const connectHref = isMcpDirectOAuthConnectSlug(current.payload.serviceSlug)
|
||||
? appSourceConnectHref(current.payload.serviceSlug, current.id)
|
||||
: `/apps/connect?${new URLSearchParams({
|
||||
byo: "1",
|
||||
appKey: current.payload.serviceSlug,
|
||||
intent: current.id,
|
||||
}).toString()}`;
|
||||
|
||||
async function loadOptions() {
|
||||
setExpanded(true);
|
||||
setPendingAction("load");
|
||||
setError(null);
|
||||
try {
|
||||
setOptions(await connectionIntentsApi.setupOptions(current.id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Couldn’t load connection options.");
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function complete(connectionId: string) {
|
||||
setPendingAction(connectionId);
|
||||
setError(null);
|
||||
try {
|
||||
setCurrent(await connectionIntentsApi.complete(current.id, connectionId));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Couldn’t use this connection.");
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
setPendingAction("decline");
|
||||
setError(null);
|
||||
try {
|
||||
setCurrent(await connectionIntentsApi.decline(current.id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Couldn’t decline this request.");
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.status === "accepted" || current.status === "rejected" || current.status === "expired") {
|
||||
const connected = current.status === "accepted";
|
||||
const StatusIcon = connected ? CheckCircle2 : XCircle;
|
||||
const title = connected
|
||||
? `${current.payload.serviceName} connected`
|
||||
: current.status === "rejected"
|
||||
? "Connection declined"
|
||||
: "Connection request expired";
|
||||
return (
|
||||
<div className="flex items-start gap-3" data-testid="connection-intent-terminal">
|
||||
<StatusIcon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{connected
|
||||
? `${current.payload.requestingAgentName} can use this connection on its continuation run.`
|
||||
: `${current.payload.requestingAgentName} can continue without this connection.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAddressee) {
|
||||
return (
|
||||
<div className="flex items-start gap-3" data-testid="connection-intent-waiting">
|
||||
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Waiting for {addresseeLabel}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Only the addressed person can choose or create a connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="connection-intent-actions">
|
||||
<div className="flex items-start gap-3">
|
||||
<AppLogo
|
||||
name={current.payload.serviceName}
|
||||
brandKey={current.payload.serviceSlug}
|
||||
logoUrl={current.payload.serviceLogoUrl}
|
||||
size={40}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{current.payload.requestingAgentName} needs {current.payload.serviceName}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Reuse an eligible connection or connect a new identity for this agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={() => void loadOptions()} disabled={pendingAction !== null}>
|
||||
{pendingAction === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
|
||||
Connect / Use existing
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => void decline()}
|
||||
disabled={pendingAction !== null}
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && pendingAction === "load" ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">Loading connection options…</p>
|
||||
) : null}
|
||||
{expanded && options ? (
|
||||
<div className="mt-3 space-y-2 rounded-md border border-border bg-muted/30 p-3">
|
||||
{options.existingConnections.map((connection) => (
|
||||
<Button
|
||||
key={connection.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => void complete(connection.id)}
|
||||
disabled={pendingAction !== null}
|
||||
>
|
||||
{pendingAction === connection.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
|
||||
Use {connection.name}
|
||||
</Button>
|
||||
))}
|
||||
<Button asChild type="button" variant="outline" className="w-full justify-start">
|
||||
<Link to={connectHref}>Connect a new {current.payload.serviceName} identity</Link>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Finishing setup will grant the new identity and resolve this request automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="mt-3 text-sm text-destructive" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -161,8 +161,12 @@ describe("InteractionGovernancePanel", () => {
|
|||
|
||||
it("renders a select for every interaction kind", () => {
|
||||
const { host } = renderPanel();
|
||||
expect(host.querySelectorAll('[data-testid$="-default"]')).toHaveLength(5);
|
||||
expect(host.querySelectorAll('[data-testid$="-cap"]')).toHaveLength(5);
|
||||
expect(host.querySelectorAll('[data-testid$="-default"]')).toHaveLength(
|
||||
INTERACTION_KINDS.length,
|
||||
);
|
||||
expect(host.querySelectorAll('[data-testid$="-cap"]')).toHaveLength(
|
||||
INTERACTION_KINDS.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a save failure", () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const INTERACTION_KIND_LABELS: Record<IssueThreadInteractionKind, string> = {
|
|||
request_confirmation: "Confirmations",
|
||||
request_checkbox_confirmation: "Checkbox confirmations",
|
||||
request_item_verdicts: "Item verdicts",
|
||||
connection_intent: "Connection requests",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -47,11 +47,18 @@ import {
|
|||
humanOnlyRequestConfirmationInteraction,
|
||||
companyCappedRequestConfirmationInteraction,
|
||||
legacyRestrictedRequestConfirmationInteraction,
|
||||
pendingConnectionIntentInteraction,
|
||||
} from "../fixtures/issueThreadInteractionFixtures";
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const connectionIntentsApiMocks = vi.hoisted(() => ({
|
||||
setupOptions: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
decline: vi.fn(),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
|
|
@ -74,6 +81,8 @@ vi.mock("@/lib/router", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: connectionIntentsApiMocks }));
|
||||
|
||||
function renderCard(
|
||||
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
|
||||
) {
|
||||
|
|
@ -107,6 +116,42 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("IssueThreadInteractionCard", () => {
|
||||
it("offers connection resolution actions to the addressed user", async () => {
|
||||
connectionIntentsApiMocks.setupOptions.mockResolvedValue({ existingConnections: [] });
|
||||
const host = renderCard({
|
||||
interaction: pendingConnectionIntentInteraction,
|
||||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeTruthy();
|
||||
expect(host.textContent).toContain("Connect / Use existing");
|
||||
expect(host.textContent).toContain("Not now");
|
||||
const loadButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Connect / Use existing"),
|
||||
);
|
||||
await act(async () => {
|
||||
loadButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const connectLink = Array.from(host.querySelectorAll("a")).find((link) =>
|
||||
link.textContent === "Connect a new Notion identity",
|
||||
);
|
||||
expect(connectLink?.getAttribute("href")).toBe(
|
||||
"/apps/connect?source=notion&intent=interaction-connection-intent-default",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps connection resolution controls exclusive to the addressed user", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingConnectionIntentInteraction,
|
||||
currentUserId: "another-user",
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="connection-intent-waiting"]')).toBeTruthy();
|
||||
expect(host.textContent).not.toContain("Connect / Use existing");
|
||||
expect(host.textContent).not.toContain("Not now");
|
||||
});
|
||||
|
||||
it("exposes pending question options as selectable radio and checkbox controls", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingAskUserQuestionsInteraction,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { Textarea } from "./ui/textarea";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProposalJustification } from "../pages/secrets/proposal-review";
|
||||
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
|
||||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
|
||||
|
|
@ -4167,6 +4168,12 @@ export function IssueThreadInteractionCard({
|
|||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "connection_intent" ? (
|
||||
<ConnectionIntentInteractionBody
|
||||
interaction={interaction}
|
||||
currentUserId={currentUserId}
|
||||
addresseeLabel={addresseeLabel ?? "the addressed person"}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
interaction={interaction}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { legacyIssueThreadInteractionResolverPolicyAlias } from "@paperclipai/shared";
|
||||
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
|
|
@ -23,6 +24,45 @@ export const issueThreadInteractionFixtureMeta = {
|
|||
currentUserId: "user-board",
|
||||
} as const;
|
||||
|
||||
export const pendingConnectionIntentInteraction: ConnectionIntentInteraction = {
|
||||
id: "interaction-connection-intent-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "connection_intent",
|
||||
title: "Connect Notion",
|
||||
summary: "Researcher needs this connection to continue.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
addresseeUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
createdAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
serviceLogoUrl: null,
|
||||
serviceDarkLogoUrl: null,
|
||||
requestingAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "requested",
|
||||
},
|
||||
result: null,
|
||||
resolverPolicy: "human_only",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "governed_action",
|
||||
legacyResolverPolicyAliases: {
|
||||
requested: "board_only",
|
||||
effective: "board_only",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolver-audience snapshot fields shared by every interaction fixture.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -239,6 +239,16 @@ export function buildIssueThreadInteractionSummary(
|
|||
return buildItemVerdictsSummary(interaction);
|
||||
}
|
||||
|
||||
if (interaction.kind === "connection_intent") {
|
||||
const serviceName = interaction.payload.serviceName;
|
||||
const outcome = interaction.result?.outcome;
|
||||
if (outcome === "connected") return `Connected ${serviceName}`;
|
||||
if (outcome === "declined") return `Declined ${serviceName} connection`;
|
||||
if (outcome === "superseded") return `${serviceName} connection request was superseded`;
|
||||
if (outcome === "expired") return `${serviceName} connection request expired`;
|
||||
return `Requested a ${serviceName} connection`;
|
||||
}
|
||||
|
||||
const count = interaction.payload.questions.length;
|
||||
if (interaction.status === "answered") {
|
||||
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const connectAppMock = vi.hoisted(() => vi.fn());
|
|||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const finishAppMock = vi.hoisted(() => vi.fn());
|
||||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const completeConnectionIntentMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -28,6 +29,7 @@ const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!
|
|||
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
|
||||
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
|
||||
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
|
||||
const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!;
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
|
|
@ -35,7 +37,9 @@ vi.mock("@/api/tools", () => ({
|
|||
listApplications: (companyId: string) => listApplicationsMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input),
|
||||
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
|
||||
startOAuth: (connectionId: string, interactionId?: string) => interactionId
|
||||
? startOAuthMock(connectionId, interactionId)
|
||||
: startOAuthMock(connectionId),
|
||||
finishApp: (companyId: string, connectionId: string, input: unknown) =>
|
||||
finishAppMock(companyId, connectionId, input),
|
||||
putConnectionInstalls: (connectionId: string, installs: unknown) =>
|
||||
|
|
@ -43,6 +47,13 @@ vi.mock("@/api/tools", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/connection-intents", () => ({
|
||||
connectionIntentsApi: {
|
||||
complete: (interactionId: string, connectionId: string) =>
|
||||
completeConnectionIntentMock(interactionId, connectionId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: { list: (companyId: string) => listAgentsMock(companyId) },
|
||||
}));
|
||||
|
|
@ -176,6 +187,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
finishAppMock.mockResolvedValue({});
|
||||
completeConnectionIntentMock.mockResolvedValue({ status: "accepted" });
|
||||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
|
|
@ -620,7 +632,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
|
||||
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
|
||||
mockSearch.value = "source=notion";
|
||||
const interactionId = "11111111-1111-4111-8111-111111111111";
|
||||
mockSearch.value = `source=notion&intent=${interactionId}`;
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||||
listApplicationsMock.mockResolvedValueOnce({
|
||||
applications: [{
|
||||
|
|
@ -649,7 +662,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await render();
|
||||
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-existing");
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-existing", interactionId);
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||||
"https://mcp.notion.com/authorize?state=existing",
|
||||
);
|
||||
|
|
@ -897,7 +910,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps non-allowlisted OAuth apps blocked", async () => {
|
||||
it("opens customer-client OAuth apps in branded setup", async () => {
|
||||
const slack = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "slack")!;
|
||||
mockParams.appKey = "slack";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [slack] });
|
||||
|
|
@ -905,7 +918,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await render();
|
||||
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||||
});
|
||||
|
||||
it("routes the enabled Notion gallery tile through the generic source deep link", async () => {
|
||||
|
|
@ -1005,7 +1019,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
|
||||
it("keeps Zapier visible and finishes without a separate access or install step", async () => {
|
||||
mockSearch.value = "byo=1&source=zapier";
|
||||
mockSearch.value = "byo=1&source=zapier&intent=11111111-1111-4111-8111-111111111111";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{ ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } },
|
||||
|
|
@ -1053,7 +1067,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
link: zapierUrl,
|
||||
name: "Zapier",
|
||||
interactionId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
// No grantKind is sent: the pasted-URL path never offered the choice, and
|
||||
// sending "user" without asking would mis-scope the credential.
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind");
|
||||
|
|
@ -1066,6 +1084,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [
|
||||
{ targetType: "company", targetId: "company-1" },
|
||||
]);
|
||||
expect(completeConnectionIntentMock).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"conn-1",
|
||||
);
|
||||
});
|
||||
|
||||
// PAP-10922: "Run your own" / "Paste a config" moved from the sidebar to rows
|
||||
|
|
@ -1206,6 +1228,51 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=setup");
|
||||
});
|
||||
|
||||
it("keeps the originating connection intent in wizard URLs", async () => {
|
||||
const interactionId = "11111111-1111-4111-8111-111111111111";
|
||||
mockSearch.value = `byo=1&intent=${interactionId}`;
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/apps/connect?byo=1&appKey=zapier&stage=access&intent=${interactionId}`,
|
||||
);
|
||||
|
||||
await passAccessStep();
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/apps/connect?byo=1&appKey=zapier&stage=setup&intent=${interactionId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an intent on its requested manual OAuth provider", async () => {
|
||||
const interactionId = "11111111-1111-4111-8111-111111111111";
|
||||
mockSearch.value = `byo=1&appKey=asana&intent=${interactionId}`;
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [ASANA],
|
||||
capabilities: {
|
||||
canSetCompanyInstall: true,
|
||||
companyInstallReason: null,
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith(
|
||||
`/apps/connect?byo=1&intent=${interactionId}`,
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
await passAccessStep();
|
||||
expect(container.textContent).toContain("Connect Asana");
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/apps/connect?byo=1&appKey=asana&stage=setup&intent=${interactionId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("steps back from the key step to Access, and from Access to the BYO gallery", async () => {
|
||||
mockSearch.value = "byo=1";
|
||||
mockParams.appKey = "zapier";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
|
|
@ -26,7 +26,14 @@ import type {
|
|||
ToolConnectionAuthKind,
|
||||
ToolConnectionCreateCapabilities,
|
||||
} from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, getAvailableConnectionMethods } from "@paperclipai/shared";
|
||||
import {
|
||||
connectionMethodAcceptsCustomerOAuthClient,
|
||||
connectionMethodSupportsAutomaticOAuth,
|
||||
credentialConfigPath,
|
||||
getAppDefinitionForUrl,
|
||||
getAvailableConnectionMethod,
|
||||
getAvailableConnectionMethods,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
|
|
@ -34,6 +41,7 @@ import { useToast } from "@/context/ToastContext";
|
|||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { connectionIntentsApi } from "@/api/connection-intents";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
|
||||
|
|
@ -79,11 +87,22 @@ const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
|
|||
success: "complete",
|
||||
};
|
||||
|
||||
function appConnectHref(appKey: string, step: Step): string {
|
||||
function appConnectHref(appKey: string, step: Step, interactionId?: string | null): string {
|
||||
const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup";
|
||||
const params = new URLSearchParams({ byo: "1", appKey, stage });
|
||||
if (interactionId) params.set("intent", interactionId);
|
||||
return `/apps/connect?${params.toString()}`;
|
||||
}
|
||||
|
||||
function appsConnectGalleryHref(byoOnly: boolean, interactionId?: string | null): string {
|
||||
const path = byoOnly ? "/apps/byo" : "/apps/connect";
|
||||
const params = new URLSearchParams();
|
||||
if (!byoOnly) params.set("byo", "1");
|
||||
if (interactionId) params.set("intent", interactionId);
|
||||
const query = params.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
type AppAccessSelection = "all_agents" | { agentIds: string[] };
|
||||
|
||||
// Access comes before credentials so the reader knows what identity and reach
|
||||
|
|
@ -178,6 +197,9 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
const [searchParams] = useSearchParams();
|
||||
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
|
||||
const sourceSlug = searchParams.get("source")?.trim() || null;
|
||||
// The wizard rewrites its own URL while moving between steps, so retain the
|
||||
// originating request for the full setup instead of rereading the query.
|
||||
const [connectionIntentId] = useState(() => searchParams.get("intent")?.trim() || null);
|
||||
const createNewConnection = searchParams.get("new") === "1";
|
||||
const directOAuthSource = isMcpDirectOAuthConnectSlug(sourceSlug) ? sourceSlug : null;
|
||||
const requestedAppKey = appKey ?? directOAuthSource ?? undefined;
|
||||
|
|
@ -255,7 +277,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
getAvailableConnectionMethod(picked)?.auth === "oauth" &&
|
||||
isMcpDirectOAuthConnectSlug(picked.slug)
|
||||
) {
|
||||
navigate(appSourceConnectHref(picked.slug));
|
||||
navigate(appSourceConnectHref(picked.slug, connectionIntentId));
|
||||
return;
|
||||
}
|
||||
setEntry(picked);
|
||||
|
|
@ -277,7 +299,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setInstallChoice("specific");
|
||||
setGrantKind(defaultGrantKindFor(picked, initialMethod));
|
||||
setStep("access");
|
||||
navigate(appConnectHref(picked.slug, "access"));
|
||||
navigate(appConnectHref(picked.slug, "access", connectionIntentId));
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
|
|
@ -298,7 +320,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setInstallChoice("all");
|
||||
setGrantKind("organization");
|
||||
setStep("gallery");
|
||||
navigate(byoOnly ? "/apps/byo" : "/apps/connect?byo=1");
|
||||
navigate(appsConnectGalleryHref(byoOnly, connectionIntentId));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -354,11 +376,12 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
|
||||
const setAppStep = (nextStep: Step) => {
|
||||
setStep(nextStep);
|
||||
if (entry) navigate(appConnectHref(entry.slug, nextStep));
|
||||
if (entry) navigate(appConnectHref(entry.slug, nextStep, connectionIntentId));
|
||||
};
|
||||
|
||||
const oauthStartMutation = useMutation({
|
||||
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
|
||||
mutationFn: ({ connectionId, interactionId }: { connectionId: string; interactionId?: string }) =>
|
||||
toolsApi.startOAuth(connectionId, interactionId),
|
||||
onSuccess: ({ authorizationUrl }) => {
|
||||
// The endpoint chose this address, so it is checked here too — this is the
|
||||
// line where an unsafe scheme would actually run (PAP-17099).
|
||||
|
|
@ -386,7 +409,10 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
);
|
||||
},
|
||||
});
|
||||
const startOAuth = oauthStartMutation.mutate;
|
||||
const startOAuthMutation = oauthStartMutation.mutate;
|
||||
const startOAuth = useCallback((connectionId: string, interactionId = connectionIntentId ?? undefined) => {
|
||||
startOAuthMutation({ connectionId, interactionId });
|
||||
}, [connectionIntentId, startOAuthMutation]);
|
||||
|
||||
/**
|
||||
* Commit the Access step's agent reach for a connection. Shared by the
|
||||
|
|
@ -417,6 +443,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
? configValues
|
||||
: undefined,
|
||||
applicationId: prefill.applicationId,
|
||||
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
|
||||
...(grantKind === "user" ? { grantKind } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -432,6 +459,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
oauthClientSecret: linkOAuthClientSecret,
|
||||
}),
|
||||
applicationId: prefill.applicationId,
|
||||
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
|
||||
...(grantKind === "user" ? { grantKind } : {}),
|
||||
});
|
||||
},
|
||||
|
|
@ -523,12 +551,17 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
const method = requestedEntry ? getAvailableConnectionMethod(requestedEntry) : null;
|
||||
const methods = requestedEntry ? getAvailableConnectionMethods(requestedEntry) : [];
|
||||
const directOAuth = method?.auth === "oauth" && isMcpDirectOAuthConnectSlug(requestedEntry?.slug);
|
||||
const brokeredOAuth = method?.oauthStrategy === "paperclip_id_connector";
|
||||
const unsupportedOAuth = methods.length === 1 && method?.auth === "oauth" && !brokeredOAuth && !directOAuth;
|
||||
const unsupportedOAuth = methods.length === 1
|
||||
&& method?.auth === "oauth"
|
||||
&& !connectionMethodSupportsAutomaticOAuth(method)
|
||||
&& !connectionMethodAcceptsCustomerOAuthClient(method);
|
||||
if (!requestedEntry || unsupportedOAuth || requestedEntry.availability?.available === false) {
|
||||
setEntry(null);
|
||||
setStep("gallery");
|
||||
navigate("/apps/connect", { replace: true });
|
||||
navigate(
|
||||
connectionIntentId ? appsConnectGalleryHref(false, connectionIntentId) : "/apps/connect",
|
||||
{ replace: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -570,7 +603,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setOAuthError(null);
|
||||
setOAuthPhase("starting");
|
||||
if (existingOAuthConnection) {
|
||||
startOAuth(existingOAuthConnection.id);
|
||||
startOAuth(existingOAuthConnection.id, connectionIntentId ?? undefined);
|
||||
} else {
|
||||
connectApp(requestedEntry);
|
||||
}
|
||||
|
|
@ -581,6 +614,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
connectApp,
|
||||
connectionsQuery.isError,
|
||||
connectionsQuery.isFetchedAfterMount,
|
||||
connectionIntentId,
|
||||
entry?.slug,
|
||||
existingOAuthConnection,
|
||||
galleryQuery.data,
|
||||
|
|
@ -626,6 +660,9 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
access: selection,
|
||||
});
|
||||
await applyAccessInstalls(connected.connectionId);
|
||||
if (connectionIntentId) {
|
||||
await connectionIntentsApi.complete(connectionIntentId, connected.connectionId);
|
||||
}
|
||||
return finished;
|
||||
},
|
||||
onSuccess: () => setAppStep("success"),
|
||||
|
|
|
|||
|
|
@ -24,5 +24,7 @@ describe("app connect policy", () => {
|
|||
|
||||
it("builds a generic source deep link", () => {
|
||||
expect(appSourceConnectHref("notion")).toBe("/apps/connect?source=notion");
|
||||
expect(appSourceConnectHref("notion", "intent-1"))
|
||||
.toBe("/apps/connect?source=notion&intent=intent-1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ export function isMcpDirectOAuthConnectSlug(slug: string | null | undefined): bo
|
|||
return MCP_DIRECT_OAUTH_CONNECT_SLUGS.some((allowedSlug) => allowedSlug === slug);
|
||||
}
|
||||
|
||||
export function appSourceConnectHref(slug: string): string {
|
||||
return `/apps/connect?${new URLSearchParams({ source: slug }).toString()}`;
|
||||
export function appSourceConnectHref(slug: string, interactionId?: string | null): string {
|
||||
const params = new URLSearchParams({ source: slug });
|
||||
if (interactionId) params.set("intent", interactionId);
|
||||
return `/apps/connect?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
|
||||
|
|
|
|||
Loading…
Reference in New Issue