merge: integrate current MCP connection improvements

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 22:54:54 -05:00
commit f712015f7f
40 files changed
+2575 -46

No files matched your search

+4
View File
@@ -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.
+85
View File
@@ -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.
+6
View File
@@ -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,
@@ -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.
@@ -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.
+3 -1
View File
@@ -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,
+221
View File
@@ -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;
}
+1
View File
@@ -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";
+1 -1
View File
@@ -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("'", "'\"'\"'")}'`;
+2
View File
@@ -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;
@@ -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<typeof createServer>[] = [];
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<typeof createServer>[0]) => {
const server = createServer(handler);
servers.push(server);
await new Promise<void>((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<string, unknown>; 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<Buffer>, 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<void>((resolve) => server.close(() => resolve()));
await pool.end();
await tenant.cleanup();
await home.cleanup();
vi.unstubAllEnvs();
await rm(directory, { recursive: true, force: true });
}
}, 60000);
});
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
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<string, unknown> = {};
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<string, unknown>;
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<string, unknown>; 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();
});
});
@@ -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",
];
@@ -34,6 +34,7 @@ const apiPrefixes: Record<string, string> = {
"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<string>();
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",
+13
View File
@@ -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());
+11 -2
View File
@@ -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,
+2
View File
@@ -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
@@ -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
+136
View File
@@ -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<Record<string, unknown>> },
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;
}
@@ -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<RunAuthority> {
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" },
);
}
@@ -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<InspectionResource, Reader> = {
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<string, AnyPgColumn>;
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<string, unknown>;
}
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<unknown> {
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<string, AnyPgColumn>;
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<string, unknown>),
runtimeConfig: redactEventPayload(r.runtimeConfig as Record<string, unknown>),
}));
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<string, unknown> | null;
const routine = snapshot?.routine as Record<string, unknown> | 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<typeof deriveBundleState>[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<Parameters<typeof files.list>[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<string, unknown> {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {};
const record = snapshot as Record<string, unknown>;
const object = (value: unknown) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
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),
};
}
+6 -4
View File
@@ -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";
@@ -496,12 +496,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,
}));
},
@@ -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;
}
+40
View File
@@ -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
+3 -3
View File
@@ -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);
+13
View File
@@ -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.",
@@ -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<Record<string, string>> = {
"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");
}
@@ -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<StoryComment, "authorAgentId" | "createdByRunId" | "createdAt" | "body">;
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." },
];
}
@@ -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<typeof gradeConnectionGuidanceDecline>[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);
});
});
+31 -2
View File
@@ -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<Row>("/api/companies/" + fixtures.company.id),
api.get<Row>("/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[];
+3 -2
View File
@@ -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<Pick<IssueComment, "authorAgentId" | "createdByRunId">> {
id?: string;
authorAgentId?: string | null;
body?: unknown;
createdAt?: string;
}
+22
View File
@@ -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)!;
+2 -2
View File
@@ -139,7 +139,7 @@ export async function setupLiveFixtures(input: {
return api.post<CompanyRecord>("/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;
}
+13 -6
View File
@@ -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<McpConnection[]>("/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<McpConnection[]>(["mcp-connections"], rows => rows?.filter(row => row.id !== id));
return client.invalidateQueries({ queryKey: ["mcp-connections"] });
} });
return <div className="mx-auto max-w-xl space-y-4 py-10">
<h1 className="text-xl font-semibold">Assistant connections</h1>
<p className="text-sm text-muted-foreground">Revoking a connection stops its future tool calls. Work already delegated continues under your organization’s normal controls.</p>
{connections.isPending && <p className="text-sm">Loading connections…</p>}
{(connections.error || revoke.error) && <p className="text-sm text-destructive">{(connections.error ?? revoke.error)?.message}</p>}
{connections.data?.length === 0 && <p className="text-sm">No assistant connections.</p>}
{connections.data?.map((connection) => <Card key={connection.id} className="block space-y-2 p-4">
<h2 className="font-medium">{connection.clientName}</h2>
{active?.length === 0 && <p className="text-sm">No assistant connections.</p>}
{active?.map((connection) => <Card key={connection.id} className="block space-y-2 p-4">
<h2 className="font-medium"><Identity name={assistantConnectionDisplayName(connection)} avatarUrl={connection.user?.image} initials={deriveInitials(connection.user?.name ?? "You")} /></h2>
<p className="text-sm text-muted-foreground">Organization: {connection.companyName}</p>
<p className="text-sm">{connection.scopes.includes("paperclip:agent") ? "Dot agent connection" : connection.scopes.includes("paperclip:write") ? "Read and edit work" : "Read only"}</p>
{connection.scopes.includes("paperclip:configure") && <p className="text-sm">Configure agents, projects and skills</p>}
{connection.revokedAt ? <p className="text-sm text-muted-foreground">Revoked</p> : <Button variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(connection.id)}>Revoke connection</Button>}
<Button variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(connection.id)}>Revoke connection</Button>
</Card>)}
<Link className="text-sm underline" to="/">Back to Paperclip</Link>
</div>;
+56 -5
View File
@@ -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 }) => <a href={to} {...props}>{children}</a> }));
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 = <AssistantConnection initialAssistant="opencode" />) {
@@ -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<HTMLButtonElement>('[aria-label="Revoke OpenCode connection"]')!.click());
await act(async () => container.querySelector<HTMLButtonElement>('[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(<AssistantConnectionCard onNavigate={vi.fn()} />);
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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]);
@@ -100,8 +150,9 @@ describe("assistant setup from Connections", () => {
await act(async () => { await client.invalidateQueries({ queryKey: ["mcp-connections"] }); });
await flush();
expect(container.textContent).toContain("Connected as you · Read and write");
expect(container.querySelector('[aria-label="Revoke OpenCode connection"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Revoke Dedicated Dot connection"]')).toBeNull();
expect(container.querySelector('[aria-label="Revoke Dotta’s OpenCode connection"]')).not.toBeNull();
expect(container.textContent).not.toContain("Dedicated Dot");
expect(container.querySelector('[aria-label="Revoke Dotta’s Dedicated Dot connection"]')).toBeNull();
});
it("shows a recoverable error instead of pretending setup succeeded", async () => {
mocks.setup.mockRejectedValue(new Error("offline"));
+12 -9
View File
@@ -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 <div role="listitem" data-app-slug="assistant-connection" data-connected={connections.isSuccess ? String(active.length > 0) : undefined} className="overflow-hidden rounded-xl border border-border">
<div className="flex flex-wrap items-center gap-3 px-4 py-4">
@@ -55,9 +57,7 @@ export function AssistantConnectionCard({ onNavigate }: { onNavigate: (href: str
<Button size="sm" variant="ghost" disabled={connections.isFetching} onClick={() => void connections.refetch()}>Try again</Button>
</div>}
{connections.isSuccess && active.length > 0 && <div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-3 text-sm">
<Check className="size-4 text-muted-foreground" aria-hidden="true" />
<span>{active.map(row => row.clientName).join(", ")}</span>
<span className="text-xs text-muted-foreground">Connected as you</span>
{active.map(row => <Identity key={row.id} name={assistantConnectionDisplayName(row)} avatarUrl={row.user?.image} initials={deriveInitials(row.user?.name ?? "You")} size="sm" />)}
</div>}
</div>;
}
@@ -92,7 +92,10 @@ export function AssistantConnection({ initialAssistant = "codex" }: { initialAss
const [assistant, setAssistant] = useState<Assistant>(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<McpConnection[]>(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) && <p role="alert" className="text-sm text-destructive">{revoke.error ? "Couldn’t revoke this connection. Try again." : "Couldn’t load your connections. Try again."} <button type="button" className="underline" onClick={() => void connections.refetch()}>Refresh</button></p>}
{connections.isSuccess && connections.rows.length === 0 && <p className="text-sm text-muted-foreground">No assistants connected to {selectedCompany.name} yet.</p>}
<div className="divide-y divide-border">{connections.rows.map(row => <div key={row.id} className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="space-y-1"><p className="text-sm font-medium">{row.clientName}</p><p className="text-xs text-muted-foreground">{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" : ""}`}</p></div>
{!row.revokedAt && <Button variant="outline" size="sm" disabled={revoke.isPending} onClick={() => revoke.mutate(row.id)} aria-label={`Revoke ${row.clientName} connection`}>Revoke</Button>}
<div className="min-w-0 flex-1 space-y-1"><Identity name={assistantConnectionDisplayName(row)} avatarUrl={row.user?.image} initials={deriveInitials(row.user?.name ?? "You")} className="font-medium" /><p className="text-xs text-muted-foreground">{`Connected as you · ${row.scopes.includes("paperclip:write") ? "Read and write" : "Read only"}${row.scopes.includes("paperclip:configure") ? " · Configure agents, projects and skills" : ""}`}</p></div>
<Button variant="outline" size="sm" disabled={revoke.isPending} onClick={() => revoke.mutate(row.id)} aria-label={`Revoke ${assistantConnectionDisplayName(row)}`}>Revoke</Button>
</div>)}</div>
</section>
<footer className="flex items-center justify-between gap-3 border-t border-border pt-4"><Button variant="ghost" asChild><Link to="/apps">Back to Connections</Link></Button><a className="inline-flex items-center gap-1 text-xs text-muted-foreground underline" href={assistant === "opencode" ? "https://opencode.ai/docs/mcp-servers/" : assistant === "claude" ? "https://code.claude.com/docs/en/mcp" : "https://developers.openai.com/codex/mcp"} target="_blank" rel="noreferrer">Setup documentation <ExternalLink className="size-3" /></a></footer>
+8 -1
View File
@@ -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 <span className="text-xs text-muted-foreground">Unknown</span>;
return (
+6 -3
View File
@@ -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('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#626972"/><circle cx="32" cy="24" r="12" fill="#eef0f2"/><path d="M10 64v-8a22 22 0 0 1 44 0v8" fill="#eef0f2"/></svg>')}` };
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<McpConnectionRequest>;
@@ -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) => {
@@ -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 }) => {
@@ -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 <PluginLauncherProvider><Routes>
@@ -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();
} };