mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
merge: integrate current MCP connection improvements
Preserve dedicated Dot authority while integrating personalized assistant setup and filtering revoked grants. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
beb1d5598a
40 files changed
+2572
-44
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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("'", "'\"'\"'")}'`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { and, eq, gt, inArray, isNull, lt, lte, notExists, sql } from "drizzle-o
|
||||
import { z } from "zod";
|
||||
import type { Request } from "express";
|
||||
import {
|
||||
type Db, activityLog, agents, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions,
|
||||
type Db, activityLog, agents, authUsers, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions,
|
||||
} from "@paperclipai/db";
|
||||
import { DOT_RUNNER_MCP_PATH, DOT_RUNNER_MCP_SCOPES, PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, type McpConnectionRequest } from "@paperclipai/shared";
|
||||
import { boardAuthService } from "../board-auth.js";
|
||||
@@ -493,12 +493,14 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
await fenceAgentGrant(grant);
|
||||
},
|
||||
async listConnections(userId: string) {
|
||||
const rows = await db.select({ grant: mcpOauthGrants, clientName: mcpOauthClients.name, companyName: companies.name })
|
||||
const rows = await db.select({ grant: mcpOauthGrants, clientName: mcpOauthClients.name, companyName: companies.name, userName: authUsers.name, userImage: authUsers.image })
|
||||
.from(mcpOauthGrants).innerJoin(mcpOauthClients, eq(mcpOauthGrants.clientId, mcpOauthClients.id))
|
||||
.innerJoin(companies, eq(companies.id, mcpOauthGrants.companyId))
|
||||
.leftJoin(authUsers, eq(authUsers.id, mcpOauthGrants.userId))
|
||||
.where(eq(mcpOauthGrants.userId, userId));
|
||||
return rows.map(({ grant, clientName, companyName }) => ({
|
||||
id: grant.id, companyId: grant.companyId, clientName, companyName, scopes: grant.scopes,
|
||||
return rows.map(({ grant, clientName, companyName, userName, userImage }) => ({
|
||||
id: grant.id, companyId: grant.companyId, clientName, companyName,
|
||||
user: userName === null ? null : { name: userName, image: userImage }, scopes: grant.scopes,
|
||||
createdAt: grant.createdAt.toISOString(), revokedAt: grant.revokedAt?.toISOString() ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
} };
|
||||
Reference in new issue
Block a user