diff --git a/doc/AGENT-IDENTITY.md b/doc/AGENT-IDENTITY.md index f8dabb3c54a..0e379b905d7 100644 --- a/doc/AGENT-IDENTITY.md +++ b/doc/AGENT-IDENTITY.md @@ -98,3 +98,7 @@ have distinct identities. Native failure records and reports redact the assigned key before truncating diagnostics. Streaming output buffers settle at item or turn completion: short structural prefixes (such as a trailing dash) are preserved, longer interrupted key fragments become redaction markers, and pending buffers are cleared. Terminal events carry these settled `outputTails`; transcript projection displays them as deltas without changing the source event receipt. Codex shell delivery preserves its default `KEY`/`SECRET`/`TOKEN` name exclusions for other configured credentials. The exceptions are the three identity variables and `PAPERCLIP_API_KEY`: the latter is the short-lived, scoped run/bridge credential required by the Paperclip agent skill’s Bash/curl API calls. Disabling Codex’s automatic exclusions is paired with this explicit filtered allowlist; it does not admit arbitrary host environment variables. Provider authentication secrets can still reach the provider process without being newly exposed to shell commands by this feature. + +Cloud customer-success inspection can consume this existing identity together +with strict active-run authority. See [inspection support](CUSTOMER-SUCCESS-INSPECTION.md). +No additional agent keypair or private-key distribution is introduced. diff --git a/doc/CUSTOMER-SUCCESS-INSPECTION.md b/doc/CUSTOMER-SUCCESS-INSPECTION.md new file mode 100644 index 00000000000..e20a2c807f3 --- /dev/null +++ b/doc/CUSTOMER-SUCCESS-INSPECTION.md @@ -0,0 +1,85 @@ +# Cloud customer-success inspection support + +Paperclip exposes `/api/customer-success/v1/run-authority` and `/read` for the +coordinated Cloud inspection broker. This foundation supports one registered +Paperclip agent; bot creation, scheduling, scoring and reporting are separate. + +The authority endpoint is disabled unless +`PAPERCLIP_CUSTOMER_SUCCESS_AUTHORITY_ENABLED=true`. It accepts only a strict, +current instance/company-derived managed-run JWT. It requires the authenticated +agent's existing provisioned Ed25519 identity and an active running heartbeat; +paused, cancelled/stopped, finished and unsupported-runtime agents are rejected. +No identity GET provisions keys. Normal API JWT compatibility remains unchanged; +legacy signatures are rejected specifically at this authority boundary. + +Tenant reads are disabled unless +`PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED=true`. Configure the public-only +`PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_JWKS` and the stable HTTPS +`PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN`. Cloud provision/roll delivers the trust +configuration. The persisted runtime stack identity is authoritative; an explicit +`PAPERCLIP_CLOUD_STACK_ID` is usable on operator-configured qualification stacks. +Cloud's dedicated inspection key is distinct from the inspecting agent's key. + +The tenant verifies a maximum-sixty-second, audience/type-bound Ed25519 permit +with exact stack, query, grant, registered key/agent/run, request and binding +version. It calls Cloud's permit-consumption endpoint before reading, so replay +fencing and every inspection record live in Cloud. Cloud remains responsible for +stack eligibility, seven days from creation/pooled claim, human older-stack +approval, active source-run checks, limits, revocation and audits. The source run +bearer is never forwarded to tenants. + +The namespace terminates before actor middleware. It never creates users, +memberships, sessions, run-identity snapshots, activity records or read receipts. +All database readers run inside repeatable-read, read-only transactions. An +explicit catalog avoids existing GET side effects such as skill reconciliation, +instruction recovery/adoption and provider-trace cleanup. There is no arbitrary +GET proxy, write operation or credential-resolution operation. + +The catalog in `services/customer-success-inspection.ts` covers companies, +directory/membership/permission metadata, agents/config/instruction revisions and +public identity, tasks/comments/documents/interactions, approvals/decisions, +routines/schedules, projects/workspaces/stored repository assignments, skill +sources/versions, execution events/trace metadata, activity/costs, work products/ +assets/attachments, and existing readable connections/installs/grants/catalogs. +`packages/shared/src/customer-success.ts` is the versioned wire contract and is +mirrored byte-for-byte in the private Cloud broker; update both together. + +`list`/`get` readers enforce company scope and bounded pagination. Special +operations read instruction files without repair, installed skill snapshots, +existing run logs, workspace files through the current file-resource service, +and company-scoped storage assets. Workspace context uses an owning task and +existing project/workspace checks. Binary envelopes and inclusive byte ranges +pass through Cloud; ranges cap at 1 MiB, JSON at 2 MiB, and lists at 100 rows. +Remote or unsnapshotted resources report unavailable; large content requires +pagination or download ranges. No storage credentials or bypass URLs are issued. + +Existing config/event/run redactions and path/symlink/secret-file/size restrictions +remain in use. Authentication account/session, secret, private-key/encrypted-key +and raw provider-trace tables/readers are excluded. Identity-enabled trace +suppression stays intact. No new prose/file scanner is added; arbitrary pasted +secrets in otherwise readable content may be present. + +Deploy this support first, then Cloud's migration/broker/admin UI. Keep both +inspection and Cloud policy disabled until staging and internal canary checks +pass. Wake only idle sleeps through Cloud's existing controller; normal startup +and background writes after wake are separate from the inspection read. Existing +human login/Slack alerts are unchanged. + +Focused verification: + +```sh +pnpm exec vitest run server/src/__tests__/customer-success.test.ts \ + server/src/__tests__/agent-auth-jwt.test.ts server/src/__tests__/agent-identity.test.ts +# Coordinated qualification, with the sibling Cloud build: +PAPERCLIP_INSPECTION_CLOUD_DIST=/absolute/path/paperclip-cloud/dist \ + pnpm exec vitest run server/src/__tests__/customer-success-cloud.test.ts +``` + +The coordinated test uses two disposable local PostgreSQL databases, a real +managed process with the existing identity/env injection, a PostgreSQL-backed +Cloud broker, HTTP permit consumption, the existing wake controller with a local +provider, and bounded binary file reads. It tests durable replay fencing and +never touches a customer. Cloud's operations document covers enrollment, +capability grants, exclusions, approval/expiry/revocation, audit retention and +rollback. During rollback, disable Cloud policy first, disable tenant inspection, +and preserve identity material and Cloud audit history. diff --git a/doc/evals.md b/doc/evals.md index a242d214731..77b5ac15e23 100644 --- a/doc/evals.md +++ b/doc/evals.md @@ -41,6 +41,12 @@ standard/Ask tasks early, while preserving user-supplied titles. Its oracle correlates browser creation, native tool receipts, durable titles, audit ownership, and the reloaded task UI; fixture prompts contain no naming instructions. +The explicit-only [native connection guidance suite](../tests/runner-e2e/README.md#native-connection-guidance-explicit-only) +adds neutral decline prompts, same-task run-attributed explanations, and measured +no-use controls across three native local profiles. Its fifteen configured cells +are preparation for future matched instruction comparisons, not a live result. +Historical Everyday cases and production prompts are preserved. + ## Selecting a family Use **Runner Evals** for a runner protocol, adapter, transport, native session, diff --git a/doc/plans/2026-10-06-customer-success-inspection.md b/doc/plans/2026-10-06-customer-success-inspection.md new file mode 100644 index 00000000000..37e52e5a503 --- /dev/null +++ b/doc/plans/2026-10-06-customer-success-inspection.md @@ -0,0 +1,48 @@ +# Customer-success inspection foundation + +Approved scope: one designated Paperclip agent uses its existing persistent +Ed25519 identity plus a verified, active managed-run credential. Cloud owns +short-lived grants, older-stack human approvals, durable replay fencing and audit. +Customer stacks expose an explicit read-only API before ordinary auth middleware. +Automatic eligibility is seven days from stack creation (or recorded pooled +claim), with no user/account/signup-age condition. The eventual bot and morning +schedule are separate work. + +## Implementation and qualification + +- Paperclip: `codex/customer-success-inspection`, updated to master `ac8f3eb14`. +- Cloud: same branch name, isolated worktree `/private/tmp/paperclip-cloud-customer-success`, updated to master `044160d0`. +- Core strict JWT/run-authority, versioned permit routes, explicit resource catalog, + read-only transactions, instruction/workspace/asset/snapshot readers implemented. +- Cloud broker, dedicated signer, durable store/migration, admin-session/capability + routes, registration/kill switch/approval/audit UI and agent client implemented. +- Core catalog, file restrictions, active-run authority and existing identity tests pass. + The complete database snapshot remains equal before and after catalog reads. +- Coordinated qualification passes with a real managed process agent, separate + source/customer PostgreSQL databases, two Cloud broker replicas, HTTP permits, + runtime-role append-only enforcement, one-year retention, the existing wake + controller with a local provider, and binary file reads through Cloud. +- Cloud root suite: 2,544 pass, 73 expected skips. Admin web: 779 pass. The 23 + inspection checks cover scoped approval review, replay, revocation, bounded + anonymous audit traffic, and database-pool concurrency. Routing/wake smoke passes. +- Browser acceptance verified native admin login, navigation, exact target review, + approval, revocation, immediate disablement and persistence after refresh. +- Full core typecheck and build pass. The local broad test run recorded 14,277 + passes and four failures in existing suites (two timeouts and two PR-metadata + mock assertions); all three affected suites pass on isolated reruns. The full + CI matrix passes at the implementation head. Public PR #15405 and its companion + Cloud PR carry current review/check status. No deployment or customer enablement. + +## Required invariants + +No database credentials, owner-login fallback, private identity material, tenant +sessions/memberships/activity/read receipts or routine Slack alerts. Preserve +existing content redactions, file restrictions and raw-trace suppression. Audit +before dispatch and before release; recheck run, policy, grant and age exception. +The source bearer never reaches customers. Tenant permits are short-lived, +operation-bound and consumed atomically in Cloud. Human approvals override age +only, freeze exact targets, and can authorize later runs of the same identity. + +Deploy core support before Cloud migration/broker/UI. Disabled by default; staging +and internal canary qualification precede customer enablement. Default retention +one year; rollback/reenrollment/exclusions/retention runbooks accompany both PRs. diff --git a/doc/plans/2026-10-06-native-connection-guidance.md b/doc/plans/2026-10-06-native-connection-guidance.md new file mode 100644 index 00000000000..5e0085b1088 --- /dev/null +++ b/doc/plans/2026-10-06-native-connection-guidance.md @@ -0,0 +1,125 @@ +# Native connection guidance: audit and eval preparation + +## Decision + +Keep production connection instructions intact in this slice. The current +fixtures cannot justify removing all of the repeated text: decline prompts +teach the no-retry behavior, the old provider-decline grader permits an empty +explanation, and several boundaries have no direct live oracle. This change +prepares a separate neutral comparison suite and preserves the existing cases. +It does not claim that either production text or the new live journeys passed +a model comparison. + +Audit base: faa8e452c73bae5e044dd6379179a00106abb131. +The preceding hiring-only experiment #15389 remains a separate failed +qualification. Its results do not establish connection behavior. + +## What reaches the model + +| Layer | Delivery | Relevant source | +| --- | --- | --- | +| Native fixed prompt | Session instruction context | packages/paperclip-runner/src/contracts/runtime-context.ts | +| Connection discovery and request descriptions/schemas | Granted tool catalog; subject to provider/catalog delivery | server/src/services/connection-tool-definitions.ts and packages/shared/src/connection-intent-guidance.ts | +| Shared connection guidance | Legacy prompt/environment delivery and native task-context tool results | packages/adapter-utils/src/server-utils.ts and server/src/services/native-runtime/paperclip-runner-tool-authority.ts | +| Search, request, and outcome instructions | Returned as the operation/state is encountered | server/src/services/connection-intents.ts | +| Assigned connection instructions | Optional per-connection runtime context, separate from fixed guidance | packages/paperclip-runner/src/contracts/runtime-context.ts | + +The fixed connection span is 98 whitespace-separated words / 654 UTF-8 bytes +including its trailing space. The complete fixed prompt is 262 words / 1,713 +bytes. These are source counts, not provider tokens, billed usage, every +configuration's total prompt, or proof of upstream loading/truncation. + +The existing full-catalog capture measures nine scripted provider/phase +projections plus an authenticated OpenCode MCP tools/list. Its source manifest +now also hashes the shared connection text, tool-definition source and input +validators that supply these bytes. The captured catalog contains 41 supplied tools. All three providers have +normalized projections of 53,341 bytes at start/resume and 50,949 bytes for +compact continuation; the authenticated MCP tool catalog is 48,195 bytes. +The connection search/request descriptions are 651/503 bytes and their schemas +248/611 bytes. The fixture has API tools enabled and no +assigned external apps. Per-connection instructions, app catalogs, private +vendor prompts, and runtime results are not silently counted as standing text. + +## Rules that must survive a later reduction + +- Explicit connection requests must search before using even an installed + service. Ordinary work can use installed access without unnecessary setup. +- Provider discovery is not human consent or proof of underlying app access. + Saved provider choice and permission gates remain authoritative. +- The actual request tool creates setup or Grant access cards. Do not invent + access or solicit credentials in task comments. +- Complete independent work, then yield. Do not poll or repeat requests while + waiting. Continue from the saved outcome and refreshed tools. +- Respect decline and use an alternative when possible. An explicit human + request is needed to reconsider a declined provider choice. + +The search/request descriptions and result instructions already carry much of +this procedure. They do not by themselves prove every model discovers it, nor +does moving words into descriptions prove a lower total instruction load. +The fixed decline/alternative rule and optional access-grant paths need explicit +coverage before removal. No prompt revision or session compatibility changes +are needed while production stays byte-identical. + +## New executable coverage + +The manual-only native-connection-guidance suite has five cases on local native +Codex, ACPX Claude and OpenCode, for fifteen configured cells: + +| Case | Independent boundary | +| --- | --- | +| service-approve | No fixture call before approval; one afterwards; saved briefing contains actual titles and hidden marker | +| service-decline | Saved refusal; no call or replacement request; attributed post-decision explanation | +| connection-decline | Real Notion setup card; Not now saved; no connection or repeat; attributed explanation | +| provider-decline | Real provider choice, restart/reload, None saved; instrumented installed gateway receives zero calls; attributed explanation | +| provider-second | Saved second-provider choice after restart; no early call, one chosen-provider call; actual marker and no duplicate setup | + +Decline prompts contain a user-permitted fallback but no no-retry, decline, +tool-selection, polling or completion-protocol instruction. They still define +the requested deliverable: a brief explanation if data is unavailable. This +allows Done without pretending that unfinished required work is complete. + +New explanation checks join comment.createdByRunId to a successful run with the same +agent and native issue. They reject absent/stale/wrong-agent/wrong-task output, +missing decision timestamps, unrelated decisions, duplicate requests, missing +call evidence, unauthorized calls and connection changes. They check saved +output, not cognitive consumption or arbitrary prose truthfulness. Existing +screenshots and the original independent workflow checks remain in use. + +Each cell has one attempt, expected two provider turns, a twelve-run ceiling, +a 720-second deadline and verified 1,000-cent company/lead budget hard stops. +The bound is a ceiling, not a target. The selected provider supplies its normal +model profile and permissions. No production or workflow credentials are +changed. No paid campaign is started by defining or testing this suite. + +## Remaining coverage before any broad connection reduction + +A single saved interaction does not prove that an agent avoided repeating an +idempotent request-tool call. This suite rejects duplicate saved decisions, +not every repeated tool invocation. + +Successful new authentication/setup and tool refresh, existing-connection +agent grants, useful independent work before yielding, explicit reconsideration +after decline, arbitrary provider compatibility, and unavailable access with +remaining mandatory work need separate oracles. A passing subset cannot +qualify these missing behaviors. Positive approval of an already installed +service is not successful connection creation. + +## Validation and disposition + +Local support validation passes 1,424 TypeScript tests and 128 Node checks. +The six full-catalog measurement tests, repository typecheck/build and Product +E2E typecheck pass. Exact suite discovery lists fifteen local cells and tests +confirm exclusion from default/generic selection. The pre-rebase full test invocation was stopped when master advanced. Its +partial log is not a pass. The branch was replayed onto master +`a6306ba606eb87c89b9ef0344e9fe8e0025580f9`, retaining the upstream Cursor catalog +additions. The measurements above remain tied to the original audit base. +Post-rebase verification and source CI/review are pending at preparation. +The initial support run exposed catalog-size expectations needing the fifteen +new cells, and typecheck caught an unknown comment-body input; both are fixed +and the final support/typecheck runs pass. + +This PR changes no production instruction, tool-description, authority or +runtime file relative to its master parent. Upstream runtime changes during +the rebase are not instruction savings from this PR. There is no baseline/candidate behavioral score yet and no paid +usage in this preparation slice. Preserve historical failures and report any +future original attempt without rerolling a usable behavioral failure. diff --git a/doc/public-mcp.md b/doc/public-mcp.md index b3017b500c6..87a66486427 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -88,7 +88,9 @@ or refetching does not undo a write-access opt-out. Nothing is authorized until Return to the same Connections entry to see your connected assistants, their read/write access, and revoke access. The list refreshes after consent and only -shows your grants for the selected organization. The legacy +shows your active grants for the selected organization. Each connection shows your +profile avatar and defaults to a name such as “Dotta’s Codex connection.” Revoked +connections disappear from the list; their audit records remain. The legacy `/assistant-connections` URL remains available for account-wide management. `GET /api/mcp/setup` returns the live experimental gate, canonical endpoint, diff --git a/packages/shared/src/customer-success.ts b/packages/shared/src/customer-success.ts new file mode 100644 index 00000000000..a2d02aaa822 --- /dev/null +++ b/packages/shared/src/customer-success.ts @@ -0,0 +1,221 @@ +/** Version 1 inspection wire contract. Keep Cloud's protocol.ts byte-identical. */ +export const INSPECTION_VERSION = 1; +export const INSPECTION_AUDIENCE = "paperclip-customer-success/v1"; +export const INSPECTION_PERMIT_TYPE = "paperclip-inspection+jwt"; +export const INSPECTION_HEADER = "x-paperclip-cloud-inspection"; +export const CHALLENGE_DOMAIN = "paperclip-customer-success-challenge/v1\n"; +export const MAX_INSPECTION_BYTES = 1024 * 1024; +export const MAX_INSPECTION_ROWS = 100; +export const INSPECTION_RESOURCES = [ + "companies", + "goals", + "users", + "memberships", + "permissions", + "agents", + "agentIdentities", + "agentInstructions", + "agentConfigRevisions", + "tasks", + "comments", + "documents", + "documentRevisions", + "taskDocuments", + "interactions", + "approvals", + "approvalComments", + "decisions", + "routines", + "routineTriggers", + "routineRuns", + "routineRevisions", + "routineDocuments", + "projects", + "projectWorkspaces", + "projectMemberships", + "skills", + "skillVersions", + "skillSources", + "runs", + "runEvents", + "traceMetadata", + "activity", + "costs", + "workProducts", + "artifacts", + "attachments", + "connections", + "connectionInstalls", + "connectionGrants", + "connectionGrantMembers", + "connectionCatalog", + "toolProfiles", + "toolProfileEntries", + "agentMemberships", + "agentCommentary", + "skillPolicies", + "projectGoals", + "labels", + "taskLabels", + "taskRelations", + "taskApprovals", + "documentMemberships", + "documentThreads", + "documentComments", + "threads", +] as const; +export type InspectionResource = (typeof INSPECTION_RESOURCES)[number]; +export interface InspectionQuery { + operation: + | "list" + | "get" + | "instructions.file" + | "skills.file" + | "runs.log" + | "files.list" + | "files.read" + | "files.download" + | "assets.content"; + resource?: InspectionResource; + companyId?: string; + resourceId?: string; + parentId?: string; + path?: string; + versionId?: string; + limit?: number; + offset?: number; + range?: { start: number; end: number }; + context?: { + projectId?: string; + workspaceId?: string; + workspace?: "auto" | "execution" | "project"; + }; +} +export interface RunAuthority { + version: 1; + instanceId: string; + companyId: string; + agentId: string; + runId: string; + keyId: string; + active: true; +} +export interface InspectionPermit { + v: 1; + iss: "paperclip-cloud"; + aud: typeof INSPECTION_AUDIENCE; + sub: string; + jti: string; + iat: number; + exp: number; + bindingVersion: number; + grantId: string; + requestId: string; + agentId: string; + keyId: string; + runId: string; + query: InspectionQuery; +} +export function canonicalJson(value: unknown): string { + if (value === null || typeof value === "string" || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + const entries = Object.entries(value) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; + } + throw new Error("Invalid JSON value"); +} +export function parseInspectionQuery(value: unknown): InspectionQuery { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Invalid inspection query"); + const q = value as InspectionQuery; + const allowed = new Set([ + "operation", + "resource", + "companyId", + "resourceId", + "parentId", + "path", + "versionId", + "limit", + "offset", + "range", + "context", + ]); + if (Object.keys(q).some((k) => !allowed.has(k))) + throw new Error("Unsupported inspection parameter"); + if ( + ![ + "list", + "get", + "instructions.file", + "skills.file", + "runs.log", + "files.list", + "files.read", + "files.download", + "assets.content", + ].includes(q.operation) + ) + throw new Error("Unsupported inspection operation"); + for (const k of ["companyId", "resourceId", "parentId", "versionId"] as const) { + if (q[k] !== undefined && (typeof q[k] !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(q[k]!))) + throw new Error(`Invalid ${k}`); + } + if (q.resource !== undefined && !INSPECTION_RESOURCES.includes(q.resource)) + throw new Error("Unsupported inspection resource"); + if (q.operation === "get" && !q.resourceId) throw new Error("resourceId required"); + if (["get", "list"].includes(q.operation) && !q.resource) throw new Error("resource required"); + if (!(q.operation === "list" && q.resource === "companies") && !q.companyId) + throw new Error("companyId required"); + if (!["get", "list"].includes(q.operation) && !q.resourceId) + throw new Error("resourceId required"); + if ( + q.path !== undefined && + (typeof q.path !== "string" || q.path.length > 1024 || q.path.includes("\0")) + ) + throw new Error("Invalid path"); + if ( + q.range !== undefined && + !["files.download", "assets.content", "runs.log"].includes(q.operation) + ) + throw new Error("This operation does not support byte ranges"); + for (const k of ["limit", "offset"] as const) + if ( + q[k] !== undefined && + (!Number.isSafeInteger(q[k]) || q[k]! < 0 || q[k]! > (k === "limit" ? 100 : 1000000)) + ) + throw new Error(`Invalid ${k}`); + if ( + q.range !== undefined && + (!q.range || + typeof q.range !== "object" || + Array.isArray(q.range) || + Object.keys(q.range).sort().join(",") !== "end,start" || + !Number.isSafeInteger(q.range.start) || + !Number.isSafeInteger(q.range.end) || + q.range.start < 0 || + q.range.end < q.range.start || + q.range.end - q.range.start + 1 > MAX_INSPECTION_BYTES) + ) + throw new Error("Invalid byte range"); + if (q.context !== undefined) { + if ( + !q.context || + typeof q.context !== "object" || + Array.isArray(q.context) || + Object.keys(q.context).some((k) => !["projectId", "workspaceId", "workspace"].includes(k)) || + Object.values(q.context).some( + (v) => typeof v !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(v), + ) || + (q.context.workspace !== undefined && + !["auto", "execution", "project"].includes(q.context.workspace)) + ) + throw new Error("Invalid file context"); + } + return JSON.parse(canonicalJson(q)) as InspectionQuery; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4c01d1b482e..0e40261c5af 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2832,3 +2832,4 @@ export { aiConnectionRouterSlug, aiConnectionRouterAppDefinition, aiConnectionRo export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, aggregatorAppsRefreshSchema, arcadeDiscoverySetupSchema, type AggregatorAppSnapshot, type AggregatorAppsResponse, type ArcadeDiscoverySetupInput } from "./aggregator-apps.js"; export * from "./connection-instructions.js"; +export * from "./customer-success.js"; diff --git a/packages/shared/src/mcp-setup.ts b/packages/shared/src/mcp-setup.ts index 06428fa8d17..a774458f00a 100644 --- a/packages/shared/src/mcp-setup.ts +++ b/packages/shared/src/mcp-setup.ts @@ -10,7 +10,7 @@ export function mcpSetupUrl(serverUrl: string, companyId?: string) { return url.toString(); } export function mcpInvitation(serverUrl: string, company?: { id: string; name: string }) { - return `Connect to my ${company ? `${company.name} ` : ""}Paperclip organization using the instructions at ${mcpSetupUrl(serverUrl, company?.id)}. Start authorization, give me the approval link, and verify the connection after I approve. This connects as me, not as a Paperclip agent.`; + return `Connect to my ${company ? `${company.name} ` : ""}Paperclip organization using the instructions at ${mcpSetupUrl(serverUrl, company?.id)}. Start authorization, give me the approval link, and verify the connection after I approve.`; } export function mcpSetupSteps(serverUrl: string, assistant: AssistantClient) { const quoted = `'${serverUrl.replaceAll("'", "'\"'\"'")}'`; diff --git a/packages/shared/src/public-mcp.ts b/packages/shared/src/public-mcp.ts index ce8ac9ccd98..f57bd5dadef 100644 --- a/packages/shared/src/public-mcp.ts +++ b/packages/shared/src/public-mcp.ts @@ -39,6 +39,8 @@ export interface McpConnection { companyId: string; clientName: string; companyName: string; + /** The signed-in person who authorized this connection; absent on older servers. */ + user?: { name: string; image: string | null } | null; scopes: string[]; createdAt: string; revokedAt: string | null; diff --git a/server/src/__tests__/customer-success-cloud.test.ts b/server/src/__tests__/customer-success-cloud.test.ts new file mode 100644 index 00000000000..3fcbb12530b --- /dev/null +++ b/server/src/__tests__/customer-success-cloud.test.ts @@ -0,0 +1,407 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; +import express from "express"; +import { describe, it, expect, vi } from "vitest"; +import { + createDb, + agents, + companies, + heartbeatRuns, + issues, + projects, + projectWorkspaces, +} from "@paperclipai/db"; +import { customerSuccessRoutes } from "../routes/customer-success.js"; +import { agentIdentityService } from "../services/agent-identity.js"; +import { createLocalAgentJwt } from "../agent-auth-jwt.js"; +import { execute } from "../adapters/process/execute.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import type { StorageService } from "../storage/types.js"; + +// Coordinated qualification against the sibling Cloud build, never a customer +// stack or hosted provider. Default unit runs have no cross-repository dependency. +const cloudDist = process.env.PAPERCLIP_INSPECTION_CLOUD_DIST; +describe.skipIf(!cloudDist)("customer-success managed agent / Cloud / tenant qualification", () => { + it("uses the existing managed-runtime identity, active JWT, PostgreSQL broker, HTTP permits, and audited tenant reads", async () => { + const load = (module: string) => import(pathToFileURL(resolve(cloudDist!, module)).href); + const [ + { CustomerSuccessInspection }, + { InspectionSigner }, + { PostgresInspectionStore }, + { inspectionRoute }, + { InMemoryCloudHarnessRegistry }, + { CloudStackSleepController }, + pg, + ] = await Promise.all([ + load("customer-success/service.js"), + load("customer-success/crypto.js"), + load("customer-success/store.js"), + load("customer-success/routes.js"), + load("provisioner/memory.js"), + load("sleep/controller.js"), + load("../node_modules/pg/lib/index.js"), + ]); + const home = await startEmbeddedPostgresTestDatabase("inspection-home-"); + const tenant = await startEmbeddedPostgresTestDatabase("inspection-customer-"); + const directory = await mkdtemp(join(tmpdir(), "inspection-journey-")); + const servers: ReturnType[] = []; + const serverErrors: string[] = []; + const pool = new pg.default.Pool({ + connectionString: home.connectionString, + max: 2, + connectionTimeoutMillis: 1000, + }); + try { + vi.stubEnv("PAPERCLIP_HOME", directory); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "qualification-home"); + vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "isolated-qualification-jwt"); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(directory, "master.key")); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_AUTHORITY_ENABLED", "true"); + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED", "true"); + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "qualification-stack"); + const homeDb = createDb(home.connectionString); + const tenantDb = createDb(tenant.connectionString); + const [homeCompany] = await homeDb + .insert(companies) + .values({ name: "Internal", issuePrefix: "INT" }) + .returning(); + const [agent] = await homeDb + .insert(agents) + .values({ + companyId: homeCompany.id, + name: "Success qualification", + adapterType: "process", + }) + .returning(); + const identity = await agentIdentityService(homeDb).ensureAgentIdentity( + homeCompany.id, + agent.id, + ); + const [run] = await homeDb + .insert(heartbeatRuns) + .values({ + companyId: homeCompany.id, + agentId: agent.id, + status: "running", + startedAt: new Date(), + }) + .returning(); + const [customer] = await tenantDb + .insert(companies) + .values({ name: "Synthetic customer", issuePrefix: "SYN" }) + .returning(); + const [project] = await tenantDb + .insert(projects) + .values({ companyId: customer.id, name: "Qualification project" }) + .returning(); + const [workspace] = await tenantDb + .insert(projectWorkspaces) + .values({ + companyId: customer.id, + projectId: project.id, + name: "Qualification files", + cwd: directory, + }) + .returning(); + const filePath = join(directory, "result.bin"); + await writeFile(filePath, Buffer.from([0, 1, 2, 3, 4, 5])); + const fileBefore = await stat(filePath); + const [task] = await tenantDb + .insert(issues) + .values({ companyId: customer.id, projectId: project.id, title: "First successful task" }) + .returning(); + const storage = { provider: "local_disk" } as StorageService; + const start = async (handler: Parameters[0]) => { + const server = createServer(handler); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }; + const homeApp = express(); + homeApp.use(express.json()); + homeApp.use("/api/customer-success/v1", customerSuccessRoutes(homeDb, storage)); + homeApp.use(errorHandler); + const sourceOrigin = await start(homeApp); + const tenantApp = express(); + tenantApp.use(express.json()); + tenantApp.use("/api/customer-success/v1", customerSuccessRoutes(tenantDb, storage)); + tenantApp.use(errorHandler); + const tenantOrigin = await start(tenantApp); + await pool.query("CREATE SCHEMA cloud_harness"); + await pool.query( + await readFile( + resolve(cloudDist!, "../migrations/0058_customer_success_inspection.sql"), + "utf8", + ), + ); + const registry = new InMemoryCloudHarnessRegistry(); + let transactionReads = 0; + const registryForClient = (client: any) => ({ + getStack: async (id: string) => { + await client.query("SELECT 1"); + transactionReads++; + return registry.getStack(id); + }, + getAccount: async (id: string) => { + await client.query("SELECT 1"); + transactionReads++; + return registry.getCustomerSuccessAccount(id); + }, + listStackIds: async () => registry.listCustomerSuccessStackIds(), + }); + const store = new PostgresInspectionStore(pool, registryForClient); + const now = new Date(); + registry.accountGroups.set("fixture-account", { + id: "fixture-account", + kind: "customer", + billingStatus: "unconfigured", + }); + registry.stacks.set("qualification-stack", { + id: "qualification-stack", + accountGroupId: "fixture-account", + displayName: "Synthetic customer", + primaryHost: "fixture.example.test", + kind: "customer_production", + lifecycleState: "sleeping", + sleepState: "sleeping", + sleepReason: "idle", + version: 1, + updatedAt: now, + rolloutMetadata: { track: "stable" }, + providerRefs: [ + { + provider: "railway", + resourceType: "service_domain:web", + externalId: "fixture", + metadata: { upstreamOrigin: tenantOrigin }, + }, + ], + createdAt: now, + }); + const signer = new InspectionSigner( + "qualification", + generateKeyPairSync("ed25519").privateKey.export({ format: "jwk" }), + ); + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_JWKS", signer.publicJwks); + // Use the production wake controller with a disposable local provider. + // The tenant HTTP process is already listening; no hosted stack is woken. + const sleep = new CloudStackSleepController({ + registry, + provider: { + name: "fake", + wakeStack: async ( + _id: string, + context: { stack: Record; providerRefs: unknown[] }, + ) => ({ + stack: { ...context.stack, lifecycleState: "active", sleepState: "awake" }, + resources: context.providerRefs, + secretRefs: { providerAdminCredentials: {} }, + operations: [], + }), + inspectStack: async () => ({ + stackId: "qualification-stack", + lifecycleState: "active", + sleepState: "awake", + resources: registry.stacks.get("qualification-stack").providerRefs, + }), + }, + }); + const broker = new CustomerSuccessInspection({ + store, + registry, + signer, + allowLoopback: true, + wakeStack: async (stackId: string) => { + await sleep.wakeStack({ stackId }); + }, + }); + const cloudOrigin = await start(async (req, res) => { + try { + const handled = await inspectionRoute({ + req, + res, + url: new URL(req.url!, "http://localhost"), + service: broker, + authenticateHuman: async () => { + throw new Error("Qualification never uses human access"); + }, + readJson: async (request: AsyncIterable, max: number) => { + const parts: Buffer[] = []; + let bytes = 0; + for await (const part of request) { + bytes += part.length; + if (bytes > max) throw new Error("Too large"); + parts.push(part); + } + return JSON.parse(Buffer.concat(parts).toString()); + }, + }); + if (!handled) { + res.statusCode = 404; + res.end(); + } + } catch (error) { + serverErrors.push(String(error)); + res.statusCode = 503; + res.end("{}"); + } + }); + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN", cloudOrigin); + await broker.configure("qualification-operator", { + binding: { + sourceOrigin, + instanceId: "qualification-home", + companyId: homeCompany.id, + agentId: agent.id, + keyId: identity.keyId, + publicKeyPem: identity.publicKeyPem, + }, + }); + await broker.configure("qualification-operator", { enabled: true }); + const program = ` + const {inspectionAgentClient}=await import(${JSON.stringify(pathToFileURL(resolve(cloudDist!, "customer-success/client.js")).href)}); + const call=inspectionAgentClient(); + const discovery=await call('discover',{}); + const grant=await call('grant',{stackId:'qualification-stack'}); + const tasks=await call('read',{stackId:'qualification-stack',grantToken:grant.token,query:{operation:'list',resource:'tasks',companyId:${JSON.stringify(customer.id)}}}); + const file=await call('read',{stackId:'qualification-stack',grantToken:grant.token,query:{operation:'files.download',companyId:${JSON.stringify(customer.id)},resourceId:${JSON.stringify(task.id)},context:{projectId:${JSON.stringify(project.id)},workspaceId:${JSON.stringify(workspace.id)}},path:'result.bin',range:{start:2,end:5}}}); + console.log(JSON.stringify({stackCount:discovery.items.length,titles:tasks.items.map(t=>t.title),fileBytes:[...Buffer.from(file.content,'base64')]})); + `; + const programFile = join(directory, "qualify.mjs"); + await writeFile(programFile, program); + const output: string[] = []; + const result = await execute({ + runId: run.id, + agent, + agentIdentity: identity, + authToken: createLocalAgentJwt(agent.id, homeCompany.id, "process", run.id)!, + config: { + command: process.execPath, + args: [programFile], + cwd: directory, + env: { PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN: cloudOrigin }, + }, + context: {}, + runtime: { sessionId: null, sessionParams: null, taskKey: null }, + onLog: async (_stream, chunk) => { + output.push(chunk); + }, + onMeta: async () => {}, + }); + expect(result.exitCode, [...output, ...serverErrors].join("\n")).toBe(0); + expect(output.join("")).toContain("First successful task"); + expect(output.join("")).toContain('"fileBytes":[2,3,4,5]'); + expect((await stat(filePath)).mtimeMs).toBe(fileBefore.mtimeMs); + expect(await readFile(filePath)).toEqual(Buffer.from([0, 1, 2, 3, 4, 5])); + const audit = await pool.query( + "SELECT event FROM cloud_harness.customer_success_audit ORDER BY occurred_at", + ); + expect(audit.rows.map((r: { event: string }) => r.event)).toContain("permit.consumed"); + expect(audit.rows.map((r: { event: string }) => r.event)).toContain("read.completed"); + expect(audit.rows.map((r: { event: string }) => r.event)).toContain("stack.woken"); + expect(await tenantDb.select().from(issues)).toEqual([task]); + // The production SQL reader scopes before pagination and hides mixed + // approval cohorts and global policy records from tenant approvers. + await store.transaction(async (tx: any) => { + const localId = randomUUID(); + const mixedId = randomUUID(); + const foreignId = randomUUID(); + for (const [id, detail, stackId] of [ + [localId, { stackIds: ["qualification-stack"] }, undefined], + [mixedId, { stackIds: ["qualification-stack", "foreign-stack"] }, undefined], + [foreignId, {}, "foreign-stack"], + ]) + await tx.audit({ id, at: tx.now, event: "scope.fixture", detail, stackId }); + const scoped = await tx.audits(0, 100, ["qualification-stack"]); + expect(scoped.some((event: any) => event.id === localId)).toBe(true); + expect(scoped.some((event: any) => event.id === mixedId || event.id === foreignId)).toBe( + false, + ); + expect(scoped.some((event: any) => event.event === "policy.changed")).toBe(false); + expect(await tx.audits(0, 100, [])).toEqual([]); + const last = await tx.audits(scoped.length - 1, 100, ["qualification-stack"]); + expect(last).toHaveLength(1); + }); + // Two independent pool connections prove consumption is durable, not a + // process-local Set: exactly one replica can consume a signed challenge. + const token = createLocalAgentJwt(agent.id, homeCompany.id, "process", run.id)!; + const c = await broker.challenge(token, "discover", {}); + const { sign } = await import("node:crypto"); + const sig = sign(null, Buffer.from(c.bytes), identity.privateKeyPem).toString("base64url"); + const replica = new CustomerSuccessInspection({ + store: new PostgresInspectionStore(pool, registryForClient), + registry, + signer, + allowLoopback: true, + }); + const competing = await Promise.allSettled([ + broker.execute(token, c.challengeId, sig), + replica.execute(token, c.challengeId, sig), + ]); + expect(competing.filter((r) => r.status === "fulfilled")).toHaveLength(1); + const grants = await Promise.all( + Array.from({ length: 4 }, () => + broker.challenge(token, "grant", { stackId: "qualification-stack" }), + ), + ); + const concurrentGrants = await Promise.all( + grants.map((proof) => + broker.execute( + token, + proof.challengeId, + sign(null, Buffer.from(proof.bytes), identity.privateKeyPem).toString("base64url"), + ), + ), + ); + expect(concurrentGrants).toHaveLength(4); + expect(transactionReads).toBeGreaterThan(0); + // Prove append-only enforcement with a runtime role, even if someone + // accidentally grants it UPDATE/DELETE privileges on the audit table. + const client = await pool.connect(); + const runtimeRole = `inspection_runtime_${randomUUID().replaceAll("-", "")}`; + try { + await client.query(`CREATE ROLE ${runtimeRole}`); + await client.query(`GRANT USAGE ON SCHEMA cloud_harness TO ${runtimeRole}`); + await client.query( + `GRANT SELECT, UPDATE, DELETE ON cloud_harness.customer_success_audit TO ${runtimeRole}`, + ); + await client.query(`SET ROLE ${runtimeRole}`); + await expect( + client.query("UPDATE cloud_harness.customer_success_audit SET event = 'rewritten'"), + ).rejects.toThrow("append-only"); + await expect( + client.query("DELETE FROM cloud_harness.customer_success_audit"), + ).rejects.toThrow("append-only"); + } finally { + await client.query("RESET ROLE"); + client.release(); + } + const { applyInspectionRetention } = await load("customer-success/retention.js"); + await expect(applyInspectionRetention(pool, 364)).rejects.toThrow("at least one year"); + await pool.query( + "INSERT INTO cloud_harness.customer_success_audit VALUES ($1,clock_timestamp() - interval '366 days','old.fixture','{}')", + [randomUUID()], + ); + expect((await applyInspectionRetention(pool)).auditDeleted).toBe(1); + expect( + (await pool.query("SELECT count(*) FROM cloud_harness.customer_success_audit")).rows[0] + .count, + ).not.toBe("0"); + } finally { + for (const server of servers.reverse()) + await new Promise((resolve) => server.close(() => resolve())); + await pool.end(); + await tenant.cleanup(); + await home.cleanup(); + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); + } + }, 60000); +}); diff --git a/server/src/__tests__/customer-success.test.ts b/server/src/__tests__/customer-success.test.ts new file mode 100644 index 00000000000..30ace46a0e6 --- /dev/null +++ b/server/src/__tests__/customer-success.test.ts @@ -0,0 +1,605 @@ +import { randomUUID, generateKeyPairSync, createHash, createHmac, sign } from "node:crypto"; +import { mkdtemp, writeFile, rm, symlink, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import * as schema from "@paperclipai/db"; +import { + INSPECTION_AUDIENCE, + INSPECTION_HEADER, + INSPECTION_PERMIT_TYPE, + INSPECTION_RESOURCES, + parseInspectionQuery, + type InspectionQuery, + type InspectionPermit, +} from "@paperclipai/shared"; +import { customerSuccessRunAuthority } from "../services/customer-success-authority.js"; +import { + readCustomerSuccessResource, + inspectionReaders, +} from "../services/customer-success-inspection.js"; +import { customerSuccessRoutes, verifyInspectionPermit } from "../routes/customer-success.js"; +import { agentIdentityService } from "../services/agent-identity.js"; +import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { redactAgentAdapterConfig } from "../redaction.js"; +import type { StorageService } from "../storage/types.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +describe("customer-success read authority", () => { + let db: schema.Db; + let database: Awaited>; + let directory: string; + let companyId: string; + let otherCompanyId: string; + let agentId: string; + let runId: string; + let bearer: string; + const storage: StorageService = { + provider: "local_disk", + putFile: async () => { + throw new Error("write forbidden"); + }, + deleteObject: async () => { + throw new Error("write forbidden"); + }, + headObject: async () => ({ exists: true }), + getObject: async (_companyId, _objectKey, opts) => ({ + stream: Readable.from([ + Buffer.from([0, 1, 2, 3, 4]).subarray( + opts?.range?.start ?? 0, + opts?.range ? opts.range.end + 1 : undefined, + ), + ]), + contentType: "application/octet-stream", + }), + }; + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), "inspection-test-")); + vi.stubEnv("PAPERCLIP_HOME", directory); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "inspection-home"); + vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "inspection-local-fixture"); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(directory, "master.key")); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + database = await startEmbeddedPostgresTestDatabase("inspection-db-"); + db = schema.createDb(database.connectionString); + const companies = await db + .insert(schema.companies) + .values([ + { name: "Customer", issuePrefix: "CUS" }, + { name: "Other", issuePrefix: "OTH" }, + ]) + .returning(); + companyId = companies[0].id; + otherCompanyId = companies[1].id; + const [agent] = await db + .insert(schema.agents) + .values({ companyId, name: "Managed inspection fixture", adapterType: "process" }) + .returning(); + agentId = agent.id; + const [run] = await db + .insert(schema.heartbeatRuns) + .values({ companyId, agentId, status: "running", startedAt: new Date() }) + .returning(); + runId = run.id; + bearer = createLocalAgentJwt(agentId, companyId, "process", runId)!; + }); + afterAll(async () => { + await database?.cleanup(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + await rm(directory, { recursive: true, force: true }); + }); + + it("unprovisioned reads never generate a key, strict authority requires a current managed run", async () => { + await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("unprovisioned"); + expect(await db.select().from(schema.agentIdentityKeys)).toHaveLength(0); + const key = await agentIdentityService(db).ensureAgentIdentity(companyId, agentId); + expect(await customerSuccessRunAuthority(db, bearer)).toEqual({ + version: 1, + active: true, + instanceId: "inspection-home", + companyId, + agentId, + runId, + keyId: key.keyId, + }); + await expect(customerSuccessRunAuthority(db, "ordinary-api-key")).rejects.toThrow( + "managed-run", + ); + await db.update(schema.agents).set({ status: "paused" }).where(eq(schema.agents.id, agentId)); + await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("not active"); + await db.update(schema.agents).set({ status: "idle" }).where(eq(schema.agents.id, agentId)); + await db + .update(schema.heartbeatRuns) + .set({ resultJson: { executionCancellation: { state: "requested" } } }) + .where(eq(schema.heartbeatRuns.id, runId)); + await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("not active"); + await db + .update(schema.heartbeatRuns) + .set({ resultJson: null, status: "succeeded" }) + .where(eq(schema.heartbeatRuns.id, runId)); + await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("not active"); + await db + .update(schema.heartbeatRuns) + .set({ status: "running" }) + .where(eq(schema.heartbeatRuns.id, runId)); + }); + it("rejects legacy signatures, missing instance claims and foreign-instance derived tokens", async () => { + const [header, payload] = bearer.split("."); + const input = `${header}.${payload}`; + const legacy = `${input}.${createHmac("sha256", "inspection-local-fixture").update(input).digest("base64url")}`; + expect(verifyLocalAgentJwt(legacy)).not.toBeNull(); + expect(verifyLocalAgentJwt(legacy, { strictRunAuthority: true })).toBeNull(); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "development-clone"); + const foreign = createLocalAgentJwt(agentId, companyId, "process", runId)!; + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "inspection-home"); + await expect(customerSuccessRunAuthority(db, foreign)).rejects.toThrow("managed-run"); + }); + it("every catalog reader is company scoped, paginated, and leaves the complete database unchanged", async () => { + const snapshot = async () => { + const tables = await db.execute( + sql`select tablename from pg_tables where schemaname = 'public' order by tablename`, + ); + const data: Record = {}; + for (const table of tables) { + const name = String(table.tablename).replaceAll('"', '""'); + data[name] = await db.execute( + sql.raw( + `SELECT coalesce(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text),'[]'::jsonb) AS rows FROM "${name}" t`, + ), + ); + } + return JSON.stringify(data); + }; + const before = await snapshot(); + for (const resource of INSPECTION_RESOURCES) { + const result = (await readCustomerSuccessResource(db, storage, { + operation: "list", + resource, + companyId, + limit: 1, + })) as { items: unknown[] }; + expect(result.items.length).toBeLessThanOrEqual(1); + } + expect(await snapshot()).toEqual(before); + await expect( + db.transaction( + (tx) => + tx.update(schema.agents).set({ name: "forbidden" }).where(eq(schema.agents.id, agentId)), + { accessMode: "read only" }, + ), + ).rejects.toThrow(); + expect(inspectionReaders.agentIdentities.omit).toContain("privateKeyMaterial"); + const identity = (await readCustomerSuccessResource(db, storage, { + operation: "get", + resource: "agentIdentities", + companyId, + resourceId: agentId, + })) as Record; + expect(identity).not.toHaveProperty("privateKeyMaterial"); + await expect( + readCustomerSuccessResource(db, storage, { + operation: "get", + resource: "agentIdentities", + companyId: otherCompanyId, + resourceId: agentId, + }), + ).rejects.toThrow("not found"); + }); + it("returns tasks/documents verbatim and uses existing adapter config redaction", async () => { + await db + .update(schema.agents) + .set({ + adapterConfig: { + env: { API_KEY: "hidden", NOTE: "same existing env policy" }, + promptTemplate: "Do useful work", + }, + }) + .where(eq(schema.agents.id, agentId)); + const agent = (await readCustomerSuccessResource(db, storage, { + operation: "get", + resource: "agents", + companyId, + resourceId: agentId, + })) as { adapterConfig: { env: Record; promptTemplate: string } }; + expect(JSON.stringify(agent)).not.toContain("hidden"); + expect(agent.adapterConfig.promptTemplate).toEqual("Do useful work"); + const [task] = await db + .insert(schema.issues) + .values({ + companyId, + title: "A pasted instruction", + description: "Keep prose unchanged: arbitrary pasted material", + }) + .returning(); + const taskResult = (await readCustomerSuccessResource(db, storage, { + operation: "get", + resource: "tasks", + companyId, + resourceId: task.id, + })) as { description: string }; + expect(taskResult.description).toEqual(task.description); + await expect( + readCustomerSuccessResource(db, storage, { + operation: "get", + resource: "tasks", + companyId: otherCompanyId, + resourceId: task.id, + }), + ).rejects.toThrow("not found"); + }); + it("reads existing instruction files without recovery, adoption, or filesystem writes", async () => { + const file = join(directory, "AGENTS.md"); + await writeFile(file, "# Investigate carefully\n"); + await db + .update(schema.agents) + .set({ adapterConfig: { instructionsFilePath: file } }) + .where(eq(schema.agents.id, agentId)); + const before = await stat(file); + const count = (await db.select().from(schema.agentInstructionRevisions)).length; + const result = (await readCustomerSuccessResource(db, storage, { + operation: "instructions.file", + companyId, + resourceId: agentId, + })) as { content: string }; + expect(Buffer.from(result.content, "base64").toString()).toEqual("# Investigate carefully\n"); + expect((await stat(file)).mtimeMs).toEqual(before.mtimeMs); + expect((await db.select().from(schema.agentInstructionRevisions)).length).toEqual(count); + await writeFile(join(directory, ".env"), "CREDENTIAL=do-not-return"); + await expect( + readCustomerSuccessResource(db, storage, { + operation: "instructions.file", + companyId, + resourceId: agentId, + path: ".env", + }), + ).rejects.toThrow("denied by policy"); + await symlink(file, join(directory, "linked.md")); + await expect( + readCustomerSuccessResource(db, storage, { + operation: "instructions.file", + companyId, + resourceId: agentId, + path: "linked.md", + }), + ).rejects.toThrow("symlink"); + }); + it("reuses environment redaction for project and routine configuration and revisions", async () => { + const env = { + CONFIG: { type: "plain" as const, value: "configuration-credential" }, + REFERENCE: { + type: "secret_ref" as const, + secretId: randomUUID(), + version: "latest" as const, + }, + }; + const [project] = await db + .insert(schema.projects) + .values({ companyId, name: "Configured project", env }) + .returning(); + const [routine] = await db + .insert(schema.routines) + .values({ companyId, title: "Configured routine", env }) + .returning(); + const [revision] = await db + .insert(schema.routineRevisions) + .values({ + companyId, + routineId: routine.id, + revisionNumber: 1, + title: routine.title, + snapshot: { + version: 1, + routine: { ...routine }, + triggers: [], + } as typeof schema.routineRevisions.$inferInsert.snapshot, + }) + .returning(); + const expected = redactAgentAdapterConfig({ env }).env; + for (const [resource, resourceId] of [ + ["projects", project.id], + ["routines", routine.id], + ["routineRevisions", revision.id], + ] as const) { + const row = (await readCustomerSuccessResource(db, storage, { + operation: "get", + resource, + companyId, + resourceId, + })) as { env?: unknown; snapshot?: { routine: { env: unknown } } }; + expect(row.snapshot?.routine.env ?? row.env).toEqual(expected); + expect(JSON.stringify(row)).not.toContain("configuration-credential"); + } + await expect( + readCustomerSuccessResource(db, storage, { + operation: "get", + resource: "users", + companyId, + resourceId: "missing", + }), + ).rejects.toThrow("not found"); + }); + it("asset bytes and bounded byte ranges are company scoped, without bypass URLs", async () => { + const [asset] = await db + .insert(schema.assets) + .values({ + companyId, + provider: "local_disk", + objectKey: "test/object", + contentType: "application/octet-stream", + byteSize: 5, + sha256: "fixture", + }) + .returning(); + const result = (await readCustomerSuccessResource(db, storage, { + operation: "assets.content", + companyId, + resourceId: asset.id, + range: { start: 1, end: 3 }, + })) as { content: string; bytes: number }; + expect(Buffer.from(result.content, "base64")).toEqual(Buffer.from([1, 2, 3])); + expect(result.bytes).toEqual(3); + await expect( + readCustomerSuccessResource(db, storage, { + operation: "assets.content", + companyId: otherCompanyId, + resourceId: asset.id, + }), + ).rejects.toThrow("not found"); + }); + it("tenant requests verify exact Cloud permit parameters, consume centrally, reject cookies and unknown paths", async () => { + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED", "true"); + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack"); + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN", "https://cloud.example.test"); + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_AUTHORITY_ENABLED", "true"); + const keys = generateKeyPairSync("ed25519"); + const jwks = { keys: [{ ...keys.publicKey.export({ format: "jwk" }), kid: "cloud" }] }; + vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_JWKS", JSON.stringify(jwks)); + const query: InspectionQuery = { + operation: "get", + resource: "companies", + companyId, + resourceId: companyId, + }; + const now = Math.floor(Date.now() / 1000); + const claims: InspectionPermit = { + v: 1, + iss: "paperclip-cloud", + aud: INSPECTION_AUDIENCE, + sub: "fixture-stack", + iat: now, + exp: now + 60, + jti: randomUUID(), + bindingVersion: 1, + grantId: randomUUID(), + requestId: randomUUID(), + agentId, + keyId: "sha256:fixture", + runId, + query, + }; + function token(value = claims, privateKey = keys.privateKey) { + const header = Buffer.from( + JSON.stringify({ alg: "EdDSA", typ: INSPECTION_PERMIT_TYPE, kid: "cloud" }), + ).toString("base64url"); + const body = Buffer.from(JSON.stringify(value)).toString("base64url"); + const input = `${header}.${body}`; + return `${input}.${sign(null, Buffer.from(input), privateKey).toString("base64url")}`; + } + const fetcher = vi.fn().mockResolvedValue(Response.json({ consumed: true })); + vi.stubGlobal("fetch", fetcher); + const app = express(); + app.use(express.json()); + app.use("/api/customer-success/v1", customerSuccessRoutes(db, storage)); + app.use(errorHandler); + const response = await request(app) + .post("/api/customer-success/v1/read") + .set(INSPECTION_HEADER, token()) + .send(query); + expect(response.status).toEqual(200); + expect(response.headers["cache-control"]).toEqual("no-store"); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher.mock.calls[0][0]).toEqual( + "https://cloud.example.test/v1/customer-success/permits/consume", + ); + expect( + ( + await request(app) + .post("/api/customer-success/v1/read") + .set(INSPECTION_HEADER, token()) + .send({ ...query, resource: "agents" }) + ).status, + ).toEqual(403); + expect( + ( + await request(app) + .post("/api/customer-success/v1/read") + .set(INSPECTION_HEADER, token()) + .set("Cookie", "session=ignored") + .send(query) + ).status, + ).toEqual(403); + expect( + ( + await request(app) + .get("/api/customer-success/v1/run-authority") + .set("Authorization", `Bearer ${bearer}`) + ).status, + ).toEqual(200); + expect((await request(app).post("/api/customer-success/v1/unknown").send({})).status).toEqual( + 404, + ); + fetcher.mockResolvedValue(new Response("", { status: 503 })); + expect( + ( + await request(app) + .post("/api/customer-success/v1/read") + .set(INSPECTION_HEADER, token()) + .send(query) + ).status, + ).toEqual(403); + expect(() => + verifyInspectionPermit(token({ ...claims, sub: "other-stack" }), jwks, "fixture-stack"), + ).toThrow(); + expect(() => + verifyInspectionPermit(token({ ...claims, exp: now }), jwks, "fixture-stack"), + ).toThrow(); + expect(() => + verifyInspectionPermit(token({ ...claims, exp: now + 61 }), jwks, "fixture-stack"), + ).toThrow(); + expect(() => + verifyInspectionPermit( + token(claims, generateKeyPairSync("ed25519").privateKey), + jwks, + "fixture-stack", + ), + ).toThrow(); + }); + it("reads assigned repository URLs, snapshotted skill files, and workspace downloads with existing file restrictions", async () => { + const root = await mkdtemp(join(directory, "workspace-")); + await writeFile(join(root, "result.bin"), Buffer.alloc(2 * 1024 * 1024, 7)); + await writeFile(join(root, "empty.txt"), ""); + await writeFile(join(root, ".env"), "TOKEN=fixture"); + await symlink(join(root, "result.bin"), join(root, "link.bin")); + const [project] = await db + .insert(schema.projects) + .values({ companyId, name: "Onboarding repo" }) + .returning(); + const [workspace] = await db + .insert(schema.projectWorkspaces) + .values({ + companyId, + projectId: project.id, + name: "Local checkout", + cwd: root, + repoUrl: "https://github.com/example/onboarding", + }) + .returning(); + const [issue] = await db + .insert(schema.issues) + .values({ companyId, projectId: project.id, title: "Workspace outputs" }) + .returning(); + const base = { + companyId, + resourceId: issue.id, + context: { projectId: project.id, workspaceId: workspace.id }, + }; + const repo = (await readCustomerSuccessResource(db, storage, { + operation: "get", + resource: "projectWorkspaces", + companyId, + resourceId: workspace.id, + })) as { repoUrl: string }; + expect(repo.repoUrl).toEqual("https://github.com/example/onboarding"); + const before = await stat(join(root, "result.bin")); + const result = (await readCustomerSuccessResource(db, storage, { + ...base, + operation: "files.download", + path: "result.bin", + range: { start: 2, end: 7 }, + })) as { content: string; bytes: number }; + expect(Buffer.from(result.content, "base64")).toEqual(Buffer.alloc(6, 7)); + expect(result.bytes).toEqual(6); + expect((await stat(join(root, "result.bin"))).mtimeMs).toEqual(before.mtimeMs); + for (const path of [".env", "../result.bin", "link.bin"]) + await expect( + readCustomerSuccessResource(db, storage, { ...base, operation: "files.download", path }), + ).rejects.toThrow(); + await expect( + readCustomerSuccessResource(db, storage, { + ...base, + operation: "files.download", + path: "result.bin", + }), + ).rejects.toThrow("bounded byte range"); + const listed = await readCustomerSuccessResource(db, storage, { + ...base, + operation: "files.list", + limit: 5, + }); + expect(JSON.stringify(listed)).toContain("result.bin"); + expect( + await readCustomerSuccessResource(db, storage, { + ...base, + operation: "files.download", + path: "empty.txt", + }), + ).toMatchObject({ available: true, bytes: 0, content: "" }); + const [unassigned] = await db + .insert(schema.issues) + .values({ companyId, title: "No workspace" }) + .returning(); + expect( + await readCustomerSuccessResource(db, storage, { + companyId, + resourceId: unassigned.id, + operation: "files.read", + path: "result.txt", + }), + ).toMatchObject({ available: false }); + const [skill] = await db + .insert(schema.companySkills) + .values({ + companyId, + key: "example", + slug: "example", + name: "Example", + markdown: "# Example", + fileInventory: [{ path: "SKILL.md", kind: "markdown", sizeBytes: 9 }], + }) + .returning(); + const [version] = await db + .insert(schema.companySkillVersions) + .values({ + companyId, + companySkillId: skill.id, + revisionNumber: 1, + fileInventory: [{ path: "SKILL.md", kind: "markdown", sizeBytes: 9, content: "# Example" }], + }) + .returning(); + await db + .update(schema.companySkills) + .set({ currentVersionId: version.id }) + .where(eq(schema.companySkills.id, skill.id)); + const snapshot = (await readCustomerSuccessResource(db, storage, { + operation: "skills.file", + companyId, + resourceId: skill.id, + })) as { content: string }; + expect(Buffer.from(snapshot.content, "base64").toString()).toEqual("# Example"); + await expect( + readCustomerSuccessResource(db, storage, { + operation: "skills.file", + companyId, + resourceId: skill.id, + path: ".env", + }), + ).rejects.toThrow("denied by policy"); + const versions = (await readCustomerSuccessResource(db, storage, { + operation: "list", + resource: "skillVersions", + companyId, + parentId: skill.id, + })) as { items: unknown[] }; + expect(versions.items).toHaveLength(1); + }); + it("the operation contract has no writes, credentials or unknown parameters", () => { + for (const value of [ + { operation: "write", companyId }, + { operation: "list", resource: "secrets", companyId }, + { operation: "get", resource: "agents", companyId, resourceId: agentId, key: "replacement" }, + { + operation: "assets.content", + companyId, + resourceId: agentId, + range: { start: 0, end: 1048576 }, + }, + ]) + expect(() => parseInspectionQuery(value)).toThrow(); + }); +}); diff --git a/server/src/__tests__/native-procedure-measurement.test.ts b/server/src/__tests__/native-procedure-measurement.test.ts index 09c0b2308b3..7c76b191aeb 100644 --- a/server/src/__tests__/native-procedure-measurement.test.ts +++ b/server/src/__tests__/native-procedure-measurement.test.ts @@ -144,6 +144,9 @@ afterAll(() => { "packages/paperclip-runner/src/backends/codex-native-backend.ts", "packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts", "server/src/services/native-runtime/paperclip-runner-tool-authority.ts", + "server/src/services/connection-tool-definitions.ts", + "packages/shared/src/connection-intent-guidance.ts", + "packages/shared/src/validators/connection-intent.ts", "server/src/services/native-runtime/native-session-resume.ts", "server/src/__tests__/native-procedure-measurement.test.ts", ]; diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 4cdf04cd51a..205745b56be 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -34,6 +34,7 @@ const apiPrefixes: Record = { "slack-tools.ts": "/api", "email.ts": "/api", "cloud.ts": "/api/cloud", + "customer-success.ts": "/api/customer-success/v1", "companies.ts": "/api/companies", "company-skills.ts": "/api", "company-skill-policy.ts": "/api", @@ -95,6 +96,11 @@ const HTTP_METHODS = new Set([ const explicitOpenApiCoverageExclusions = new Set(); const explicitOpenApiOperationCoverageExclusions = new Set([ + // Inspection uses its own versioned Cloud-permit/managed-run protocol, + // documented in CUSTOMER-SUCCESS-INSPECTION.md and the shared contract. + // Ordinary board sessions and agent API keys cannot invoke these endpoints. + "GET /api/customer-success/v1/run-authority", + "POST /api/customer-success/v1/read", // OAuth discovery and protocol endpoints have their own metadata contract; // browser connection-management operations remain documented in the board API. "GET /.well-known/oauth-authorization-server", diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 7094aab83de..5c447778ad6 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -123,6 +123,19 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => return { actor, company: company!, membership: membership!, client, tokens, exchange }; } + it("returns only the authorizing person’s profile with their connection list", async () => { + const f = await fixture(); + const other = await fixture(); + await db.update(authUsers).set({ name: "Dotta", image: "https://avatars.example/dotta.png" }).where(eq(authUsers.id, f.actor.userId!)); + const rows = await oauth.listConnections(f.actor.userId!); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ companyId: f.company.id, user: { name: "Dotta", image: "https://avatars.example/dotta.png" } }); + expect(JSON.stringify(rows)).not.toContain(other.actor.userId!); + expect(JSON.stringify(rows)).not.toContain("@example.com"); + await oauth.revokeConnection(rows[0]!.id, f.actor.userId!); + expect((await oauth.listConnections(f.actor.userId!))[0]?.revokedAt).not.toBeNull(); + }); + async function deviceFixture() { const f = await fixture(); const client = await oauth.register({ client_name: "Device client", redirect_uris: [], grant_types: [DEVICE_GRANT, "refresh_token"], response_types: [] }, randomUUID()); diff --git a/server/src/agent-auth-jwt.ts b/server/src/agent-auth-jwt.ts index a4f42b9d770..dd556115858 100644 --- a/server/src/agent-auth-jwt.ts +++ b/server/src/agent-auth-jwt.ts @@ -160,7 +160,7 @@ export function createLocalAgentJwt( return `${signingInput}.${signature}`; } -export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { +export function verifyLocalAgentJwt(token: string, options: { strictRunAuthority?: boolean } = {}): LocalAgentJwtClaims | null { if (!token) return null; const config = jwtConfig(); if (!config) return null; @@ -199,7 +199,7 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { const perCompanyKey = deriveCompanySigningKey(config.secret, claimedCompanyId, config.instanceId); const perCompanySig = signPayload(perCompanyKey, signingInput); let signatureOk = safeCompare(signature, perCompanySig); - if (!signatureOk && !config.disableLegacyFallback) { + if (!signatureOk && !config.disableLegacyFallback && !options.strictRunAuthority) { const legacySig = signPayload(config.secret, signingInput); signatureOk = safeCompare(signature, legacySig); } @@ -237,6 +237,15 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { // enforcement is conditional — matching how iss/aud are handled above. const instanceClaim = typeof claims.instance_id === "string" ? claims.instance_id : undefined; if (instanceClaim && instanceClaim !== config.instanceId) return null; + // Cross-instance inspection cannot inherit the compatibility exceptions of + // ordinary API authentication. Only a current, standard managed-run token + // issued by this instance can attest live authority. + if (options.strictRunAuthority && ( + header.typ !== "JWT" || issuer !== config.issuer || audience !== config.audience + || instanceClaim !== config.instanceId || exp <= now || iat > now + || !Number.isInteger(iat) || !Number.isInteger(exp) + || (Object.hasOwn(claims, "key_scope") && (!claims.key_scope || typeof claims.key_scope !== "object" || (claims.key_scope as { kind?: unknown }).kind !== "standard")) + )) return null; return { sub, diff --git a/server/src/app.ts b/server/src/app.ts index b213fdffeaa..22792612824 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,3 +1,4 @@ +import { customerSuccessRoutes } from "./routes/customer-success.js"; import { cloudWarmStandbyMiddleware } from "./middleware/cloud-warm-standby.js"; import type { CloudWarmStandby } from "./services/cloud-warm-standby.js"; import { browserUseRoutes } from "./routes/browser-use.js"; @@ -585,6 +586,7 @@ export async function createApp( // A signed claim above commits identity before any normal request can seed // company data. Unclaimed probes bypass session resolution as well as SQL. app.use(cloudWarmStandbyMiddleware(isWarmStandby, health, staticUi)); + app.use("/api/customer-success/v1", customerSuccessRoutes(db, opts.storageService)); app.use(publicMcpIngress); // Connection-intent tools carry their own short-lived, run-bound bearer and // must be reachable by remote adapters that intentionally do not receive an diff --git a/server/src/middleware/http-log-redaction.ts b/server/src/middleware/http-log-redaction.ts index 0e97df2bda5..21acd2a633f 100644 --- a/server/src/middleware/http-log-redaction.ts +++ b/server/src/middleware/http-log-redaction.ts @@ -16,6 +16,7 @@ export const HTTP_LOG_REDACT_PATHS = [ 'req.headers["x-paperclip-cloud-session-id"]', 'req.headers["x-paperclip-cloud-runtime-identity"]', 'req.headers["x-paperclip-cloud-control"]', + 'req.headers["x-paperclip-cloud-inspection"]', // Runtime GitHub capabilities authorize credential acquisition for a live run. 'req.headers["x-paperclip-github-capability"]', // Telegram's optional webhook verification header is a reusable bearer diff --git a/server/src/routes/customer-success.ts b/server/src/routes/customer-success.ts new file mode 100644 index 00000000000..a28a0d0eedb --- /dev/null +++ b/server/src/routes/customer-success.ts @@ -0,0 +1,136 @@ +import { createPublicKey, verify } from "node:crypto"; +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { + canonicalJson, + parseInspectionQuery, + INSPECTION_AUDIENCE, + INSPECTION_PERMIT_TYPE, + INSPECTION_HEADER, + type InspectionPermit, +} from "@paperclipai/shared"; +import type { StorageService } from "../storage/types.js"; +import { getCloudRuntimeIdentity } from "../services/cloud-runtime-identity.js"; +import { customerSuccessRunAuthority } from "../services/customer-success-authority.js"; +import { readCustomerSuccessResource } from "../services/customer-success-inspection.js"; +import { forbidden, unprocessable } from "../errors.js"; + +export function verifyInspectionPermit( + token: string, + keys: { keys: Array> }, + stackId: string, + now = Date.now(), +): InspectionPermit { + const parts = token.split("."); + if (parts.length !== 3 || token.length > 16384) throw forbidden("Invalid inspection permit"); + try { + const header = JSON.parse(Buffer.from(parts[0], "base64url").toString()); + const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as InspectionPermit; + const jwk = keys.keys.find( + (k) => k.kid === header.kid && k.kty === "OKP" && k.crv === "Ed25519" && !k.d, + ); + if ( + header.alg !== "EdDSA" || + header.typ !== INSPECTION_PERMIT_TYPE || + !jwk || + !verify( + null, + Buffer.from(`${parts[0]}.${parts[1]}`), + createPublicKey({ key: jwk, format: "jwk" }), + Buffer.from(parts[2], "base64url"), + ) + ) + throw new Error(); + const seconds = Math.floor(now / 1000); + if ( + claims.v !== 1 || + claims.iss !== "paperclip-cloud" || + claims.aud !== INSPECTION_AUDIENCE || + claims.sub !== stackId || + !Number.isInteger(claims.iat) || + !Number.isInteger(claims.exp) || + claims.exp <= seconds || + claims.iat > seconds + 5 || + claims.exp - claims.iat > 60 || + claims.exp <= claims.iat || + !claims.jti || + !claims.grantId || + !claims.requestId || + !claims.agentId || + !claims.keyId || + !claims.runId || + !Number.isSafeInteger(claims.bindingVersion) + ) + throw new Error(); + parseInspectionQuery(claims.query); + return claims; + } catch { + throw forbidden("Invalid inspection permit"); + } +} + +/** Mounted before actor middleware: none of these reads creates tenant state. */ +export function customerSuccessRoutes(db: Db, storage: StorageService) { + const router = Router(); + router.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); + }); + router.get("/run-authority", async (req, res) => { + if (process.env.PAPERCLIP_CUSTOMER_SUCCESS_AUTHORITY_ENABLED !== "true") + throw forbidden("Run authority is disabled"); + if (req.headers.cookie) throw forbidden("Browser credentials are not run authority"); + const match = req.headers.authorization?.match(/^Bearer ([A-Za-z0-9_.-]+)$/); + if (!match) throw forbidden("Managed run bearer required"); + res.json(await customerSuccessRunAuthority(db, match[1])); + }); + router.post("/read", async (req, res) => { + if (process.env.PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED !== "true") + throw forbidden("Inspection is disabled"); + if (req.headers.authorization || req.headers.cookie) + throw forbidden("Only a Cloud inspection permit is accepted"); + const stackId = getCloudRuntimeIdentity()?.stackId ?? process.env.PAPERCLIP_CLOUD_STACK_ID; + const rawKeys = process.env.PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_JWKS; + const origin = process.env.PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN; + const token = req.get(INSPECTION_HEADER); + if (!stackId || !rawKeys || !origin || !token) + throw forbidden("Inspection trust is unavailable"); + const url = new URL(origin); + if ( + (url.protocol !== "https:" && + !( + url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) + )) || + url.origin !== origin + ) + throw forbidden("Inspection trust is invalid"); + const permit = verifyInspectionPermit(token, JSON.parse(rawKeys), stackId); + let query; + try { + query = parseInspectionQuery(req.body); + } catch { + throw unprocessable("Invalid inspection query"); + } + if (canonicalJson(query) !== canonicalJson(permit.query)) + throw forbidden("Inspection parameters do not match permit"); + // Replay fencing and all inspection state live in Cloud. No tenant receipt. + const consumed = await fetch(`${origin}/v1/customer-success/permits/consume`, { + method: "POST", + headers: { "Content-Type": "application/json", [INSPECTION_HEADER]: token }, + body: "{}", + redirect: "error", + signal: AbortSignal.timeout(10000), + }); + if (!consumed.ok) throw forbidden("Inspection permit could not be consumed"); + const result = await readCustomerSuccessResource(db, storage, query); + const body = JSON.stringify(result); + if (Buffer.byteLength(body) > 2 * 1024 * 1024) + throw unprocessable("Inspection response is too large; use pagination or a byte range"); + res.type("application/json").send(body); + }); + // Unknown methods/paths terminate here rather than entering normal auth. + router.use((_req, res) => { + res.status(404).json({ error: "Unsupported inspection endpoint" }); + }); + return router; +} diff --git a/server/src/services/customer-success-authority.ts b/server/src/services/customer-success-authority.ts new file mode 100644 index 00000000000..2081902ae08 --- /dev/null +++ b/server/src/services/customer-success-authority.ts @@ -0,0 +1,56 @@ +import { and, eq } from "drizzle-orm"; +import { agents, heartbeatRuns, type Db } from "@paperclipai/db"; +import type { RunAuthority } from "@paperclipai/shared"; +import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; +import { agentRunWritesRevoked } from "../agent-run-cancellation.js"; +import { forbidden } from "../errors.js"; +import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js"; + +/** No actor/session resolution, key provisioning, or run identity capture. */ +export async function customerSuccessRunAuthority(db: Db, bearer: string): Promise { + const claims = verifyLocalAgentJwt(bearer, { strictRunAuthority: true }); + if (!claims) throw forbidden("A current managed-run credential is required"); + return db.transaction( + async (tx) => { + const [agent] = await tx + .select() + .from(agents) + .where(and(eq(agents.id, claims.sub), eq(agents.companyId, claims.company_id))); + const [run] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, claims.run_id), + eq(heartbeatRuns.agentId, claims.sub), + eq(heartbeatRuns.companyId, claims.company_id), + ), + ); + if ( + !agent || + !supportsManagedAgentIdentity(agent.adapterType, agent.adapterConfig, run?.driverKind) || + !["idle", "running", "active"].includes(agent.status) || + !run || + run.status !== "running" || + run.finishedAt || + agentRunWritesRevoked(run) + ) + throw forbidden("The managed run is not active"); + const identity = await agentIdentityService(tx as unknown as Db).getPublicIdentity( + agent.companyId, + agent.id, + ); + if (!identity) throw forbidden("The agent identity is unprovisioned"); + return { + version: 1, + instanceId: claims.instance_id!, + companyId: agent.companyId, + agentId: agent.id, + runId: run.id, + keyId: identity.keyId, + active: true, + }; + }, + { accessMode: "read only" }, + ); +} diff --git a/server/src/services/customer-success-inspection.ts b/server/src/services/customer-success-inspection.ts new file mode 100644 index 00000000000..2f9a031e82a --- /dev/null +++ b/server/src/services/customer-success-inspection.ts @@ -0,0 +1,422 @@ +import fs from "node:fs/promises"; +import { constants } from "node:fs"; +import { and, eq, getTableColumns, type SQL } from "drizzle-orm"; +import type { AnyPgTable, AnyPgColumn } from "drizzle-orm/pg-core"; +import * as schema from "@paperclipai/db"; +import type { Db } from "@paperclipai/db"; +import { + MAX_INSPECTION_BYTES, + type InspectionQuery, + type InspectionResource, +} from "@paperclipai/shared"; +import type { StorageService } from "../storage/types.js"; +import { HttpError, notFound, unprocessable } from "../errors.js"; +import { redactAgentAdapterConfig, redactEventPayload } from "../redaction.js"; +import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js"; +import { + assertWorkspaceFilePathAllowed, + workspaceFileResourceService, +} from "./workspace-file-resources.js"; +import { deriveBundleState } from "./agent-instructions.js"; +import { readInstructionBytes } from "./agent-instruction-files.js"; +import { getRunLogStore } from "./run-log-store.js"; + +// This is a reviewed catalog of readers, not a proxy for existing GET routes. +// Select credentials out of queries; never load encrypted identity records. +interface Reader { + table: AnyPgTable; + omit?: string[]; + parent?: string; + id?: string; +} +export const inspectionReaders: Record = { + companies: { table: schema.companies }, + goals: { table: schema.goals }, + users: { table: schema.authUsers }, + memberships: { table: schema.companyMemberships }, + permissions: { table: schema.principalPermissionGrants }, + agents: { table: schema.agents }, + agentIdentities: { table: schema.agentIdentityKeys, id: "agentId", omit: ["privateKeyMaterial"] }, + agentInstructions: { table: schema.agentInstructionRevisions, parent: "agentId" }, + agentConfigRevisions: { table: schema.agentConfigRevisions, parent: "agentId" }, + tasks: { table: schema.issues }, + comments: { table: schema.issueComments, parent: "issueId" }, + documents: { table: schema.documents }, + documentRevisions: { table: schema.documentRevisions, parent: "documentId" }, + taskDocuments: { table: schema.issueDocuments, parent: "issueId" }, + interactions: { table: schema.issueThreadInteractions, parent: "issueId" }, + approvals: { table: schema.approvals }, + approvalComments: { table: schema.approvalComments, parent: "approvalId" }, + decisions: { table: schema.decisions }, + routines: { table: schema.routines }, + routineTriggers: { + table: schema.routineTriggers, + parent: "routineId", + omit: ["webhookSecretHash", "webhookSecret"], + }, + routineRuns: { table: schema.routineRuns, parent: "routineId" }, + routineRevisions: { table: schema.routineRevisions, parent: "routineId" }, + routineDocuments: { table: schema.routineDocuments, parent: "routineId" }, + projects: { table: schema.projects }, + projectWorkspaces: { table: schema.projectWorkspaces, parent: "projectId" }, + projectMemberships: { table: schema.projectMemberships, parent: "projectId" }, + skills: { table: schema.companySkills, omit: ["publicShareToken"] }, + skillVersions: { table: schema.companySkillVersions, parent: "companySkillId" }, + skillSources: { table: schema.companySkillSources, omit: ["leaseToken"] }, + runs: { table: schema.heartbeatRuns, omit: ["logRef"] }, + runEvents: { table: schema.heartbeatRunEvents, parent: "runId" }, + traceMetadata: { table: schema.providerTraceRecords, parent: "runId", omit: ["traceRef"] }, + activity: { table: schema.activityLog }, + costs: { table: schema.costEvents }, + workProducts: { table: schema.issueWorkProducts, parent: "issueId" }, + artifacts: { table: schema.assets, omit: ["objectKey"] }, + attachments: { table: schema.issueAttachments, parent: "issueId" }, + connections: { + table: schema.toolConnections, + omit: ["externalCredential", "credentialRefs", "credentialSecretRefs"], + }, + connectionInstalls: { table: schema.toolConnectionInstalls, parent: "connectionId" }, + connectionGrants: { + table: schema.connectionGrants, + parent: "connectionId", + omit: ["credentialSecretRefs", "externalCredential"], + }, + connectionGrantMembers: { table: schema.connectionGrantMembers, parent: "grantId" }, + connectionCatalog: { table: schema.toolCatalogEntries, parent: "connectionId" }, + toolProfiles: { table: schema.toolProfiles }, + toolProfileEntries: { table: schema.toolProfileEntries, parent: "profileId" }, + agentMemberships: { table: schema.agentMemberships, parent: "agentId" }, + agentCommentary: { table: schema.agentCommentary, parent: "runId" }, + skillPolicies: { table: schema.companySkillPolicies, id: "companyId" }, + projectGoals: { table: schema.projectGoals, parent: "projectId" }, + labels: { table: schema.labels }, + taskLabels: { table: schema.issueLabels, parent: "issueId" }, + taskRelations: { table: schema.issueRelations }, + taskApprovals: { table: schema.issueApprovals, parent: "issueId" }, + documentMemberships: { table: schema.documentMemberships, parent: "documentId" }, + documentThreads: { table: schema.documentAnnotationThreads, parent: "documentId" }, + documentComments: { table: schema.documentAnnotationComments, parent: "threadId" }, + threads: { table: schema.chatConversations, parent: "endpointId" }, +}; + +async function ownRow(tx: Db, table: AnyPgTable, companyId: string, id: string) { + const columns = getTableColumns(table) as Record; + const [row] = await tx + .select() + .from(table) + .where(and(eq(columns.companyId, companyId), eq(columns.id, id))) + .limit(1); + if (!row) throw notFound("Inspection resource not found"); + return row as Record; +} +function unavailable(reason: string) { + return { available: false, reason }; +} +function binary( + bytes: Buffer, + totalBytes: number, + start = 0, + contentType = "application/octet-stream", +) { + return { + available: true, + encoding: "base64", + contentType, + totalBytes, + start, + end: start + bytes.length - 1, + bytes: bytes.length, + content: bytes.toString("base64"), + }; +} + +/** All SQL, including nested serializers, runs in one read-only transaction. */ +export async function readCustomerSuccessResource( + db: Db, + storage: StorageService, + q: InspectionQuery, +): Promise { + return db.transaction( + async (rawTx) => { + const tx = rawTx as unknown as Db; + if (q.companyId) { + const [company] = await tx + .select({ id: schema.companies.id }) + .from(schema.companies) + .where(eq(schema.companies.id, q.companyId)); + if (!company) throw notFound("Inspection company not found"); + } + if (q.operation === "list" || q.operation === "get") { + const reader = inspectionReaders[q.resource!]; + const all = getTableColumns(reader.table) as Record; + const columns = Object.fromEntries( + Object.entries(all).filter(([key]) => !reader.omit?.includes(key)), + ); + const predicates: SQL[] = []; + if (q.resource === "companies") { + if (q.companyId) predicates.push(eq(all.id, q.companyId)); + } else if (q.resource === "users") { + // Directory only, never the authentication account/session tables. + const memberships = await tx + .select({ userId: schema.companyMemberships.principalId }) + .from(schema.companyMemberships) + .where( + and( + eq(schema.companyMemberships.companyId, q.companyId!), + eq(schema.companyMemberships.principalType, "user"), + ), + ); + const { inArray } = await import("drizzle-orm"); + if (!memberships.length) { + if (q.operation === "get") throw notFound("Inspection resource not found"); + return { items: [], nextOffset: null }; + } + predicates.push( + inArray( + all.id, + memberships.map((m) => m.userId), + ), + ); + } else { + if (!all.companyId) throw unprocessable("Reader has no company boundary"); + predicates.push(eq(all.companyId, q.companyId!)); + } + if (q.operation === "get" && !all[reader.id ?? "id"]) + throw unprocessable("This resource supports listing only"); + if (q.operation === "get") predicates.push(eq(all[reader.id ?? "id"], q.resourceId!)); + if (q.parentId) { + if (!reader.parent || !all[reader.parent]) + throw unprocessable("This reader does not accept parentId"); + predicates.push(eq(all[reader.parent], q.parentId)); + } + const limit = q.operation === "get" ? 1 : Math.max(1, q.limit ?? 25); + const order = all[reader.id ?? "id"] ?? all.createdAt ?? all.companyId; + let rows = await tx + .select(columns) + .from(reader.table) + .where(and(...predicates)) + .orderBy(order) + .limit(limit + (q.operation === "get" ? 0 : 1)) + .offset(q.offset ?? 0); + if (q.resource === "agents") + rows = rows.map((r) => ({ + ...r, + adapterConfig: redactAgentAdapterConfig(r.adapterConfig as Record), + runtimeConfig: redactEventPayload(r.runtimeConfig as Record), + })); + if (q.resource === "agentConfigRevisions") + rows = rows.map((r) => ({ + ...r, + beforeConfig: redactRevisionSnapshot(r.beforeConfig), + afterConfig: redactRevisionSnapshot(r.afterConfig), + })); + if (q.resource === "projects" || q.resource === "routines") + rows = rows.map((r) => ({ ...r, env: redactAgentAdapterConfig({ env: r.env }).env })); + if (q.resource === "routineRevisions") + rows = rows.map((r) => { + const snapshot = r.snapshot as Record | null; + const routine = snapshot?.routine as Record | undefined; + return routine + ? { + ...r, + snapshot: { + ...snapshot, + routine: { + ...routine, + env: redactAgentAdapterConfig({ env: routine.env }).env, + }, + }, + } + : r; + }); + if (q.resource === "runs") + rows = await createRunSecretRedactionRegistry(tx).redactForRuns( + q.companyId!, + rows as Array<{ id: string }>, + ); + // Match the existing structured event/config redaction, without scanning + // prose, documents, instructions, skill contents, or workspace files. + if ( + [ + "runEvents", + "activity", + "connections", + "connectionGrants", + "routineTriggers", + "routineRevisions", + ].includes(q.resource!) + ) + rows = rows.map((r) => redactEventPayload(r) ?? {}); + if (q.resource === "runEvents") { + const redactions = createRunSecretRedactionRegistry(tx); + rows = await Promise.all( + rows.map((r) => redactions.redactForRun(q.companyId!, String(r.runId), r)), + ); + } + if (q.operation === "get") { + if (!rows.length) throw notFound("Inspection resource not found"); + return rows[0]; + } + return { + items: rows.slice(0, limit), + nextOffset: rows.length > limit ? (q.offset ?? 0) + limit : null, + }; + } + if (q.operation === "instructions.file") { + const agent = await ownRow(tx, schema.agents, q.companyId!, q.resourceId!); + const state = deriveBundleState( + agent as unknown as Parameters[0], + ); + if (!state.rootPath) return unavailable("instructions_not_configured"); + const relative = q.path ?? state.entryFile; + assertWorkspaceFilePathAllowed(relative); + const bytes = await readInstructionBytes(state.rootPath, relative); + return bytes + ? binary(bytes, bytes.length, 0, "text/plain; charset=utf-8") + : unavailable("instructions_file_missing"); + } + if (q.operation === "skills.file") { + const skill = await ownRow(tx, schema.companySkills, q.companyId!, q.resourceId!); + const relative = q.path ?? "SKILL.md"; + assertWorkspaceFilePathAllowed(relative); + const versionId = q.versionId ?? skill.currentVersionId; + // Installed version snapshots are authoritative. Reading must not invoke + // inventory reconciliation, checkout, fetching, adoption or materialization. + if (versionId) { + const version = await ownRow( + tx, + schema.companySkillVersions, + q.companyId!, + String(versionId), + ); + if (version.companySkillId !== skill.id) throw notFound("Skill version not found"); + const files = version.fileInventory as Array<{ + path: string; + content?: string; + encoding?: string; + }>; + const file = files.find((f) => f.path === relative); + if (file?.content !== undefined) { + const bytes = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8"); + if (bytes.length > MAX_INSPECTION_BYTES) + throw unprocessable("Skill snapshot exceeds inspection limit"); + return binary(bytes, bytes.length); + } + } + if (relative === "SKILL.md") + return binary( + Buffer.from(String(skill.markdown), "utf8"), + Buffer.byteLength(String(skill.markdown)), + ); + return unavailable("skill_file_not_snapshotted"); + } + if (q.operation === "runs.log") { + const run = await ownRow(tx, schema.heartbeatRuns, q.companyId!, q.resourceId!); + if (!run.logRef || run.logStore !== "local_file") return unavailable("run_log_unavailable"); + const result = await getRunLogStore().read( + { store: "local_file", logRef: String(run.logRef) }, + { + offset: q.range?.start ?? q.offset ?? 0, + limitBytes: q.range ? q.range.end - q.range.start + 1 : 256000, + }, + ); + return createRunSecretRedactionRegistry(tx).redactForRun( + q.companyId!, + q.resourceId!, + result, + ); + } + if (q.operation.startsWith("files.")) { + try { + const issue = await ownRow(tx, schema.issues, q.companyId!, q.resourceId!); + const files = workspaceFileResourceService(tx); + const input = { path: q.path ?? "", ...q.context, limit: q.limit, offset: q.offset }; + const opts = { + issue: issue as unknown as NonNullable[2]>["issue"], + }; + if (q.operation === "files.list") return await files.list(q.resourceId!, input, opts); + if (q.operation === "files.read") + return await files.readContent(q.resourceId!, input, opts); + const resolved = await files.prepareDownload(q.resourceId!, input, opts); + const file = await fs.open(resolved.realPath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stat = await file.stat(); + if (!stat.isFile()) throw unprocessable("Regular file required"); + if (stat.size === 0 && !q.range) return binary(Buffer.alloc(0), 0); + const start = q.range?.start ?? 0; + const end = q.range?.end ?? stat.size - 1; + if (start >= stat.size || end >= stat.size || end - start + 1 > MAX_INSPECTION_BYTES) + throw unprocessable("Request a bounded byte range"); + const bytes = Buffer.alloc(end - start + 1); + const result = await file.read(bytes, 0, bytes.length, start); + const after = await file.stat(); + if ( + after.size !== stat.size || + after.mtimeMs !== stat.mtimeMs || + after.ino !== stat.ino + ) + return unavailable("file_changed_during_read"); + if (result.bytesRead !== bytes.length) return unavailable("file_changed_during_read"); + return binary(bytes, stat.size, start); + } finally { + await file.close(); + } + } catch (error) { + const code = + error instanceof HttpError + ? (error.details as { code?: string } | undefined)?.code + : undefined; + if (code && ["remote_workspace", "no_workspace", "no_local_workspace"].includes(code)) + return unavailable(code); + throw error; + } + } + if (q.operation === "assets.content") { + const asset = await ownRow(tx, schema.assets, q.companyId!, q.resourceId!); + const range = q.range; + if (range && (range.start >= Number(asset.byteSize) || range.end >= Number(asset.byteSize))) + throw unprocessable("Asset byte range is outside the resource"); + if (!range && Number(asset.byteSize) > MAX_INSPECTION_BYTES) + throw unprocessable("Request a bounded byte range"); + const object = await storage.getObject(q.companyId!, String(asset.objectKey), { range }); + const chunks: Buffer[] = []; + let length = 0; + try { + for await (const chunk of object.stream) { + const bytes = Buffer.from(chunk); + length += bytes.length; + if (length > MAX_INSPECTION_BYTES) + throw unprocessable("Asset exceeds inspection limit"); + chunks.push(bytes); + } + } finally { + object.stream.destroy(); + } + return binary( + Buffer.concat(chunks), + Number(asset.byteSize), + q.range?.start ?? 0, + object.contentType, + ); + } + throw unprocessable("Unsupported inspection operation"); + }, + { accessMode: "read only", isolationLevel: "repeatable read" }, + ); +} + +function redactRevisionSnapshot(snapshot: unknown): Record { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {}; + const record = snapshot as Record; + const object = (value: unknown) => + value && typeof value === "object" ? (value as Record) : {}; + return { + ...record, + adapterConfig: redactAgentAdapterConfig(object(record.adapterConfig)), + runtimeConfig: redactEventPayload(object(record.runtimeConfig)), + metadata: + record.metadata && typeof record.metadata === "object" + ? redactEventPayload(object(record.metadata)) + : (record.metadata ?? null), + }; +} diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index 72b9aba44c3..079bf295577 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -4,7 +4,7 @@ import { and, eq, gt, inArray, isNull, lt, lte, notExists, sql } from "drizzle-o import { z } from "zod"; import type { Request } from "express"; import { - type Db, activityLog, agents, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions, + type Db, activityLog, agents, authUsers, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions, } from "@paperclipai/db"; import { DOT_RUNNER_MCP_PATH, DOT_RUNNER_MCP_SCOPES, PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, type McpConnectionRequest } from "@paperclipai/shared"; import { boardAuthService } from "../board-auth.js"; @@ -493,12 +493,14 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: { await fenceAgentGrant(grant); }, async listConnections(userId: string) { - const rows = await db.select({ grant: mcpOauthGrants, clientName: mcpOauthClients.name, companyName: companies.name }) + const rows = await db.select({ grant: mcpOauthGrants, clientName: mcpOauthClients.name, companyName: companies.name, userName: authUsers.name, userImage: authUsers.image }) .from(mcpOauthGrants).innerJoin(mcpOauthClients, eq(mcpOauthGrants.clientId, mcpOauthClients.id)) .innerJoin(companies, eq(companies.id, mcpOauthGrants.companyId)) + .leftJoin(authUsers, eq(authUsers.id, mcpOauthGrants.userId)) .where(eq(mcpOauthGrants.userId, userId)); - return rows.map(({ grant, clientName, companyName }) => ({ - id: grant.id, companyId: grant.companyId, clientName, companyName, scopes: grant.scopes, + return rows.map(({ grant, clientName, companyName, userName, userImage }) => ({ + id: grant.id, companyId: grant.companyId, clientName, companyName, + user: userName === null ? null : { name: userName, image: userImage }, scopes: grant.scopes, createdAt: grant.createdAt.toISOString(), revokedAt: grant.revokedAt?.toISOString() ?? null, })); }, diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index 5f11afa72d6..90b8534ce63 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -333,6 +333,11 @@ function throwIfDenied(segments: string[]) { } } +/** Apply the existing file-path policy to other read-only file surfaces. */ +export function assertWorkspaceFilePathAllowed(relativePath: string): void { + throwIfDenied(normalizeWorkspaceRelativePath(relativePath).segments); +} + function shouldPruneSegments(segments: string[]) { return denyReasonForPathSegments(segments) != null; } diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index be0e51d38e1..9a50f99b2d5 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -22,6 +22,46 @@ scheduled execution gets a fresh Paperclip home, embedded Postgres database, instance configuration, port, workspace, company, encrypted secrets, environment, and agent. +## Native connection guidance (explicit only) + +The manual-only native-connection-guidance suite separates connection-policy +discovery from fixture instructions. It reuses five Everyday journeys on local +native Codex, ACPX Claude and OpenCode: service approval/decline, new Notion +setup decline, external-provider decline and choosing the second provider. +Fifteen cells are configured, not live-qualified by their existence. Select an +exact execution ID or this suite; --all and generic profile selectors exclude it. + +The three decline prompts define a brief explanation as the permitted fallback. +They do not mention the future decline, name connection tools, prescribe a +provider, or tell the model not to retry. The approval and second-provider +prompts remain identical to the original stories. The historical +everyday-workflows cases and their original grades remain unchanged. + +Each cell allows one attempt, expects two provider turns, retains a twelve-run +maximum and twelve-minute deadline, and verifies 1,000-cent company and agent +hard stops through public records before task creation. All actual runs, +usage/cost gaps, controller retries and cleanup must remain in the report. +No real third-party mutation occurs. The external-provider decline includes +the same deterministic installed Arcade gateway as the positive control, so its +zero-call assertion has an actual counter rather than a missing-fixture default. + +The new decline oracle requires the saved decision, exactly one interaction, +unchanged connection identities, and an explanation after the decision from a +successful run of the same agent on the same native task. The public comment createdByRunId field +owns attribution; names, ordering or counts cannot substitute. Service/provider +declines require observed zero fixture calls. Notion setup ends before +credentials or a service invocation; it does not qualify real Notion access. +The original lifecycle, native identity, approval and document checks still run. +These fallback tasks expect Done; they do not qualify blocking when essential +work remains, arbitrary setup success, independent work while waiting, +explicit retry after decline, or general integration quality. + +Use the existing report publisher and retained artifact boundary. The suite +definition digest includes its prompts, flow, graders, fixture setup and browser +submission code. Compare frozen sources under identical fixture/model/budget +controls before using it to qualify a production instruction change. See +[the connection audit](../../doc/plans/2026-10-06-native-connection-guidance.md). + ## Native procedure guidance comparison (explicit only) Select `--suite everyday-workflows --environment local --case hire-reuse --case delegate-feedback diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 86245688000..6b386d6e182 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -146,10 +146,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 63, 12, 6, 8, 2, 2, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, + 63, 12, 6, 8, 2, 2, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, ]); - expect(validateRunnerCatalog()).toHaveLength(683); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(683); + expect(validateRunnerCatalog()).toHaveLength(698); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(698); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 8cc47e9e4d4..d9ddf5300e2 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -19,6 +19,7 @@ import { lifecycleLiveTasks, lifecycleLiveDefinitionDigest } from "./lifecycle-l import { publicMcpTasks, publicMcpExpandedDigest, publicMcpSetupDigest, publicMcpWorkflowDigest, publicMcpWorkerInstructions, publicMcpWorkerSkillDigest } from "./public-mcp-cases.js"; import { graderVersion as publicMcpGraderVersion } from "./public-mcp-grading.js"; import { everydayTasks, productionStoryProfile } from "./everyday-cases.js"; +import { CONNECTION_GUIDANCE_SUITE, CONNECTION_GUIDANCE_BUDGET_CENTS, connectionGuidanceTasks, connectionGuidanceDefinitionDigest } from "./connection-guidance-cases.js"; import { firstTaskTasks } from "./first-task-cases.js"; import { chatTasks, chatHardeningTasks, chatStoryTasks, chatQualificationTasks, chatCompletionTasks } from "./chat-cases.js"; @@ -1273,6 +1274,18 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ ], definitionMetadata: { version: 4, grading: "durable-state-and-approval-boundaries", instructions: "production" }, }, + { + id: CONNECTION_GUIDANCE_SUITE, label: "Native connection guidance", manualOnly: true, + description: "Neutral decline prompts and approval/provider-choice controls; production connection instructions stay unchanged.", + groups: ["native", "local"], + profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude", "runner-opencode"].includes(profile.id)).map(productionStoryProfile), + environments: [localEnvironment], tasks: connectionGuidanceTasks, expectedMatrixSize: 15, + definitionMetadata: { version: 1, fixtureDigest: connectionGuidanceDefinitionDigest(), + instructions: "unchanged-production", maximumAttemptsPerCell: 1, + automaticRetryPolicy: "single_attempt", companyAndAgentBudgetCents: CONNECTION_GUIDANCE_BUDGET_CENTS, + expectedProviderTurnsPerCell: 2, maximumRunRecordsPerCell: 12, + grading: "original-workflow-plus-attributed-decline-output", scheduling: "explicit-only" }, + }, { id: "everyday-workflows", label: "Everyday Paperclip Work", manualOnly: true, description: "Real user requests, useful downloaded work, and durable continuation using production instructions.", diff --git a/tests/runner-e2e/connection-guidance-cases.ts b/tests/runner-e2e/connection-guidance-cases.ts new file mode 100644 index 00000000000..de28364a635 --- /dev/null +++ b/tests/runner-e2e/connection-guidance-cases.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { everydayTasks } from "./everyday-cases.js"; + +export const CONNECTION_GUIDANCE_SUITE = "native-connection-guidance"; +export const CONNECTION_GUIDANCE_BUDGET_CENTS = 1_000; + +// The fallback defines the user's deliverable, not the connection procedure. +// No tool names, no-retry rule, provider preference, or completion API hints. +export const connectionGuidanceDeclinePrompts: Readonly> = { + "service-decline": "Use the connected page service to find recent pages and create a short Markdown briefing on this task. Include the titles and verification code returned by the service. If the data is unavailable, a brief explanation is enough instead of the briefing.", + "connection-decline": "Connect Notion so you can read my recent pages and write a short briefing. If the pages are unavailable, a brief explanation is enough instead of the briefing.", + "provider-decline": "Connect HubSpot so you can read my recent contacts. If the contacts are unavailable, a brief explanation is enough instead of the contact list.", +}; +const cases = ["service-approve", "service-decline", "connection-decline", "provider-decline", "provider-second"]; + +export const connectionGuidanceTasks = cases.map(id => { + const original = everydayTasks.find(task => task.id === id); + if (!original) throw new Error("Missing original connection story: " + id); + return { + ...original, + automaticRetryPolicy: "single_attempt" as const, + buildPrompt: (nonce: string) => connectionGuidanceDeclinePrompts[id] ?? original.buildPrompt(nonce), + }; +}); + +export function connectionGuidanceDefinitionDigest() { + const files = [ + "connection-guidance-cases.ts", "connection-guidance-evidence.ts", + "everyday-cases.ts", "everyday-flow.ts", "everyday-decisions.ts", + "everyday-observations.ts", "everyday-delivery.ts", + "connection-routing-evidence.ts", "connection-reviews.ts", "aggregator-fixture.ts", + "live-fixtures.ts", "runner.spec.ts", "user-actions.ts", "catalog.ts", + ]; + return createHash("sha256").update(files.map(file => + file + "\0" + readFileSync(new URL(file, import.meta.url), "utf8"), + ).join("\0")).digest("hex"); +} diff --git a/tests/runner-e2e/connection-guidance-evidence.ts b/tests/runner-e2e/connection-guidance-evidence.ts new file mode 100644 index 00000000000..3bd699e3f26 --- /dev/null +++ b/tests/runner-e2e/connection-guidance-evidence.ts @@ -0,0 +1,54 @@ +import type { StoryComment } from "./everyday-observations.js"; + +type Decision = { + id: string; kind: string; status: string; resolvedAt?: string | null; + result?: { answers?: Array<{ questionId: string; optionIds?: string[] }> }; +}; +type Reply = Pick; +type Run = { id: string; nativeIssueId?: string | null; agentId?: string | null; status: string; finishedAt?: string | null }; + +// Additional oracle for the new neutral-prompt suite only. Historical Everyday +// grades remain intact. This proves attributed saved output, not cognition. +export function gradeConnectionGuidanceDecline(input: { + caseId: "service-decline" | "connection-decline" | "provider-decline"; + decisionId: string; + decisions: Decision[]; + leadAgentId: string; + issueId: string; + replies: Reply[]; + runs: Run[]; + calls?: number; + marker: string; + sameConnections: boolean; +}) { + const decision = input.decisions.find(row => row.id === input.decisionId); + const resolvedAt = Date.parse(decision?.resolvedAt ?? ""); + const provider = input.caseId === "provider-decline"; + const expectedKind = provider ? "ask_user_questions" + : input.caseId === "connection-decline" ? "connection_intent" : "request_confirmation"; + const options = decision?.result?.answers?.find(answer => + answer.questionId === "connection-provider:hubspot")?.optionIds; + const validDecision = Number.isFinite(resolvedAt) && + decision?.kind === expectedKind && + (provider ? decision.status === "answered" && options?.length === 1 && options[0] === "none" + : decision?.status === "rejected"); + const afterDecision = input.replies.filter(reply => + validDecision && reply.authorAgentId === input.leadAgentId && + Number.isFinite(Date.parse(reply.createdAt ?? "")) && + Date.parse(reply.createdAt!) >= resolvedAt); + const attributed = afterDecision.filter(reply => input.runs.some(run => + Boolean(reply.createdByRunId) && run.id === reply.createdByRunId && run.agentId === input.leadAgentId && run.nativeIssueId === input.issueId && + run.status === "succeeded" && Date.parse(run.finishedAt ?? "") >= resolvedAt)); + const explainsUnavailable = (text: string) => + /declin|not now|could(?:n.t| not)|cannot|can.t|unable|unavailable|not (?:connect|retriev)|without (?:access|connect)/i.test(text); + return [ + { id: "guidance-decline-decision", passed: validDecision && input.decisions.length === 1, + detail: "Exactly one correctly typed, resolved decline belongs to the selected decision." }, + { id: "guidance-decline-attributed-explanation", + passed: attributed.some(reply => typeof reply.body === "string" && explainsUnavailable(reply.body)), + detail: "A saved explanation follows the decision and joins by run ID to the lead's successful execution on this task." }, + { id: "guidance-decline-no-use", passed: (input.caseId === "connection-decline" || input.calls === 0) && input.sameConnections && + afterDecision.every(reply => (typeof reply.body !== "string" || !reply.body.includes(input.marker))), + detail: "No connection changes or unread marker in replies. Installed-service/provider declines also require an observed zero fixture-call count; Notion setup does not execute a service." }, + ]; +} diff --git a/tests/runner-e2e/connection-guidance.test.ts b/tests/runner-e2e/connection-guidance.test.ts new file mode 100644 index 00000000000..2ea504c7462 --- /dev/null +++ b/tests/runner-e2e/connection-guidance.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { runnerMatrix, runnerSuites } from "./catalog.js"; +import { everydayTasks } from "./everyday-cases.js"; +import { CONNECTION_GUIDANCE_SUITE, connectionGuidanceTasks, connectionGuidanceDefinitionDigest } from "./connection-guidance-cases.js"; +import { gradeConnectionGuidanceDecline } from "./connection-guidance-evidence.js"; +import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js"; + +describe("neutral connection guidance selection", () => { + it("requires explicit selection and admits five stories on exactly three local native profiles", () => { + const selected = selectRunnerExecutions(parseRunnerSelectors(["--suite", CONNECTION_GUIDANCE_SUITE])); + expect(selected).toHaveLength(15); + expect(new Set(selected.map(row => row.profile.id))).toEqual(new Set(["runner-codex", "runner-acpx-claude", "runner-opencode"])); + for (const row of selected) { + expect(row.environment.id).toBe("local"); + expect(row.task.automaticRetryPolicy).toBe("single_attempt"); + expect(row.task.attemptTimeoutMs.local).toBe(720_000); + expect(row.task.expectedRunCount).toBe(2); + } + for (const args of [["--all"], ["--profile", "runner-opencode"]]) { + expect(selectRunnerExecutions(parseRunnerSelectors(args), runnerMatrix).some(row => row.suite.id === CONNECTION_GUIDANCE_SUITE)).toBe(false); + } + expect(runnerSuites.find(suite => suite.id === CONNECTION_GUIDANCE_SUITE)?.definitionMetadata) + .toMatchObject({ fixtureDigest: connectionGuidanceDefinitionDigest(), companyAndAgentBudgetCents: 1_000, maximumAttemptsPerCell: 1 }); + }); + it("keeps workflow instructions out of new decline prompts and preserves the historical prompts", () => { + for (const task of connectionGuidanceTasks) { + const original = everydayTasks.find(row => row.id === task.id)!; + if (task.id.endsWith("decline")) { + expect(task.buildPrompt("nonce")).toMatch(/brief explanation is enough/); + expect(task.buildPrompt("nonce")).not.toMatch(/declin|Not now|None for now|do not|retry|try again|connection_request|connections_search|paperclip_|yield|poll/i); + expect(task.buildPrompt("nonce")).not.toBe(original.buildPrompt("nonce")); + } else expect(task.buildPrompt("nonce")).toBe(original.buildPrompt("nonce")); + } + expect(everydayTasks.find(task => task.id === "service-decline")!.buildPrompt("nonce")).toContain("do not try again"); + expect(everydayTasks.find(task => task.id === "connection-decline")!.buildPrompt("nonce")).toContain("do not try again"); + }); +}); + +const valid = { + caseId: "provider-decline" as const, decisionId: "decision", leadAgentId: "lead", issueId: "task", + decisions: [{ id: "decision", kind: "ask_user_questions", status: "answered", resolvedAt: "2026-10-06T22:00:00Z", + result: { answers: [{ questionId: "connection-provider:hubspot", optionIds: ["none"] }] } }], + replies: [{ authorAgentId: "lead", createdByRunId: "run", createdAt: "2026-10-06T22:00:02Z", body: "Contacts are unavailable after your decision." }], + runs: [{ id: "run", nativeIssueId: "task", agentId: "lead", status: "succeeded", finishedAt: "2026-10-06T22:00:03Z" }], + calls: 0, marker: "PRIVATE_MARKER", sameConnections: true, +}; +const passes = (input: Parameters[0]) => + gradeConnectionGuidanceDecline(input).every(check => check.passed); + +describe("neutral decline evidence", () => { + it("accepts an attributed explanation after each kind of saved decline", () => { + expect(passes(valid)).toBe(true); + for (const [caseId, kind] of [["service-decline", "request_confirmation"], ["connection-decline", "connection_intent"]] as const) { + expect(passes({ ...valid, caseId, decisions: [{ ...valid.decisions[0], kind, status: "rejected" }] })).toBe(true); + } + }); + it("rejects missing, stale, unattributed, wrong-agent, or unsuccessful explanations", () => { + for (const input of [ + { ...valid, replies: [] }, + { ...valid, replies: [{ ...valid.replies[0], body: "Done." }] }, + { ...valid, replies: [{ ...valid.replies[0], body: { text: "Unavailable" } }] }, + { ...valid, replies: [{ ...valid.replies[0], createdAt: "2026-10-06T21:59:00Z" }] }, + { ...valid, replies: [{ ...valid.replies[0], createdByRunId: undefined }] }, + { ...valid, replies: [{ ...valid.replies[0], createdByRunId: null, runId: "run" }] }, + { ...valid, replies: [{ ...valid.replies[0], createdByRunId: "other-run" }] }, + { ...valid, replies: [{ ...valid.replies[0], authorAgentId: "worker" }] }, + { ...valid, replies: [{ ...valid.replies[0], createdAt: "invalid" }] }, + { ...valid, runs: [{ ...valid.runs[0], agentId: "worker" }] }, + { ...valid, runs: [{ ...valid.runs[0], nativeIssueId: "other-task" }] }, + { ...valid, runs: [{ ...valid.runs[0], nativeIssueId: undefined }] }, + { ...valid, runs: [{ ...valid.runs[0], status: "failed" }] }, + { ...valid, runs: [{ ...valid.runs[0], finishedAt: "2026-10-06T21:59:00Z" }] }, + ]) expect(passes(input)).toBe(false); + }); + it("rejects wrong or repeated decisions, early use, missing call evidence and connection changes", () => { + for (const input of [ + { ...valid, decisionId: "other" }, + { ...valid, decisions: [] }, + { ...valid, decisions: [...valid.decisions, ...valid.decisions] }, + { ...valid, decisions: [{ ...valid.decisions[0], resolvedAt: undefined }] }, + { ...valid, decisions: [{ ...valid.decisions[0], kind: "request_confirmation" }] }, + { ...valid, decisions: [{ ...valid.decisions[0], status: "pending" }] }, + { ...valid, decisions: [{ ...valid.decisions[0], result: { answers: [{ questionId: "connection-provider:hubspot", optionIds: ["via:arcade:hubspot"] }] } }] }, + { ...valid, calls: 1 }, + { ...valid, calls: undefined }, + { ...valid, sameConnections: false }, + { ...valid, replies: [{ ...valid.replies[0], body: "Unavailable, but PRIVATE_MARKER" }] }, + ]) expect(passes(input)).toBe(false); + }); +}); diff --git a/tests/runner-e2e/everyday-flow.ts b/tests/runner-e2e/everyday-flow.ts index 22f9734815f..050d079a12a 100644 --- a/tests/runner-e2e/everyday-flow.ts +++ b/tests/runner-e2e/everyday-flow.ts @@ -1,4 +1,6 @@ import { gradeAgentmailSetup } from "./agentmail-setup-evidence.js"; +import { CONNECTION_GUIDANCE_SUITE, CONNECTION_GUIDANCE_BUDGET_CENTS } from "./connection-guidance-cases.js"; +import { gradeConnectionGuidanceDecline } from "./connection-guidance-evidence.js"; import { expect, type Page } from "@playwright/test"; import { runnerApiToolsEnabled } from "../../server/src/services/native-runtime/runner-api-rollout.js"; import { spawn } from "node:child_process"; @@ -62,6 +64,7 @@ export interface EverydayEvidence { fixtureConfiguration?: { apiToolsEnabled: boolean; aiConnection?: LiveFixtureValues["aiConnection"]; + connectionGuidanceBudgets?: { companyMonthlyCents: unknown; agentMonthlyCents: unknown }; }; documents?: Row[]; checks: StoryCheck[]; @@ -565,6 +568,20 @@ export async function runEverydayFlow(input: Input) { "connection-reviews.ts", "catalog.ts", ]; + if (execution.suite.id === CONNECTION_GUIDANCE_SUITE) { + harnessFiles.push("connection-guidance-cases.ts", "connection-guidance-evidence.ts"); + const [company, agent] = await Promise.all([ + api.get("/api/companies/" + fixtures.company.id), + api.get("/api/agents/" + fixtures.agent.id), + ]); + ev.fixtureConfiguration!.connectionGuidanceBudgets = { + companyMonthlyCents: company.budgetMonthlyCents, agentMonthlyCents: agent.budgetMonthlyCents, + }; + const budgetsMatch = company.budgetMonthlyCents === CONNECTION_GUIDANCE_BUDGET_CENTS && + agent.budgetMonthlyCents === CONNECTION_GUIDANCE_BUDGET_CENTS; + check("guidance-budget-hard-stops", budgetsMatch, "Public company and lead records retain both 1,000-cent hard stops before task creation."); + if (!budgetsMatch) throw new Error("Connection guidance budget admission failed before task creation"); + } ev.harnessDigest = createHash("sha256") .update( ( @@ -649,8 +666,8 @@ export async function runEverydayFlow(input: Input) { marker: `Pages: Roadmap, Meeting notes. Verification code: SERVICE_${nonce}`, authenticated: true, }); - if (providerChoice || nativeProviderCase) { - if (caseId === "provider-second") aggregatorFixture = await setupAggregatorFixture(api, fixtures.company.id, fixtures.agent.id, `CONTACTS_${nonce}`); + if (providerChoice || nativeProviderCase || (execution.suite.id === CONNECTION_GUIDANCE_SUITE && Boolean(review))) { + if (caseId === "provider-second" || (execution.suite.id === CONNECTION_GUIDANCE_SUITE && caseId === "provider-decline")) aggregatorFixture = await setupAggregatorFixture(api, fixtures.company.id, fixtures.agent.id, `CONTACTS_${nonce}`); const state = await api.get<{connections:Row[]}>(`/api/companies/${fixtures.company.id}/tools/connections`); initialConnections = state.connections.map(c=>c.id); } @@ -1116,6 +1133,18 @@ export async function runEverydayFlow(input: Input) { sameConnections:isDeepStrictEqual(state.connections.map(c=>c.id).sort(), initialConnections.sort()), })); } + if (execution.suite.id === CONNECTION_GUIDANCE_SUITE && + (caseId === "service-decline" || caseId === "connection-decline" || caseId === "provider-decline")) { + const issue = ev.issues.find(i => i.id === parent!.id)!; + const state = await api.get<{ connections: Row[] }>("/api/companies/" + fixtures.company.id + "/tools/connections"); + ev.checks.push(...gradeConnectionGuidanceDecline({ + caseId, decisionId: decisionId!, decisions: issue.interactions as any, + leadAgentId: fixtures.agent.id, issueId: parent!.id, replies: issue.comments ?? [], + runs: ev.runs, calls: review?.invocationCount() ?? aggregatorFixture?.invocationCount(), + marker: (caseId === "provider-decline" ? "CONTACTS_" : "SERVICE_") + nonce, + sameConnections: isDeepStrictEqual(state.connections.map(c => c.id).sort(), initialConnections.sort()), + })); + } if (declining) { const issue = ev.issues.find((i) => i.id === parent!.id)!; const requests = issue.interactions as Row[]; diff --git a/tests/runner-e2e/everyday-observations.ts b/tests/runner-e2e/everyday-observations.ts index adbaaeed81d..33ae797704c 100644 --- a/tests/runner-e2e/everyday-observations.ts +++ b/tests/runner-e2e/everyday-observations.ts @@ -1,3 +1,5 @@ +import type { IssueComment } from "../../packages/shared/src/types/issue.js"; + export interface StoryCheck { id: string; passed: boolean; @@ -24,9 +26,8 @@ export interface StoryIssue { wakeDiagnostics?: StoryWakeDiagnostics; blockedTransitionAt?: string | null; } -export interface StoryComment { +export interface StoryComment extends Partial> { id?: string; - authorAgentId?: string | null; body?: unknown; createdAt?: string; } diff --git a/tests/runner-e2e/live-fixtures.test.ts b/tests/runner-e2e/live-fixtures.test.ts index a2c231dc3a3..d3ac65d6d79 100644 --- a/tests/runner-e2e/live-fixtures.test.ts +++ b/tests/runner-e2e/live-fixtures.test.ts @@ -27,6 +27,28 @@ describe("live runner fixtures", () => { }, ); + it.each(["runner-codex", "runner-acpx-claude", "runner-opencode"])( + "sets both connection-guidance budget stops for %s before execution", async profile => { + const execution = runnerMatrix.find(row => row.suite.id === "native-connection-guidance" && row.profile.id === profile)!; + let companyBudget: unknown; + let agentBudget: unknown; + const api = { + async get() { return [{ id: "local", driver: "local" }]; }, + async postSensitive() { return { id: "secret" }; }, + async post(url: string, data: any) { + if (url === "/api/companies") { companyBudget = data.budgetMonthlyCents; return { id: "company", name: "Test" }; } + if (url.endsWith("/agents")) { agentBudget = data.budgetMonthlyCents; return { id: "lead", ...data }; } + throw new Error("Unexpected POST " + url); + }, + } as unknown as RunnerApi; + const fixtures = await setupLiveFixtures({ api, execution, executionNonce: "nonce", workspacePath: "/tmp/test", + credentials: { [execution.profile.credential]: "test-value" } }); + expect(companyBudget).toBe(1_000); + expect(agentBudget).toBe(1_000); + await fixtures.teardown(); + }, + ); + it.each(["runner-codex", "legacy-codex", "runner-acpx-claude", "legacy-opencode"])( "creates a production-default %s hire with company and agent budget stops", async (profile) => { const execution = runnerMatrix.find(row => row.suite.id === "stock-harness" && row.profile.id === profile)!; diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts index 3f466a63ddb..26512d6079a 100644 --- a/tests/runner-e2e/live-fixtures.ts +++ b/tests/runner-e2e/live-fixtures.ts @@ -139,7 +139,7 @@ export async function setupLiveFixtures(input: { return api.post("/api/companies", { name: `Runner E2E ${execution.id} ${input.executionNonce}`, description: "Ephemeral paid full-stack runner acceptance fixture", - budgetMonthlyCents: ["native-completion", "native-instruction-consolidation"].includes(execution.suite.id) + budgetMonthlyCents: ["native-completion", "native-instruction-consolidation", "native-connection-guidance"].includes(execution.suite.id) || (execution.suite.id === "everyday-workflows" && ["hire-reuse", "delegate-feedback"].includes(execution.task.id)) ? NATIVE_COMPLETION_BUDGET_CENTS : execution.suite.id === "task-titles" ? TASK_TITLE_BUDGET_CENTS : execution.suite.id === "stock-harness" ? 1_000 : 0, @@ -295,7 +295,7 @@ export async function setupLiveFixtures(input: { secretRefs, executionId: input.executionNonce, }); - if (execution.suite.id === "stock-harness" + if (["stock-harness", "native-connection-guidance"].includes(execution.suite.id) || (execution.suite.id === "everyday-workflows" && ["hire-reuse", "delegate-feedback"].includes(execution.task.id))) { agent.budgetMonthlyCents = 1_000; } diff --git a/ui/src/pages/McpConnect.tsx b/ui/src/pages/McpConnect.tsx index 498aa035efb..0ee0106c93e 100644 --- a/ui/src/pages/McpConnect.tsx +++ b/ui/src/pages/McpConnect.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Globe, Paperclip } from "lucide-react"; import { Link, useParams } from "@/lib/router"; import type { McpConnection, McpConnectionRequest } from "@paperclipai/shared"; +import { deriveInitials, Identity } from "@/components/Identity"; +import { assistantConnectionDisplayName } from "./apps/connection-owner"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; @@ -116,20 +118,25 @@ function McpConnectRequest({ id, device = false, onEditCode }: { id: string; dev } export function AssistantConnectionsPage() { + const client = useQueryClient(); const connections = useQuery({ queryKey: ["mcp-connections"], queryFn: () => api.get("/mcp/connections"), retry: false }); - const revoke = useMutation({ mutationFn: (id: string) => api.delete(`/mcp/connections/${id}`), onSuccess: () => { void connections.refetch(); } }); + const active = connections.data?.filter(connection => !connection.revokedAt); + const revoke = useMutation({ mutationFn: (id: string) => api.delete(`/mcp/connections/${id}`), onSuccess: (_, id) => { + client.setQueryData(["mcp-connections"], rows => rows?.filter(row => row.id !== id)); + return client.invalidateQueries({ queryKey: ["mcp-connections"] }); + } }); return

Assistant connections

Revoking a connection stops its future tool calls. Work already delegated continues under your organization’s normal controls.

{connections.isPending &&

Loading connections…

} {(connections.error || revoke.error) &&

{(connections.error ?? revoke.error)?.message}

} - {connections.data?.length === 0 &&

No assistant connections.

} - {connections.data?.map((connection) => -

{connection.clientName}

+ {active?.length === 0 &&

No assistant connections.

} + {active?.map((connection) => +

Organization: {connection.companyName}

{connection.scopes.includes("paperclip:agent") ? "Dot agent connection" : connection.scopes.includes("paperclip:write") ? "Read and edit work" : "Read only"}

{connection.scopes.includes("paperclip:configure") &&

Configure agents, projects and skills

} - {connection.revokedAt ?

Revoked

: } +
)} Back to Paperclip
; diff --git a/ui/src/pages/apps/AssistantConnection.test.tsx b/ui/src/pages/apps/AssistantConnection.test.tsx index f994f06dc52..1ab357f2717 100644 --- a/ui/src/pages/apps/AssistantConnection.test.tsx +++ b/ui/src/pages/apps/AssistantConnection.test.tsx @@ -12,7 +12,7 @@ vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompan vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mocks.breadcrumbs }) })); vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => {children} })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -const grant = { id: "grant", companyId: "butter", companyName: "Butter", clientName: "OpenCode", scopes: ["paperclip:read", "paperclip:write"], createdAt: "2026-10-05T00:00:00Z", revokedAt: null }; +const grant = { id: "grant", companyId: "butter", companyName: "Butter", clientName: "OpenCode", user: { name: "Dotta", image: "/avatar.jpg" }, scopes: ["paperclip:read", "paperclip:write"], createdAt: "2026-10-05T00:00:00Z", revokedAt: null }; let root: Root, container: HTMLDivElement, client: QueryClient; async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); } async function render(element: React.ReactNode = ) { @@ -72,13 +72,63 @@ describe("assistant setup from Connections", () => { await render(); expect(container.textContent).not.toContain("Other secret client"); expect(container.textContent).toContain("Connected as you · Read and write"); + expect(container.textContent).toContain("Dotta’s OpenCode connection"); + expect(container.querySelector('[data-slot="avatar"]')).not.toBeNull(); mocks.connections.mockResolvedValue([{ ...grant, revokedAt: "2026-10-05T00:00:00Z" }]); - await act(async () => container.querySelector('[aria-label="Revoke OpenCode connection"]')!.click()); + await act(async () => container.querySelector('[aria-label="Revoke Dotta’s OpenCode connection"]')!.click()); await flush(); expect(mocks.revoke.mock.calls[0]?.[0]).toBe("grant"); - expect(container.textContent).toContain("Revoked"); + expect(container.textContent).not.toContain("Revoked"); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + expect(container.querySelector('[data-slot="avatar"]')).toBeNull(); + expect(container.textContent).toContain("No assistants connected to Butter yet"); expect(container.querySelector('[aria-label="Copy first prompt"]')).toBeNull(); }); + it("hides retained revoked grants in both the page and catalog card", async () => { + mocks.connections.mockResolvedValue([{ ...grant, revokedAt: "2026-10-05T00:00:00Z" }]); + await render(); + expect(container.textContent).toContain("No assistants connected to Butter yet"); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + await render(); + expect(container.querySelector('[aria-label="Set up Assistant Connection (MCP)"]')).not.toBeNull(); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + }); + it.each(["https://assistant.example", "my_assistant", "Custom Assistant"])("preserves a custom client’s name: %s", async (clientName) => { + mocks.connections.mockResolvedValue([{ ...grant, clientName }]); + await render(); + expect(container.textContent).toContain(`Dotta’s ${clientName} connection`); + expect(container.querySelector('button[aria-label^="Revoke Dotta’s"]')?.getAttribute("aria-label")).toBe(`Revoke Dotta’s ${clientName} connection`); + }); + it("uses the person’s initials when they have no profile image", async () => { + mocks.connections.mockResolvedValue([{ ...grant, user: { name: "Dotta", image: null } }]); + await render(); + expect(container.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("DO"); + expect(container.textContent).toContain("Dotta’s OpenCode connection"); + }); + it("keeps an older server’s connection usable without profile metadata", async () => { + mocks.connections.mockResolvedValue([{ ...grant, user: undefined }]); + await render(); + expect(container.textContent).toContain("OpenCode connection"); + expect(container.querySelector('[aria-label="Revoke OpenCode connection"]')).not.toBeNull(); + }); + it("retains the connection when revocation fails", async () => { + mocks.connections.mockResolvedValue([grant]); + mocks.revoke.mockRejectedValueOnce(new Error("offline")); + await render(); + await act(async () => container.querySelector('[aria-label="Revoke Dotta’s OpenCode connection"]')!.click()); + await flush(); + expect(container.textContent).toContain("Couldn’t revoke this connection"); + expect(container.textContent).toContain("Dotta’s OpenCode connection"); + }); + it("hides a successfully revoked connection even if the follow-up refresh fails", async () => { + mocks.connections.mockResolvedValue([grant]); + await render(); + mocks.connections.mockRejectedValue(new Error("offline")); + await act(async () => container.querySelector('[aria-label="Revoke Dotta’s OpenCode connection"]')!.click()); + await flush(); + expect(mocks.revoke).toHaveBeenCalledWith("grant", expect.anything()); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + }); it("shows a real grant when the list refreshes after assistant sign-in", async () => { await render(); mocks.connections.mockResolvedValue([grant]); diff --git a/ui/src/pages/apps/AssistantConnection.tsx b/ui/src/pages/apps/AssistantConnection.tsx index f9745a1de78..3951399ec5b 100644 --- a/ui/src/pages/apps/AssistantConnection.tsx +++ b/ui/src/pages/apps/AssistantConnection.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { assistantClientNames, mcpAuthorizationHandoffInstructions, mcpInvitation, mcpSetupSteps, type AssistantClient } from "@paperclipai/shared"; +import { assistantClientNames, mcpAuthorizationHandoffInstructions, mcpInvitation, mcpSetupSteps, type AssistantClient, type McpConnection } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, ExternalLink, Globe, Paperclip, Plug, Terminal } from "lucide-react"; import { publicMcpApi } from "@/api/publicMcp"; @@ -8,6 +8,8 @@ import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { Link } from "@/lib/router"; import { copyTextToClipboard } from "@/lib/clipboard"; +import { deriveInitials, Identity } from "@/components/Identity"; +import { assistantConnectionDisplayName } from "./connection-owner"; import { AgentSetupPrompt } from "@/components/AgentSetupPrompt"; import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; @@ -32,13 +34,13 @@ export function useAssistantConnections(poll = false) { queryKey: connectionsKey, queryFn: publicMcpApi.connections, retry: false, enabled: Boolean(selectedCompanyId), refetchInterval: poll ? 5000 : false, }); - return { ...query, rows: (query.data ?? []).filter(row => row.companyId === selectedCompanyId && !row.scopes.includes("paperclip:agent")) }; + return { ...query, rows: (query.data ?? []).filter(row => row.companyId === selectedCompanyId && !row.revokedAt && !row.scopes.includes("paperclip:agent")) }; } /** Inbound assistant access belongs beside the existing outbound connectors. */ export function AssistantConnectionCard({ onNavigate }: { onNavigate: (href: string) => void }) { const connections = useAssistantConnections(); - const active = connections.rows.filter(row => !row.revokedAt); + const active = connections.rows; const action = !connections.isSuccess ? "Open" : active.length ? "Manage" : "Set up"; return
0) : undefined} className="overflow-hidden rounded-xl border border-border">
@@ -55,9 +57,7 @@ export function AssistantConnectionCard({ onNavigate }: { onNavigate: (href: str
} {connections.isSuccess && active.length > 0 &&
-
}
; } @@ -92,7 +92,10 @@ export function AssistantConnection({ initialAssistant = "codex" }: { initialAss const [assistant, setAssistant] = useState(initialAssistant); const setup = useQuery({ queryKey: ["mcp-setup"], queryFn: publicMcpApi.setup, retry: false, refetchOnWindowFocus: "always", refetchOnMount: "always" }); const connections = useAssistantConnections(setup.data?.enabled === true); - const revoke = useMutation({ mutationFn: publicMcpApi.revoke, onSuccess: () => client.invalidateQueries({ queryKey: connectionsKey }) }); + const revoke = useMutation({ mutationFn: publicMcpApi.revoke, onSuccess: (_, id) => { + client.setQueryData(connectionsKey, rows => rows?.filter(row => row.id !== id)); + return client.invalidateQueries({ queryKey: connectionsKey }); + } }); useEffect(() => { setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: "Assistant Connection (MCP)" }]); return () => setBreadcrumbs([]); @@ -140,8 +143,8 @@ export function AssistantConnection({ initialAssistant = "codex" }: { initialAss {(connections.error || revoke.error) &&

{revoke.error ? "Couldn’t revoke this connection. Try again." : "Couldn’t load your connections. Try again."}

} {connections.isSuccess && connections.rows.length === 0 &&

No assistants connected to {selectedCompany.name} yet.

}
{connections.rows.map(row =>
-

{row.clientName}

{row.revokedAt ? "Revoked" : `Connected as you · ${row.scopes.includes("paperclip:write") ? "Read and write" : "Read only"}${row.scopes.includes("paperclip:configure") ? " · Configure agents, projects and skills" : ""}`}

- {!row.revokedAt && } +

{`Connected as you · ${row.scopes.includes("paperclip:write") ? "Read and write" : "Read only"}${row.scopes.includes("paperclip:configure") ? " · Configure agents, projects and skills" : ""}`}

+
)}
diff --git a/ui/src/pages/apps/connection-owner.tsx b/ui/src/pages/apps/connection-owner.tsx index 3a723e5de2d..473ae6b5d6d 100644 --- a/ui/src/pages/apps/connection-owner.tsx +++ b/ui/src/pages/apps/connection-owner.tsx @@ -1,4 +1,4 @@ -import type { ToolConnection } from "@paperclipai/shared"; +import type { McpConnection, ToolConnection } from "@paperclipai/shared"; import { humanizeConnectionDisplayName } from "@paperclipai/shared"; import { Identity } from "@/components/Identity"; import type { CompanyUserProfile } from "@/lib/company-members"; @@ -52,6 +52,13 @@ export function connectionDisplayNameForOwner( return `${possessive(ownerGivenName(owner.label))} ${applicationName}`; } +/** Use the same owner naming as outbound connections, without changing OAuth client identity. */ +export function assistantConnectionDisplayName(connection: McpConnection): string { + const clientName = connection.clientName.trim(); + const owner = connection.user ? `${possessive(ownerGivenName(connection.user.name))} ` : ""; + return `${owner}${clientName} connection`; +} + export function ConnectionOwnerIdentity({ owner }: { owner: ConnectionOwnerProfile | null }) { if (!owner) return Unknown; return ( diff --git a/ui/storybook/fixtures/publicMcp.ts b/ui/storybook/fixtures/publicMcp.ts index bca39fb3e61..bbddcb5c0ef 100644 --- a/ui/storybook/fixtures/publicMcp.ts +++ b/ui/storybook/fixtures/publicMcp.ts @@ -1,6 +1,9 @@ import { instanceExperimentalSettingsSchema, type InstanceExperimentalSettingsWithManaged, type McpConnection, type McpConnectionRequest } from "@paperclipai/shared"; import { fn } from "storybook/test"; +// Local portrait fixture keeps avatar previews independent of third-party image hosts. +export const connectedUser = { name: "Dotta", image: `data:image/svg+xml,${encodeURIComponent('')}` }; + export const consentSubmission = fn(); export const request: McpConnectionRequest = { id: "storybook-request", clientName: "Codex", redirectOrigin: "https://chatgpt.com", @@ -11,8 +14,8 @@ export const request: McpConnectionRequest = { ], setupUrl: null, }; const connections: McpConnection[] = [ - { id: "codex", companyId: request.companies[0].id, companyName: "Acme Research", clientName: "Codex", scopes: ["paperclip:read", "paperclip:write"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, - { id: "claude", companyId: request.companies[1].id, companyName: "Design Partners", clientName: "Claude", scopes: ["paperclip:read"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, + { id: "codex", companyId: request.companies[0].id, companyName: "Acme Research", clientName: "Codex", user: connectedUser, scopes: ["paperclip:read", "paperclip:write"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, + { id: "claude", companyId: request.companies[1].id, companyName: "Design Partners", clientName: "Claude", user: connectedUser, scopes: ["paperclip:read"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, ]; export interface PublicMcpFixture { request?: Partial; @@ -33,7 +36,7 @@ export interface PublicMcpFixture { export function installPublicMcpFixture(fixture: PublicMcpFixture = {}) { consentSubmission.mockClear(); const original = window.fetch; - let rows = fixture.empty ? [] : (fixture.connections ?? connections).map(row => ({ ...row, revokedAt: fixture.revoked ? "2020-01-02T00:00:00Z" : null })); + let rows = fixture.empty ? [] : (fixture.connections ?? connections).map(row => ({ ...row, revokedAt: fixture.revoked ? "2020-01-02T00:00:00Z" : row.revokedAt })); let settings: InstanceExperimentalSettingsWithManaged = { ...instanceExperimentalSettingsSchema.parse({}), enablePublicMcp: Boolean(fixture.enabled), managedKeys: fixture.managed ? { enablePublicMcp: { managed: true, managedBy: "paperclip-cloud" } } : {} }; const error = (message: string, status = 503) => Response.json({ error: message }, { status }); window.fetch = async (input, init) => { diff --git a/ui/storybook/stories/public-mcp-connections.stories.tsx b/ui/storybook/stories/public-mcp-connections.stories.tsx index 04a0f3eba46..fd6623e7599 100644 --- a/ui/storybook/stories/public-mcp-connections.stories.tsx +++ b/ui/storybook/stories/public-mcp-connections.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { AssistantConnectionsPage } from "@/pages/McpConnect"; import { installPublicMcpFixture } from "../fixtures/publicMcp"; const meta = { @@ -13,11 +13,14 @@ export const Connected: Story = {}; export const Empty: Story = { parameters: { fixture: { empty: true } } }; export const Loading: Story = { parameters: { fixture: { loading: true } } }; export const Unavailable: Story = { parameters: { fixture: { unavailable: true } } }; -export const AlreadyRevoked: Story = { parameters: { fixture: { revoked: true } } }; +export const AlreadyRevoked: Story = { parameters: { fixture: { revoked: true } }, play: async ({ canvasElement }) => { + await expect(await within(canvasElement).findByText("No assistant connections.")).toBeVisible(); +} }; export const Revoke: Story = { play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.click((await c.findAllByRole("button", { name: "Revoke connection" }))[0]); - await c.findByText("Revoked"); + await waitFor(() => expect(c.queryByText("Dotta’s Codex connection")).not.toBeInTheDocument()); + await c.findByText("Dotta’s Claude connection"); await expect(c.getAllByRole("button", { name: "Revoke connection" })).toHaveLength(1); } }; export const RevokeFailed: Story = { parameters: { fixture: { mutationError: true } }, play: async ({ canvasElement }) => { diff --git a/ui/storybook/stories/public-mcp-journey.stories.tsx b/ui/storybook/stories/public-mcp-journey.stories.tsx index 61de6adb9ac..a4c43830ffb 100644 --- a/ui/storybook/stories/public-mcp-journey.stories.tsx +++ b/ui/storybook/stories/public-mcp-journey.stories.tsx @@ -7,7 +7,7 @@ import { Browse } from "@/pages/apps/Browse"; import { AssistantConnection } from "@/pages/apps/AssistantConnection"; import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings"; import { PublicMcpPresenter } from "./public-mcp-presenter"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { connectedUser, installPublicMcpFixture } from "../fixtures/publicMcp"; function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | "claude" | "opencode" | "other" }) { return @@ -39,7 +39,7 @@ export const OpenCodeSetup: Story = { parameters: { initialEntries: ["/PAP/apps/ export const CodexSetup: Story = { ...OpenCodeSetup, args: { assistant: "codex" } }; export const ClaudeSetup: Story = { ...OpenCodeSetup, args: { assistant: "claude" } }; export const Connected: Story = { parameters: { initialEntries: ["/PAP/apps/assistant-connection"], fixture: { enabled: true, empty: false, connections: [ - { id: "opencode", companyId: "company-storybook", companyName: "Paperclip", clientName: "OpenCode", scopes: ["paperclip:read", "paperclip:write", "offline_access"], createdAt: "2026-10-05T12:00:00Z", revokedAt: null }, + { id: "opencode", companyId: "company-storybook", companyName: "Paperclip", clientName: "OpenCode", user: connectedUser, scopes: ["paperclip:read", "paperclip:write", "offline_access"], createdAt: "2026-10-05T12:00:00Z", revokedAt: null }, { id: "other-org", companyId: "another-company", companyName: "Other organization", clientName: "Private other client", scopes: ["paperclip:read"], createdAt: "2026-10-05T12:00:00Z", revokedAt: null }, ] } } }; export const Unavailable: Story = { parameters: { initialEntries: ["/PAP/apps/assistant-connection"], fixture: { unavailable: true } } }; @@ -65,3 +65,10 @@ export const InvitationCopied: Story = { ...OpenCodeSetup, play: async ({ canvas await expect(await navigator.clipboard.readText()).toContain("/mcp/setup?company="); } }; export const ManualSetup: Story = { ...OpenCodeSetup, play: async ({ canvasElement }) => { await userEvent.click(await within(canvasElement).findByText("Set up manually")); } }; + +export const RevokeConnection: Story = { ...Connected, play: async ({ canvasElement }) => { + const c = within(canvasElement); + await userEvent.click(await c.findByRole("button", { name: "Revoke Dotta’s OpenCode connection" })); + await expect(await c.findByText("No assistants connected to Paperclip Storybook yet.")).toBeVisible(); + await expect(c.queryByText("Dotta’s OpenCode connection")).not.toBeInTheDocument(); +} };