feat(connections): add durable GitHub identities and webhooks (#12843)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents need source control access for repository work
> - A shared token cannot preserve the responsible person's identity or
an agent's dedicated identity
> - GitHub App tokens also need durable refresh, repository access
checks, and webhook delivery
> - Paperclip already has managed connections, encrypted grants, run
secret leases, and merge-confirmation behavior
> - This pull request extends those systems with GitHub identities
instead of adding a parallel credential system
> - The benefit is durable GitHub access with explicit identity,
repository, runtime, and webhook boundaries

## Linked Issues or Issue Description

No public GitHub issue describes this connection change. This
description follows the feature request template.

**Subsystem affected**

Connected Apps, connection grants, secret resolution, native Git runtime
setup, webhook processing, and the Apps UI.

**Problem or motivation**

Users need to connect GitHub once and let agents use the correct GitHub
identity. A run should use a dedicated agent account when one exists.
Otherwise, it should use the responsible person's account. The
connection must survive token expiry, repository access changes, and
temporary instance downtime.

**Proposed solution**

Add user-owned and agent-owned GitHub grants to the existing connection
model. Resolve one identity for MCP, Git, `gh`, health checks, and
webhook bindings. Store provider tokens in the existing encrypted secret
system. Refresh expiring token pairs under the existing lease and
compare-and-swap path. Register signed Cloud webhook bindings and
process normalized pull request and installation events through a
durable local inbox.

**Alternatives considered**

An organization-wide GitHub token would lose person and agent
attribution. Environment variables alone would bypass the managed
connection and grant model. A new GitHub-only credential store would
duplicate the existing secret and access systems. GitHub App
installation tokens and private-key custody remain outside this first
version.

**Roadmap alignment**

This change implements the Connected Apps direction. It also extends the
shipped MCP Tool Gateway, per-agent secret access, and
action-attribution systems. It does not add a repository catalog. The
open repository catalog work in
[#11234](https://github.com/paperclipai/paperclip/pull/11234) is related
and complementary.

## What Changed

- Added agent-owned connection grants and a per-agent credential policy
with company and subject constraints.
- Added a managed GitHub App method while keeping the personal access
token method as an advanced fallback.
- Added durable access-token and refresh-token handling with proactive
rotation and one automatic recovery after a provider `401`.
- Added GitHub identity and installation summaries without storing
repository-name lists.
- Added signed Cloud webhook binding, event lease, acknowledgement,
local idempotency, pull request merge processing, and installation
access handling.
- Added one identity resolver for MCP, native Git, `gh`, checkout,
health checks, and webhook bindings.
- Added a class-3 run projection for `GH_TOKEN`, `GITHUB_TOKEN`, a
`github.com`-only credential helper, SSH-to-HTTPS rewrite, and GitHub
noreply commit attribution.
- Added personal and dedicated-agent setup choices plus identity,
repository, continuity, and webhook status in the Apps UI.
- Added schema migrations, tests, and connection documentation.

## Verification

- The current head is fully green in GitHub CI, including build,
typecheck, all serialized/general server shards, all browser shards,
policy, canary dry run, review, and security checks.
- Live staging proof completed with a non-expiring GitHub App user
token, selected-repository installation, repository add/remove refresh,
managed MCP, native `gh`, HTTPS clone/push/delete, GitHub noreply commit
attribution, signed merged-PR webhook acceptance, durable
Cloud-to-instance delivery, and installation-access event processing.
Temporary branches and temporary repository access were removed
afterward.
- `pnpm check:token-gates` passed.
- `pnpm -r typecheck` passed before and after the rebase onto
`origin/master`.
- `pnpm build` passed.
- The focused connector suite passed 285 tests after the rebase.
- The full stable suite passed 5,790 tests and failed 22 tests across 8
general server files. The failures reproduced as shared-runner
environment issues. They included `/tmp` versus `/private/tmp`, closed
database connections, and invalid high ephemeral ports. The focused
connection tests pass in isolation.

## Risks

- Migrations add agent grant subjects and a durable connection-event
inbox. Migration numbering and safety checks pass.
- A raw GitHub user token enters the agent process for Git and `gh`.
Per-tool Ask-first controls cannot limit those shell operations. The UI
warns users about this boundary.
- GitHub App user tokens can be non-expiring. Paperclip performs a
continuity check every 30 days, but provider revocation still requires a
reconnect.
- The webhook path accepts only signed and bounded payloads. It stores a
minimal normalized record and no raw provider payload.
- GitHub repository permissions remain authoritative. Removed access can
make a cached repository count temporarily stale, but runtime access
fails immediately.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code
execution, browser control, and multi-file repository editing. The
context window size was not provided.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-04 18:02:52 -05:00 committed by GitHub
parent 263f181fed
commit 0ffc091473
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 47134 additions and 516 deletions

View File

@ -107,7 +107,7 @@ chooses all five axes below.
| Authentication | `oauth`, `api_key`, `none` | How does the provider authorize requests? |
| OAuth client ownership | `dcr`, `customer`, `platform_shared`, `platform_provisioned` | Who supplies and controls the OAuth client registration? |
| Credential source | `paperclip_vault`, reviewed `vercel_connect` | Where does durable provider credential material live? |
| Grant identity | `organization`, `user` | Does the credential act for the company or one person? |
| Grant identity | `organization`, `user`, `agent` | Does the credential act for the company, one person, or one dedicated agent? |
These axes produce combinations such as:
@ -119,6 +119,8 @@ These axes produce combinations such as:
- Remote MCP + no auth + required tenant field: Shopify.
- Remote MCP + Paperclip-managed OAuth client + per-user grant: Google
Workspace MCP previews.
- Remote MCP + Paperclip-managed OAuth client + personal or dedicated-agent
grant: GitHub. See [GitHub managed connection](./GITHUB.md).
- Local stdio MCP + approved command template: the Google Sheets robot flow and
development fixtures.
- REST API parent + provider-specific child-session bridge: Composio. This is a

107
doc/connections/GITHUB.md Normal file
View File

@ -0,0 +1,107 @@
# GitHub managed connection
GitHub is a Paperclip Cloud-managed GitHub App connection with an advanced PAT
compatibility method. Cloud owns the fixed public OAuth callback and signed
webhook inbox; provider tokens are sealed to the enrolled instance and stored
only in its existing encrypted secret system.
## Identity resolution
Every MCP call, `gh` invocation, native Git operation, checkout, health check,
and webhook binding uses the same order:
1. An active dedicated GitHub grant for the current agent.
2. The active personal GitHub grant owned by the run's `responsibleUserId`.
3. For automated work without a responsible user, a personal grant only when
an existing standing delegation names the agent.
4. Legacy `GH_TOKEN`/`GITHUB_TOKEN` only when no managed GitHub connection is
configured for the company.
An unavailable or ambiguous managed identity fails visibly. It never falls
through to another person, an organization credential, or a legacy token.
Agent grants are company-scoped, have exactly one `subjectAgentId`, cannot be
organization defaults, and are installed only for that agent.
The connection installation is the credential owner's consent boundary. A
personal setup may target every agent or a selected set, and runtime resolution
considers only an enabled, active connection installed for the current agent.
Within that boundary, Paperclip treats the run's server-resolved
`responsibleUserId` as its credential principal, including for automated work;
agents cannot choose or spoof this field. The owner must still be an active
non-viewer company member at each use. A standing delegation is needed only
when a run genuinely has no responsible user.
## Credential lifecycle
The production, staging, and development GitHub Apps deliberately disable
user-to-server token expiration. The resulting long-lived access token is
checked with GitHub's `/user` endpoint every 30 days, together with installation
and repository summary refresh. Routine continuity requires no browser visit.
If GitHub returns an expiring access token and rotating refresh token instead,
Paperclip stores both encrypted and:
- refreshes at least one hour before access expiry;
- forces a rotation at least every 30 days while the instance is active;
- serializes refresh through the existing database refresh lease and compare-
and-swap update;
- atomically advances both secret values before clearing the lease;
- retries one forced refresh after a provider `401`.
Only an unrecoverable provider invalidation marks a grant
`needs_reauthorization`. Installation removal or suspension is reported as an
installation-health failure, not as token expiry.
## Repository access
OAuth completion verifies `/user`, `/user/installations`, and each
installation's accessible repository count. Setup remains incomplete until at
least one installation and repository are available. Paperclip stores user and
installation summaries, not a repository-name cache. GitHub stays authoritative:
removed repository access fails immediately even if a displayed count is stale.
The Apps UI links to GitHub's installation management page and offers
**Refresh access**. Selected repositories are recommended. Choosing all
repositories requires an explicit warning in setup.
## Webhooks
Paperclip Cloud verifies `X-Hub-Signature-256` against the exact bounded request
body before parsing, deduplicates by `X-GitHub-Delivery`, and persists a minimal
normalized event before returning `202`. Raw webhook payloads are discarded.
When registering an active binding, Paperclip sends the current user token only
inside the signed, payload-bound broker request so Cloud can verify access to
that exact installation; Cloud neither logs nor persists that proof token.
Deliveries fan out independently to every enrolled instance bound to the GitHub
installation and are sealed to each instance's public key.
The instance polls with backoff, stores a company-scoped idempotency receipt,
and acknowledges only successful applications. A merged pull request updates
its matching external-object snapshot and immediately runs the existing merge-
confirmation resolver. It wakes the assignee only when that interaction's
continuation policy requests it; unrelated Paperclip issues are not closed.
The periodic GitHub merge sweep remains the reconciliation fallback.
Installation lifecycle events refresh or invalidate installation summaries and
remove obsolete Cloud bindings. Activity records contain event identifiers and
outcomes but no webhook content. GitHub webhook content is never first-party
telemetry.
## Run projection
The resolved token is leased at run start as an audited class-3 secret and is
projected only into the child process:
- `GH_TOKEN`, `GITHUB_TOKEN`, and an internal credential-helper environment key;
- `GIT_TERMINAL_PROMPT=0`;
- process-scoped `GIT_CONFIG_COUNT/KEY_n/VALUE_n` entries that clear ambient
helpers, install a `github.com`-only helper, and rewrite GitHub SSH remotes to
HTTPS;
- author and committer identity using
`<numeric-id>+<login>@users.noreply.github.com`.
Tokens never appear in arguments, URLs, files, logs, events, or model context,
and the projection never replaces `HOME`.
Cloud deployment and exact GitHub App registration settings live in
`paperclip-cloud/docs/github-connector-deploy-bootstrap.md`.

View File

@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS "connection_event_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"provider" text NOT NULL,
"provider_delivery_id" text NOT NULL,
"event" text NOT NULL,
"action" text,
"installation_id" text,
"repository_id" text,
"normalized_payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"status" text DEFAULT 'received' NOT NULL,
"attempts" integer DEFAULT 1 NOT NULL,
"last_error" text,
"provider_created_at" timestamp with time zone,
"processed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "connection_grants" DROP CONSTRAINT IF EXISTS "connection_grants_kind_check";--> statement-breakpoint
ALTER TABLE "connection_grants" DROP CONSTRAINT IF EXISTS "connection_grants_subject_check";--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_credential_policy_check";--> statement-breakpoint
ALTER TABLE "connection_grants" ADD COLUMN IF NOT EXISTS "subject_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "subject_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "connection_event_deliveries" DROP CONSTRAINT IF EXISTS "connection_event_deliveries_company_id_companies_id_fk";--> statement-breakpoint
ALTER TABLE "connection_event_deliveries" ADD CONSTRAINT "connection_event_deliveries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "connection_event_deliveries_company_provider_id_uq" ON "connection_event_deliveries" USING btree ("company_id","provider","provider_delivery_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "connection_event_deliveries_company_status_idx" ON "connection_event_deliveries" USING btree ("company_id","status","created_at");--> statement-breakpoint
ALTER TABLE "connection_grants" DROP CONSTRAINT IF EXISTS "connection_grants_subject_agent_id_agents_id_fk";--> statement-breakpoint
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_subject_agent_id_agents_id_fk" FOREIGN KEY ("subject_agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tool_oauth_states" DROP CONSTRAINT IF EXISTS "tool_oauth_states_subject_agent_id_agents_id_fk";--> statement-breakpoint
ALTER TABLE "tool_oauth_states" ADD CONSTRAINT "tool_oauth_states_subject_agent_id_agents_id_fk" FOREIGN KEY ("subject_agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "connection_grants_subject_agent_idx" ON "connection_grants" USING btree ("company_id","subject_agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "connection_grants_agent_uq" ON "connection_grants" USING btree ("connection_id","subject_agent_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tool_oauth_states_subject_agent_idx" ON "tool_oauth_states" USING btree ("company_id","subject_agent_id");--> statement-breakpoint
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_kind_check" CHECK ("connection_grants"."kind" in ('organization', 'user', 'agent'));--> statement-breakpoint
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_subject_check" CHECK (("connection_grants"."kind" = 'user' and "connection_grants"."subject_user_id" is not null and "connection_grants"."subject_agent_id" is null) or ("connection_grants"."kind" = 'agent' and "connection_grants"."subject_agent_id" is not null and "connection_grants"."subject_user_id" is null) or ("connection_grants"."kind" = 'organization' and "connection_grants"."subject_user_id" is null and "connection_grants"."subject_agent_id" is null));--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_credential_policy_check" CHECK ("tool_connections"."credential_policy" in ('shared', 'per_user', 'per_user_with_fallback', 'per_agent'));

File diff suppressed because it is too large Load Diff

View File

@ -1660,6 +1660,13 @@
"when": 1788542862021,
"tag": "0238_graceful_infant_terrible",
"breakpoints": true
},
{
"idx": 239,
"version": "7",
"when": 1788557575279,
"tag": "0239_sturdy_santa_claus",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,39 @@
import { index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
/**
* The instance-side durable receipt for normalized connector events.
*
* Cloud remains the delivery queue. This table makes applying a leased event
* transactional and idempotent when a lease expires before its acknowledgement
* reaches Cloud. Raw provider webhook bodies must never be stored here.
*/
export const connectionEventDeliveries = pgTable(
"connection_event_deliveries",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
providerDeliveryId: text("provider_delivery_id").notNull(),
event: text("event").notNull(),
action: text("action"),
installationId: text("installation_id"),
repositoryId: text("repository_id"),
normalizedPayload: jsonb("normalized_payload").$type<Record<string, unknown>>().notNull().default({}),
status: text("status").$type<"received" | "processed" | "failed">().notNull().default("received"),
attempts: integer("attempts").notNull().default(1),
lastError: text("last_error"),
providerCreatedAt: timestamp("provider_created_at", { withTimezone: true }),
processedAt: timestamp("processed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("connection_event_deliveries_company_provider_id_uq").on(
table.companyId,
table.provider,
table.providerDeliveryId,
),
index("connection_event_deliveries_company_status_idx").on(table.companyId, table.status, table.createdAt),
],
);

View File

@ -47,6 +47,7 @@ export { issueRecoveryActions } from "./issue_recovery_actions.js";
export { issueReferenceMentions } from "./issue_reference_mentions.js";
export { externalObjects } from "./external_objects.js";
export { externalObjectMentions } from "./external_object_mentions.js";
export { connectionEventDeliveries } from "./connection_event_deliveries.js";
export { issueRelations } from "./issue_relations.js";
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
export { pipelines, pipelineStages, pipelineTransitions } from "./pipelines.js";

View File

@ -152,7 +152,7 @@ export const toolConnections = pgTable(
or
(${table.credentialSource} = 'vercel_connect' and ${table.externalCredential} is not null and jsonb_array_length(${table.credentialRefs}) = 0 and jsonb_array_length(${table.credentialSecretRefs}) = 0)
)`),
check("tool_connections_credential_policy_check", sql`${table.credentialPolicy} in ('shared', 'per_user', 'per_user_with_fallback')`),
check("tool_connections_credential_policy_check", sql`${table.credentialPolicy} in ('shared', 'per_user', 'per_user_with_fallback', 'per_agent')`),
index("tool_connections_company_idx").on(table.companyId),
index("tool_connections_application_idx").on(table.applicationId),
index("tool_connections_company_enabled_idx").on(table.companyId, table.enabled),
@ -169,20 +169,38 @@ export const connectionGrants = pgTable(
connectionId: uuid("connection_id").notNull(),
kind: text("kind").$type<ConnectionGrantKind>().notNull(),
subjectUserId: text("subject_user_id"),
subjectAgentId: uuid("subject_agent_id").references(() => agents.id, { onDelete: "cascade" }),
providerTenant: jsonb("provider_tenant").$type<{
name?: string;
externalId?: string;
oauth?: {
strategy?: string;
accessTokenExpiresAt?: string;
accessTokenExpiresAt?: string | null;
scopes?: string[];
tokenType?: string;
refreshedAt?: string;
refreshTokenExpiresAt?: string;
refreshLease?: {
id?: string;
expiresAt?: string;
};
};
github?: {
userId: string;
login: string;
avatarUrl?: string;
installationCount: number;
repositoryCount: number;
repositorySelection: "all" | "selected" | "mixed" | "none";
installationIds: string[];
installationOwnerLogins: string[];
installationUrl?: string;
managementUrl?: string;
appSlug?: string;
lastAccessRefreshAt?: string;
lastWebhookAt?: string;
webhookHealth?: "pending" | "healthy" | "unhealthy";
};
}>(),
credentialSecretRefs: jsonb("credential_secret_refs").$type<ToolCredentialSecretRef[]>().notNull().default([]),
externalCredential: jsonb("external_credential").$type<VercelConnectGrantReference>(),
@ -198,10 +216,10 @@ export const connectionGrants = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
check("connection_grants_kind_check", sql`${table.kind} in ('organization', 'user')`),
check("connection_grants_kind_check", sql`${table.kind} in ('organization', 'user', 'agent')`),
check("connection_grants_status_check", sql`${table.status} in ('active', 'revoked', 'expired', 'needs_reauthorization')`),
check("connection_grants_credential_source_one_of_check", sql`${table.externalCredential} is null or jsonb_array_length(${table.credentialSecretRefs}) = 0`),
check("connection_grants_subject_check", sql`(${table.kind} = 'user' and ${table.subjectUserId} is not null) or (${table.kind} = 'organization' and ${table.subjectUserId} is null)`),
check("connection_grants_subject_check", sql`(${table.kind} = 'user' and ${table.subjectUserId} is not null and ${table.subjectAgentId} is null) or (${table.kind} = 'agent' and ${table.subjectAgentId} is not null and ${table.subjectUserId} is null) or (${table.kind} = 'organization' and ${table.subjectUserId} is null and ${table.subjectAgentId} is null)`),
check("connection_grants_default_check", sql`${table.isDefault} = false or ${table.kind} = 'organization'`),
foreignKey({
columns: [table.companyId, table.connectionId],
@ -210,8 +228,10 @@ export const connectionGrants = pgTable(
}).onDelete("cascade"),
index("connection_grants_company_connection_idx").on(table.companyId, table.connectionId),
index("connection_grants_subject_user_idx").on(table.companyId, table.subjectUserId),
index("connection_grants_subject_agent_idx").on(table.companyId, table.subjectAgentId),
unique("connection_grants_company_id_uq").on(table.companyId, table.id),
uniqueIndex("connection_grants_user_uq").on(table.connectionId, table.subjectUserId),
uniqueIndex("connection_grants_agent_uq").on(table.connectionId, table.subjectAgentId),
uniqueIndex("connection_grants_default_uq").on(table.connectionId).where(sql`${table.isDefault} = true and ${table.kind} = 'organization'`),
],
);
@ -295,6 +315,7 @@ export const toolOauthStates = pgTable(
createdByActorId: text("created_by_actor_id"),
createdBySessionId: text("created_by_session_id"),
subjectUserId: text("subject_user_id"),
subjectAgentId: uuid("subject_agent_id").references(() => agents.id, { onDelete: "cascade" }),
requestedScopes: jsonb("requested_scopes").$type<string[]>(),
returnTo: text("return_to"),
issueId: uuid("issue_id"),
@ -306,6 +327,7 @@ export const toolOauthStates = pgTable(
index("tool_oauth_states_company_idx").on(table.companyId),
index("tool_oauth_states_connection_idx").on(table.connectionId),
index("tool_oauth_states_actor_idx").on(table.createdByActorType, table.createdByActorId),
index("tool_oauth_states_subject_agent_idx").on(table.companyId, table.subjectAgentId),
index("tool_oauth_states_expires_at_idx").on(table.expiresAt),
],
);

View File

@ -140,9 +140,9 @@ describe("AppDefinition catalog",()=>{
});
it("withholds unverified and reserved providers from the app store without deleting their definitions",()=>{
expect([...APP_STORE_HIDDEN_SLUGS].sort()).toEqual([
"beehiiv","bitly","brex","candid","coda","composio","context7","egnyte","embat","github","kernel","local-falcon","make","manufact","oreilly","planetscale","razorpay","sanity","similarweb","slack","ticket-tailor","ticktick","xero",
"beehiiv","bitly","brex","candid","coda","composio","context7","egnyte","embat","kernel","local-falcon","make","manufact","oreilly","planetscale","razorpay","sanity","similarweb","slack","ticket-tailor","ticktick","xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(35);
expect(APP_STORE_DEFINITIONS).toHaveLength(36);
const connectableSlugs=new Set(CONNECTABLE_APP_DEFINITIONS.map((entry)=>entry.slug));
const storeSlugs=new Set(APP_STORE_DEFINITIONS.map((entry)=>entry.slug));
for(const slug of APP_STORE_HIDDEN_SLUGS){
@ -150,13 +150,13 @@ describe("AppDefinition catalog",()=>{
expect(storeSlugs.has(slug),slug).toBe(false);
}
});
it("ships complete local branding provenance for all 35 store-visible providers",()=>{
it("ships complete local branding provenance for all 36 store-visible providers",()=>{
const uiPublic=path.resolve(path.dirname(fileURLToPath(import.meta.url)),"../../../ui/public");
const manifest=JSON.parse(fs.readFileSync(path.join(uiPublic,"brands/apps/manifest.json"),"utf8")) as {providers:Array<{slug:string;catalogVisible:boolean;localAsset:string;darkAsset?:string;officialSourceUrl:string;upstreamAssetUrl:string;assetType:"svg"|"png";darkVariantRequired:boolean}>};
const visible=manifest.providers.filter((entry)=>entry.catalogVisible);
expect(visible).toHaveLength(35);
expect(new Set(visible.map((entry)=>entry.slug))).toHaveProperty("size",35);
expect(new Set(visible.map((entry)=>entry.localAsset))).toHaveProperty("size",35);
expect(visible).toHaveLength(36);
expect(new Set(visible.map((entry)=>entry.slug))).toHaveProperty("size",36);
expect(new Set(visible.map((entry)=>entry.localAsset))).toHaveProperty("size",36);
expect(new Set(APP_STORE_DEFINITIONS.map((entry)=>entry.slug))).toEqual(new Set(visible.map((entry)=>entry.slug)));
for(const app of APP_STORE_DEFINITIONS){
const provenance=visible.find((entry)=>entry.slug===app.slug)!;

View File

@ -6,7 +6,6 @@ import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"zapier",
"github",
"slack",
"notion",
"posthog",
@ -23,6 +22,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
"google-chat",
"google-people",
"google-workspace-search",
"github",
]);
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
@ -45,7 +45,6 @@ export const APP_STORE_HIDDEN_SLUGS = new Set([
"context7",
"egnyte",
"embat",
"github",
"kernel",
"local-falcon",
"make",

View File

@ -15,8 +15,37 @@
"https://api.githubcopilot.com/mcp/*"
],
"methods": [
{
"key": "managed",
"label": "Connect with GitHub",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "github.code",
"grantKinds": [
"user",
"agent"
],
"ownershipModes": [
"platform_shared"
],
"whenToUse": "Connect your GitHub account for durable MCP, shell Git, gh, and repository access.",
"defaults": {
"serverUrl": "https://api.githubcopilot.com/mcp/"
},
"guidanceMd": "Authorize Paperclip, then choose selected repositories in GitHub. You can edit repository access later from GitHub's installation settings.",
"warnings": [
"Shell Git and gh receive this identity for the run and are not constrained by per-tool Ask-first controls."
],
"riskTier": "S3",
"requiredResourceFilters": [
"organization",
"repository"
]
},
{
"key": "mcp-key",
"label": "Personal access token (advanced)",
"transport": "mcp_remote",
"auth": "api_key",
"ownershipModes": [

View File

@ -0,0 +1,23 @@
export const GITHUB_CONNECTOR_PROFILE_IDS = ["github.code"] as const;
export type GitHubConnectorProfileId = (typeof GITHUB_CONNECTOR_PROFILE_IDS)[number];
export const GITHUB_CONNECTOR_PROFILES: Readonly<Record<GitHubConnectorProfileId, {
appSlug: "github";
serverUrl: string;
scopes: readonly string[];
writeTools: readonly string[];
}>> = {
"github.code": {
appSlug: "github",
serverUrl: "https://api.githubcopilot.com/mcp/",
// GitHub App permissions are configured on the App registration. GitHub
// returns an empty OAuth scope string for user-to-server tokens.
scopes: [],
writeTools: [],
},
};
export function isGitHubConnectorProfileId(value: string): value is GitHubConnectorProfileId {
return Object.prototype.hasOwnProperty.call(GITHUB_CONNECTOR_PROFILES, value);
}

View File

@ -314,6 +314,7 @@ export {
} from "./app-definitions.js";
export { APP_DEFINITIONS } from "./app-definitions.generated.js";
export * from "./google-workspace-connectors.js";
export * from "./github-connectors.js";
export {
BLOCKED_MCP_PROVIDERS,
SELF_SERVE_MCP_CANDIDATES,

View File

@ -73,9 +73,9 @@ export type ToolConnectionOwnership = "platform_shared" | "platform_provisioned"
export type ToolConnectionCredentialSource = "paperclip_vault" | "vercel_connect";
export type ToolConnectionStatus = "draft" | "active" | "disabled" | "archived";
export type ToolConnectionInstallTargetType = "company" | "agent";
export type ConnectionGrantKind = "organization" | "user";
export type ConnectionGrantKind = "organization" | "user" | "agent";
export type ConnectionGrantStatus = "active" | "revoked" | "expired" | "needs_reauthorization";
export type ToolConnectionCredentialPolicy = "shared" | "per_user" | "per_user_with_fallback";
export type ToolConnectionCredentialPolicy = "shared" | "per_user" | "per_user_with_fallback" | "per_agent";
export type ConnectionGrantMemberSubjectType = "user";
export type ToolCredentialPlacement = "header" | "env" | "url";
@ -199,20 +199,38 @@ export interface ConnectionGrant {
connectionId: string;
kind: ConnectionGrantKind;
subjectUserId: string | null;
subjectAgentId?: string | null;
providerTenant: {
name?: string;
externalId?: string;
oauth?: {
strategy?: string;
accessTokenExpiresAt?: string;
accessTokenExpiresAt?: string | null;
scopes?: string[];
tokenType?: string;
refreshTokenExpiresAt?: string;
refreshedAt?: string;
refreshLease?: {
id?: string;
expiresAt?: string;
};
};
github?: {
userId: string;
login: string;
avatarUrl?: string;
installationCount: number;
repositoryCount: number;
repositorySelection: "all" | "selected" | "mixed" | "none";
installationIds: string[];
installationOwnerLogins: string[];
installationUrl?: string;
managementUrl?: string;
appSlug?: string;
lastAccessRefreshAt?: string;
lastWebhookAt?: string;
webhookHealth?: "pending" | "healthy" | "unhealthy";
};
} | null;
credentialSecretRefs: ToolCredentialSecretRef[];
externalCredential?: VercelConnectGrantSummary | null;
@ -376,6 +394,7 @@ export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: s
export const CONNECTION_RECOVERABLE_ERROR_CODES = [
"user_authorization_required",
"agent_authorization_required",
"organization_authorization_required",
"grant_audience_denied",
"grant_revoked",

View File

@ -65,13 +65,13 @@ export const vercelConnectGrantSummarySchema = z.object({
expiresAt: z.string().datetime({ offset: true }).optional(),
lastVerifiedAt: z.string().datetime({ offset: true }).optional(),
}).strict();
export const connectionGrantKindSchema = z.enum(["organization", "user"]);
export const connectionGrantKindSchema = z.enum(["organization", "user", "agent"]);
export const connectionGrantStatusSchema = z.enum(["active", "revoked", "expired", "needs_reauthorization"]);
export const createConnectionGrantDelegationSchema = z.object({
agentId: z.string().guid(),
});
export type CreateConnectionGrantDelegation = z.infer<typeof createConnectionGrantDelegationSchema>;
export const toolConnectionCredentialPolicySchema = z.enum(["shared", "per_user", "per_user_with_fallback"]);
export const toolConnectionCredentialPolicySchema = z.enum(["shared", "per_user", "per_user_with_fallback", "per_agent"]);
export const toolConnectionStatusSchema = z.enum(["draft", "active", "disabled", "archived"]);
export const toolConnectionInstallTargetTypeSchema = z.enum(["company", "agent"]);
export const toolCredentialPlacementSchema = z.enum(["header", "env", "url"]);
@ -207,14 +207,33 @@ export const connectionGrantSchema = z.object({
connectionId: z.string().guid(),
kind: connectionGrantKindSchema,
subjectUserId: z.string().nullable(),
subjectAgentId: z.string().guid().nullable().optional(),
providerTenant: z.object({
name: z.string().trim().min(1).max(200).optional(),
externalId: z.string().trim().min(1).max(400).optional(),
oauth: z.object({
strategy: z.string().trim().min(1).max(100).optional(),
accessTokenExpiresAt: z.string().datetime().optional(),
accessTokenExpiresAt: z.string().datetime().nullable().optional(),
scopes: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
tokenType: z.string().trim().min(1).max(100).optional(),
refreshTokenExpiresAt: z.string().datetime().optional(),
refreshedAt: z.string().datetime().optional(),
}).optional(),
github: z.object({
userId: z.string().regex(/^[1-9][0-9]{0,30}$/),
login: z.string().trim().min(1).max(100),
avatarUrl: z.string().url().max(2000).optional(),
installationCount: z.number().int().nonnegative(),
repositoryCount: z.number().int().nonnegative(),
repositorySelection: z.enum(["all", "selected", "mixed", "none"]),
installationIds: z.array(z.string().regex(/^[1-9][0-9]{0,30}$/)).max(100),
installationOwnerLogins: z.array(z.string().trim().min(1).max(100)).max(100),
installationUrl: z.string().url().max(2000).optional(),
managementUrl: z.string().url().max(2000).optional(),
appSlug: z.string().regex(/^[a-z0-9-]{1,100}$/).optional(),
lastAccessRefreshAt: z.string().datetime().optional(),
lastWebhookAt: z.string().datetime().optional(),
webhookHealth: z.enum(["pending", "healthy", "unhealthy"]).optional(),
}).optional(),
}).nullable(),
credentialSecretRefs: z.array(toolCredentialSecretRefSchema),
@ -230,8 +249,11 @@ export const connectionGrantSchema = z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
}).superRefine((grant, ctx) => {
if ((grant.kind === "user") !== Boolean(grant.subjectUserId)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectUserId"], message: "User grants require a subject user; organization grants must not have one" });
const validSubject = (grant.kind === "user" && Boolean(grant.subjectUserId) && !grant.subjectAgentId)
|| (grant.kind === "agent" && Boolean(grant.subjectAgentId) && !grant.subjectUserId)
|| (grant.kind === "organization" && !grant.subjectUserId && !grant.subjectAgentId);
if (!validSubject) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["kind"], message: "User and agent grants require exactly their matching subject; organization grants cannot have a subject" });
}
if (grant.externalCredential && grant.credentialSecretRefs.length > 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["credentialSecretRefs"], message: "External grants cannot also contain Paperclip secret references" });
@ -404,9 +426,14 @@ export const connectToolAppSchema = z.object({
* Omitted keeps the historical shared-credential behaviour.
*/
grantKind: connectionGrantKindSchema.optional(),
/** Same-company agent that owns a dedicated provider identity. */
subjectAgentId: z.string().guid().optional(),
}).superRefine((value, ctx) => {
if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]);
if (value.credentialValues) rejectUnsafeHeaderCredentials(value.credentialValues, ctx, ["credentialValues"]);
if ((value.grantKind === "agent") !== Boolean(value.subjectAgentId)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectAgentId"], message: "subjectAgentId is required exactly for an agent grant" });
}
if (value.authMode && value.galleryKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@ -473,8 +500,13 @@ export type FinalizeOAuthAccess = z.infer<typeof finalizeOAuthAccessSchema>;
export const startToolOAuthSchema = z.object({
asCurrentUser: z.boolean().optional(),
asAgentId: z.string().uuid().optional(),
interactionId: z.string().uuid().optional(),
}).strict().default({});
}).strict().superRefine((value, ctx) => {
if (value.asCurrentUser && value.asAgentId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["asAgentId"], message: "Choose either the current user or one dedicated agent" });
}
}).default({});
export type StartToolOAuth = z.infer<typeof startToolOAuthSchema>;

View File

@ -172,7 +172,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
expect(serialized).not.toContain(claims.sub);
});
it("creates one addressed request, resolves only after delegation and install, and then reports ready", async () => {
it("creates one addressed request, resolves after install, and then reports ready for the responsible user", async () => {
const service = connectionIntentService(db);
const first = await service.request(claims, "notion");
const repeated = await service.request(claims, "notion");
@ -266,6 +266,11 @@ describeEmbeddedPostgres("connectionIntentService", () => {
expect.objectContaining({ targetType: "agent", targetId: otherAgent!.id }),
]));
// A task-bound run already carries the responsible user's identity. The
// standing delegation created by an agent-initiated setup is only needed
// for future automated runs that have no responsible user.
await db.delete(connectionGrantDelegations).where(eq(connectionGrantDelegations.grantId, grant!.id));
const continuationRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: continuationRunId,
@ -283,20 +288,58 @@ describeEmbeddedPostgres("connectionIntentService", () => {
expect(readySearch.results).toEqual([
expect.objectContaining({ service: "notion", state: "ready", connectionId: connection!.id }),
]);
const [dedicatedConnection] = await db.insert(toolConnections).values({
companyId: claims.company_id,
applicationId: application!.id,
name: "Researcher's dedicated Notion",
uid: `notion/${randomUUID()}`,
transport: "mcp_remote",
authKind: "api_key",
credentialPolicy: "per_agent",
status: "active",
enabled: true,
healthStatus: "ok",
config: { sourceTemplateKey: "notion" },
transportConfig: { sourceTemplateKey: "notion" },
}).returning();
await db.insert(connectionGrants).values({
companyId: claims.company_id,
connectionId: dedicatedConnection!.id,
kind: "agent",
subjectAgentId: claims.sub,
status: "active",
isDefault: false,
});
await db.insert(toolConnectionInstalls).values({
companyId: claims.company_id,
connectionId: dedicatedConnection!.id,
targetType: "agent",
targetId: claims.sub,
});
const dedicatedSearch = await service.search(continuationClaims, "notion");
expect(dedicatedSearch.results).toEqual([
expect.objectContaining({ service: "notion", state: "ready", connectionId: dedicatedConnection!.id }),
]);
const readyRequest = await service.request(continuationClaims, "notion");
expect(readyRequest).toMatchObject({
state: "ready",
interactionId: null,
connectionId: connection!.id,
connectionId: dedicatedConnection!.id,
});
expect(await db.select().from(issueThreadInteractions)).toHaveLength(1);
await expect(service.complete(first.interactionId!, connection!.id, claims.responsible_user_id))
.rejects.toThrow("already resolved");
await expect(service.request(claims, "unknown-service"))
.rejects.toThrow("is not available");
expect((await service.search(claims, "github")).results).toEqual([]);
await expect(service.request(claims, "github"))
.rejects.toThrow("is not available");
expect((await service.search(claims, "github")).results).toEqual([
expect.objectContaining({ service: "github", state: "available" }),
]);
await expect(service.request(claims, "github")).resolves.toMatchObject({
state: "needs_user_action",
connectionId: null,
});
});
it("serializes OAuth intent completion behind addressed-user membership revocation", async () => {

View File

@ -83,13 +83,23 @@ describe("createGitRemoteAuthProvider", () => {
await expect(provider(githubUrl)).resolves.toBeNull();
});
it("returns null for out-of-scope URLs without touching the secret store", async () => {
it("accepts GitHub SSH remotes for process-scoped HTTPS rewriting", async () => {
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets,
env: {},
});
const invocation = await provider("git@github.com:example/repo.git");
expect(invocation?.env.GIT_CONFIG_VALUE_3).toBe("git@github.com:");
expect(invocation?.env.GIT_CONFIG_KEY_3).toBe("url.https://github.com/.insteadOf");
});
it("returns null for non-GitHub URLs without touching the secret store", async () => {
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets,
env: {},
});
await expect(provider("git@github.com:example/repo.git")).resolves.toBeNull();
await expect(provider("https://gitlab.com/example/repo.git")).resolves.toBeNull();
expect(secrets.getByName).not.toHaveBeenCalled();
});
@ -139,6 +149,40 @@ describe("createGitRemoteAuthProvider", () => {
const invocation = await provider(githubUrl);
expect(invocation?.secretName).toBe("GH_TOKEN");
});
it("ignores a managed connection installed only for another agent", async () => {
const query = (rows: unknown[]) => ({
from: () => ({ where: async () => rows }),
});
const db = {
select: vi.fn()
.mockReturnValueOnce(query([{
id: "github-connection",
companyId: "company-1",
enabled: true,
status: "active",
config: { sourceTemplateKey: "github" },
}]))
.mockReturnValueOnce(query([{
connectionId: "github-connection",
companyId: "company-1",
targetType: "agent",
targetId: "agent-a",
}])),
} as unknown as Db;
const secrets = buildSecretsFake({ GH_TOKEN: "agent-b-legacy-token" });
const provider = createGitRemoteAuthProvider(db, "company-1", { agentId: "agent-b" }, {
secrets,
env: {},
});
const invocation = await provider(githubUrl);
expect(invocation?.source).toBe("company_secret");
expect(invocation?.secretName).toBe("GH_TOKEN");
expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("agent-b-legacy-token");
expect(db.select).toHaveBeenCalledTimes(2);
});
});
describe("buildGitAuthInvocation", () => {
@ -155,7 +199,28 @@ describe("buildGitAuthInvocation", () => {
expect(invocation.configArgs[3]).toContain("x-access-token");
expect(invocation.configArgs[5]).toContain("credential.https://www.github.com.helper=");
expect(invocation.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("super-secret-token");
expect(invocation.env.GH_TOKEN).toBe("super-secret-token");
expect(invocation.env.GITHUB_TOKEN).toBe("super-secret-token");
expect(invocation.env.GIT_TERMINAL_PROMPT).toBe("0");
expect(invocation.env).not.toHaveProperty("HOME");
});
it("sets GitHub's stable noreply commit identity without exposing the token in config", () => {
const invocation = buildGitAuthInvocation({
token: "super-secret-token",
source: "managed_connection",
secretName: null,
githubIdentity: { userId: "12345", login: "octocat" },
});
expect(invocation.env.GIT_CONFIG_KEY_7).toBe("user.name");
expect(invocation.env.GIT_CONFIG_VALUE_7).toBe("octocat");
expect(invocation.env.GIT_CONFIG_KEY_8).toBe("user.email");
expect(invocation.env.GIT_CONFIG_VALUE_8).toBe("12345+octocat@users.noreply.github.com");
expect(invocation.env.GIT_AUTHOR_NAME).toBe("octocat");
expect(invocation.env.GIT_AUTHOR_EMAIL).toBe("12345+octocat@users.noreply.github.com");
expect(invocation.env.GIT_COMMITTER_NAME).toBe("octocat");
expect(invocation.env.GIT_COMMITTER_EMAIL).toBe("12345+octocat@users.noreply.github.com");
expect(Object.values(invocation.env).filter((value) => value.includes("super-secret-token"))).toHaveLength(3);
});
});

View File

@ -0,0 +1,307 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
companies,
connectionEventDeliveries,
connectionGrants,
createDb,
externalObjects,
toolApplications,
toolConnections,
} from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { githubConnectionEventService } from "../services/github-connection-events.js";
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres.sequential("GitHub connection event delivery", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-github-events-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(connectionEventDeliveries);
await db.delete(externalObjects);
await db.delete(connectionGrants);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("applies a normalized merged PR once, updates the snapshot, and acknowledges Cloud", async () => {
const companyId = randomUUID();
const userId = `github-owner-${randomUUID()}`;
const applicationId = randomUUID();
const connectionId = randomUUID();
const grantId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: "GHE" });
await db.insert(toolApplications).values({
id: applicationId,
companyId,
applicationKey: `github-${randomUUID()}`,
name: "GitHub",
type: "mcp_server",
status: "active",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId,
applicationId,
name: "GitHub",
uid: `github-${randomUUID()}`,
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
enabled: true,
config: { sourceTemplateKey: "github", oauth: { connectorProfile: "github.code" } },
transportConfig: {},
});
await db.insert(connectionGrants).values({
id: grantId,
companyId,
connectionId,
kind: "user",
subjectUserId: userId,
status: "active",
isDefault: false,
providerTenant: {
oauth: { strategy: "paperclip_cloud_connector", accessTokenExpiresAt: null },
github: {
userId: "42",
login: "octocat",
installationCount: 1,
repositoryCount: 3,
repositorySelection: "selected",
installationIds: ["101"],
installationOwnerLogins: ["paperclipai"],
webhookHealth: "pending",
},
},
});
const externalObjectId = randomUUID();
await db.insert(externalObjects).values({
id: externalObjectId,
companyId,
providerKey: "github",
objectType: "pull_request",
externalId: "paperclipai/paperclip#pull/123",
statusCategory: "open",
statusTone: "info",
data: { provider: "github", marker: "preserved" },
});
const leasedEvent = {
id: "delivery_merged_123",
provider: "github" as const,
event: "pull_request",
action: "closed",
installationId: "101",
repositoryId: "99",
createdAt: "2026-09-04T12:00:00.000Z",
bindingIds: [`${grantId}_101`],
payload: {
repository: "paperclipai/paperclip",
number: 123,
state: "closed",
merged: true,
mergedAt: "2026-09-04T11:59:00.000Z",
updatedAt: "2026-09-04T11:59:01.000Z",
url: "https://github.com/paperclipai/paperclip/pull/123",
headRef: "feature",
headSha: "a".repeat(40),
baseRef: "master",
baseSha: "b".repeat(40),
body: "private pull request body",
comments: [{ body: "private review comment" }],
accessToken: "ghu_must_not_be_persisted",
arbitraryNested: { credential: "also-must-not-be-persisted" },
},
};
let poll = 0;
const connector = {
getCapabilities: vi.fn(async () => ["github.code" as const]),
startAuthorization: vi.fn(),
claim: vi.fn(),
refresh: vi.fn(),
revoke: vi.fn(),
setWebhookBinding: vi.fn(async () => undefined),
leaseEvents: vi.fn(async () => ({ leaseId: `lease-${++poll}`, events: [leasedEvent] })),
acknowledgeEvents: vi.fn(async () => 1),
} as unknown as PaperclipCloudConnector;
let currentTime = new Date("2026-09-04T12:00:05.000Z");
const service = githubConnectionEventService(db, { connector, now: () => currentTime });
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 1, processed: 1, duplicate: 0, failed: 0 });
const [snapshot] = await db.select().from(externalObjects).where(eq(externalObjects.id, externalObjectId));
expect(snapshot).toMatchObject({
statusKey: "merged",
statusLabel: "Merged",
statusCategory: "succeeded",
statusTone: "success",
isTerminal: true,
remoteVersion: "2026-09-04T11:59:01.000Z",
data: expect.objectContaining({ marker: "preserved", merged: true, headRef: "feature", baseSha: "b".repeat(40) }),
});
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grantId));
expect(grant?.providerTenant?.github).toMatchObject({ webhookHealth: "healthy", lastWebhookAt: currentTime.toISOString() });
const [receipt] = await db.select().from(connectionEventDeliveries).where(eq(
connectionEventDeliveries.providerDeliveryId,
leasedEvent.id,
));
expect(receipt).toMatchObject({
status: "processed",
attempts: 1,
provider: "github",
normalizedPayload: {
repository: "paperclipai/paperclip",
number: 123,
state: "closed",
merged: true,
mergedAt: "2026-09-04T11:59:00.000Z",
updatedAt: "2026-09-04T11:59:01.000Z",
url: "https://github.com/paperclipai/paperclip/pull/123",
headRef: "feature",
headSha: "a".repeat(40),
baseRef: "master",
baseSha: "b".repeat(40),
},
});
expect(JSON.stringify(receipt)).not.toMatch(/private pull request|private review|ghu_|also-must-not/);
const [activity] = await db.select().from(activityLog).where(eq(activityLog.action, "tool_connection.webhook_processed"));
expect(activity?.details).toEqual({
provider: "github",
event: "pull_request",
action: "closed",
deliveryId: leasedEvent.id,
installationId: "101",
repositoryId: "99",
});
expect(connector.acknowledgeEvents).toHaveBeenCalledTimes(1);
currentTime = new Date(currentTime.getTime() + 6_000);
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 1, processed: 0, duplicate: 1, failed: 0 });
const [duplicateReceipt] = await db.select().from(connectionEventDeliveries).where(eq(
connectionEventDeliveries.providerDeliveryId,
leasedEvent.id,
));
expect(duplicateReceipt?.attempts).toBe(1);
expect(connector.acknowledgeEvents).toHaveBeenCalledTimes(2);
});
it("applies installation repository deltas transactionally and never reapplies a processed delivery", async () => {
const companyId = randomUUID();
const applicationId = randomUUID();
const connectionId = randomUUID();
const grantId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: "GHI" });
await db.insert(toolApplications).values({
id: applicationId,
companyId,
applicationKey: `github-${randomUUID()}`,
name: "GitHub",
type: "mcp_server",
status: "active",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId,
applicationId,
name: "GitHub",
uid: `github-${randomUUID()}`,
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
enabled: true,
config: { sourceTemplateKey: "github", oauth: { connectorProfile: "github.code" } },
transportConfig: {},
});
await db.insert(connectionGrants).values({
id: grantId,
companyId,
connectionId,
kind: "user",
subjectUserId: `github-owner-${randomUUID()}`,
status: "active",
isDefault: false,
providerTenant: {
oauth: { strategy: "paperclip_cloud_connector", accessTokenExpiresAt: null },
github: {
userId: "42",
login: "octocat",
installationCount: 1,
repositoryCount: 3,
repositorySelection: "selected",
installationIds: ["101"],
installationOwnerLogins: ["paperclipai"],
webhookHealth: "pending",
},
},
});
const leasedEvent = {
id: "delivery_repository_change_101",
provider: "github" as const,
event: "installation_repositories",
action: "added",
installationId: "101",
repositoryId: null,
createdAt: "2026-09-04T12:00:00.000Z",
bindingIds: [`${grantId}_101`],
payload: {
repositorySelection: "selected",
repositoriesAdded: ["201", "202"],
repositoriesRemoved: ["203"],
},
};
let poll = 0;
const connector = {
getCapabilities: vi.fn(async () => ["github.code" as const]),
startAuthorization: vi.fn(),
claim: vi.fn(),
refresh: vi.fn(),
revoke: vi.fn(),
setWebhookBinding: vi.fn(async () => undefined),
leaseEvents: vi.fn(async () => ({ leaseId: `lease-${++poll}`, events: [leasedEvent] })),
acknowledgeEvents: vi.fn(async () => 1),
} as unknown as PaperclipCloudConnector;
let currentTime = new Date("2026-09-04T12:00:05.000Z");
const service = githubConnectionEventService(db, { connector, now: () => currentTime });
const unsubscribe = subscribeCompanyLiveEvents(companyId, () => {
throw new Error("fixture live subscriber failed");
});
await expect(service.pollOnce()).resolves.toMatchObject({ processed: 1, duplicate: 0, failed: 0 });
unsubscribe();
let [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grantId));
expect(grant?.providerTenant?.github).toMatchObject({ repositoryCount: 4, webhookHealth: "healthy" });
currentTime = new Date(currentTime.getTime() + 6_000);
await expect(service.pollOnce()).resolves.toMatchObject({ processed: 0, duplicate: 1, failed: 0 });
[grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grantId));
expect(grant?.providerTenant?.github?.repositoryCount).toBe(4);
const [receipt] = await db.select().from(connectionEventDeliveries).where(eq(
connectionEventDeliveries.providerDeliveryId,
leasedEvent.id,
));
expect(receipt).toMatchObject({ status: "processed", attempts: 1 });
});
});

View File

@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { loadGitHubGrantMetadata } from "../services/tool-access.js";
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}
describe("GitHub grant metadata", () => {
it("keeps only user and installation summaries while counting accessible repositories", async () => {
const request = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith("/user")) return json({ id: 42, login: "octocat", avatar_url: "https://avatars.example/octocat" });
if (url.includes("/user/installations?")) {
return json({
installations: [
{ id: 101, repository_selection: "selected", html_url: "https://github.com/settings/installations/101", account: { login: "paperclipai" } },
{ id: 102, repository_selection: "all", account: { login: "octocat" } },
],
});
}
if (url.includes("/user/installations/101/repositories")) {
return json({ total_count: 2, repositories: [{ full_name: "paperclipai/private-name-must-not-persist" }] });
}
if (url.includes("/user/installations/102/repositories")) return json({ total_count: 5 });
return json({}, 404);
});
const metadata = await loadGitHubGrantMetadata("ghu_secret", request, "paperclip-development");
expect(metadata).toMatchObject({
userId: "42",
login: "octocat",
installationCount: 2,
repositoryCount: 7,
repositorySelection: "mixed",
installationIds: ["101", "102"],
installationOwnerLogins: ["paperclipai", "octocat"],
installationUrl: "https://github.com/apps/paperclip-development/installations/new",
managementUrl: "https://github.com/settings/installations/101",
appSlug: "paperclip-development",
webhookHealth: "pending",
});
expect(JSON.stringify(metadata)).not.toContain("private-name-must-not-persist");
expect(request).toHaveBeenCalledTimes(4);
for (const [, init] of request.mock.calls) {
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer ghu_secret");
}
});
it("requires at least one installation with an accessible repository", async () => {
const request = vi.fn<typeof fetch>(async (input) => String(input).endsWith("/user")
? json({ id: 42, login: "octocat" })
: json({ installations: [] }));
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
details: expect.objectContaining({ code: "github_installation_required" }),
});
});
});

View File

@ -304,6 +304,81 @@ describe("resolveExecutionRunAdapterConfig", () => {
});
});
it("does not project brokered GitHub credentials across a low-trust boundary", async () => {
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
agentId: "agent-1",
issueId: "issue-1",
executionRunConfig: { env: {} },
projectEnv: null,
trustPreset: {
kind: "low_trust_review",
preset: LOW_TRUST_REVIEW_PRESET,
boundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId: "company-1",
issueIds: ["issue-1"],
allowedSecretBindingIds: [],
},
sourcePresets: {},
},
trustedEnvProjection: {
GH_TOKEN: "brokered-github-token",
GITHUB_TOKEN: "brokered-github-token",
},
trustedEnvSecretKeys: ["GH_TOKEN", "GITHUB_TOKEN"],
secretsSvc: {
resolveAdapterConfigForRuntime: vi.fn().mockResolvedValue({
config: { env: {} },
secretKeys: new Set<string>(),
manifest: [],
}),
resolveEnvBindings: vi.fn(),
} as any,
});
expect(result.resolvedConfig.env).toEqual({});
expect(result.secretKeys).toEqual(new Set());
});
it("does not let a brokered projection satisfy low-trust push preflight", async () => {
await expect(resolveExecutionRunAdapterConfig({
companyId: "company-1",
agentId: "agent-1",
issueId: "issue-1",
executionRunConfig: { env: {} },
projectEnv: null,
trustPreset: {
kind: "low_trust_review",
preset: LOW_TRUST_REVIEW_PRESET,
boundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId: "company-1",
issueIds: ["issue-1"],
allowedSecretBindingIds: [],
},
sourcePresets: {},
},
requiredScopedEnvBinding: {
keys: ["GH_TOKEN", "GITHUB_TOKEN"],
consumerScopes: ["agent", "project"],
reason: "push_write_credential_missing",
remediation: "Bind an explicitly allowed GitHub write credential.",
},
trustedEnvProjection: { GH_TOKEN: "brokered-github-token" },
trustedEnvSecretKeys: ["GH_TOKEN"],
secretsSvc: {
resolveAdapterConfigForRuntime: vi.fn(),
resolveEnvBindings: vi.fn(),
} as any,
})).rejects.toMatchObject({
code: "configuration_incomplete",
resultJson: {
configurationIncomplete: { reason: "push_write_credential_missing" },
},
});
});
it("blocks required missing user secrets before runtime env resolution", async () => {
const resolveAdapterConfigForRuntime = vi.fn();
const resolveEnvBindings = vi.fn();

View File

@ -5,6 +5,8 @@ import {
agents,
activityLog,
companies,
companyMemberships,
connectionGrants,
createDb,
heartbeatRuns,
toolAccessAuditEvents,
@ -44,6 +46,7 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
await db.delete(toolAccessAuditEvents);
await db.delete(heartbeatRuns);
await db.delete(toolMcpGateways);
await db.delete(connectionGrants);
await db.delete(toolConnectionInstalls);
await db.delete(toolProfileBindings);
await db.delete(toolProfileEntries);
@ -51,6 +54,7 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companies);
});
@ -199,6 +203,132 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
).resolves.toBeNull();
});
it("exposes only the dedicated GitHub connection when a personal connection is also installed", async () => {
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
const [company] = await db.insert(companies).values({
name: `Runtime GitHub identity ${randomUUID()}`,
issuePrefix: `RG${randomUUID().slice(0, 5).toUpperCase()}`,
}).returning();
await db.insert(companyMemberships).values({
companyId: company!.id,
principalType: "user",
principalId: "responsible-user",
status: "active",
membershipRole: "member",
});
const [agent] = await db.insert(agents).values({
companyId: company!.id,
name: "Dedicated GitHub Agent",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company!.id,
applicationKey: `github-${randomUUID().slice(0, 8)}`,
name: "GitHub",
type: "mcp_http",
status: "active",
metadata: { sourceTemplateKey: "github" },
}).returning();
const [personal, dedicated] = await db.insert(toolConnections).values([
{
companyId: company!.id,
applicationId: application!.id,
name: "Responsible user's GitHub",
uid: `github/${randomUUID()}`,
transport: "mcp_remote",
credentialPolicy: "per_user",
status: "active",
enabled: true,
healthStatus: "ok",
config: {},
transportConfig: { sourceTemplateKey: "github" },
},
{
companyId: company!.id,
applicationId: application!.id,
name: "Dedicated GitHub",
uid: `github/${randomUUID()}`,
transport: "mcp_remote",
credentialPolicy: "per_agent",
status: "active",
enabled: true,
healthStatus: "ok",
config: {},
transportConfig: { sourceTemplateKey: "github" },
},
]).returning();
await db.insert(connectionGrants).values([
{
companyId: company!.id,
connectionId: personal!.id,
kind: "user",
subjectUserId: "responsible-user",
status: "active",
isDefault: false,
},
{
companyId: company!.id,
connectionId: dedicated!.id,
kind: "agent",
subjectAgentId: agent!.id,
status: "active",
isDefault: false,
},
]);
await db.insert(toolConnectionInstalls).values([
{
companyId: company!.id,
connectionId: personal!.id,
targetType: "company",
targetId: company!.id,
},
{
companyId: company!.id,
connectionId: dedicated!.id,
targetType: "agent",
targetId: agent!.id,
},
]);
const [profile] = await db.insert(toolProfiles).values({
companyId: company!.id,
profileKey: `github-identities:${agent!.id}`,
name: "GitHub identities",
defaultAction: "deny",
}).returning();
await db.insert(toolProfileEntries).values([personal!, dedicated!].map((connection) => ({
companyId: company!.id,
profileId: profile!.id,
selectorType: "connection" as const,
effect: "include" as const,
applicationId: application!.id,
connectionId: connection.id,
})));
await db.insert(toolProfileBindings).values({
companyId: company!.id,
profileId: profile!.id,
targetType: "agent",
targetId: agent!.id,
});
const [run] = await db.insert(heartbeatRuns).values({
companyId: company!.id,
agentId: agent!.id,
status: "running",
responsibleUserId: "responsible-user",
contextSnapshot: {},
}).returning();
const servers = await buildPaperclipRuntimeMcpServers({ db, agent: agent!, runId: run!.id });
expect(servers).toHaveLength(1);
const [runtimeGateway] = await db.select().from(toolMcpGateways);
expect(runtimeGateway).toBeTruthy();
const runtimeEntries = await db.select().from(toolProfileEntries)
.where(eq(toolProfileEntries.profileId, runtimeGateway!.profileId!));
expect(runtimeEntries.map((entry) => entry.connectionId)).toEqual([dedicated!.id]);
});
it("audits permitted remote MCP connections that were not installed when delivery is empty", async () => {
const [company] = await db.insert(companies).values({
name: `Runtime MCP diagnostic ${randomUUID()}`,

View File

@ -270,7 +270,12 @@ describeEmbeddedPostgres.sequential("merged pull-request confirmation sweep", ()
resolvePullRequestState,
});
await expect(service.sweepMergedPullRequestConfirmations()).resolves.toEqual({
await expect(service.sweepMergedPullRequestConfirmations([{
companyId,
owner: "paperclipai",
repo: "paperclip",
number: 39,
}])).resolves.toEqual({
checked: 6,
candidates: 3,
accepted: 2,
@ -299,7 +304,9 @@ describeEmbeddedPostgres.sequential("merged pull-request confirmation sweep", ()
requestedByActorId: "system:pr-merged",
idempotencyKey: `interaction:${interactionIds.boardOrAgents}:accepted`,
}));
expect(resolvePullRequestState).toHaveBeenCalledTimes(2);
// The webhook hint resolves #39 immediately; only the other referenced PR
// needs the periodic provider resolver.
expect(resolvePullRequestState).toHaveBeenCalledTimes(1);
const audit = await db.select().from(activityLog);
expect(audit).toEqual(expect.arrayContaining([

View File

@ -273,6 +273,15 @@ vi.mock("../services/index.js", () => ({
executionWorkspaceService: executionWorkspaceServiceFactoryMock,
externalObjectService: externalObjectsServiceFactoryMock,
heartbeatService: heartbeatServiceFactoryMock,
githubConnectionEventService: vi.fn(() => ({
pollOnce: vi.fn(async () => ({
leased: 0,
processed: 0,
duplicate: 0,
ignored: 0,
failed: 0,
})),
})),
issueThreadInteractionService: issueThreadInteractionServiceFactoryMock,
issueService: vi.fn(() => ({ update: vi.fn(async () => null) })),
instanceSettingsService: vi.fn(() => ({
@ -315,6 +324,12 @@ vi.mock("../services/index.js", () => ({
needsAttention: 0,
failed: 0,
})),
sweepGitHubConnectionContinuity: vi.fn(async () => ({
checked: 0,
due: 0,
refreshed: 0,
failed: 0,
})),
})),
}));

View File

@ -42,7 +42,9 @@ import {
import { and, eq, inArray, sql } from "drizzle-orm";
import {
APP_STORE_HIDDEN_SLUGS,
GITHUB_CONNECTOR_PROFILES,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
getAvailableConnectionMethod,
getConnectableAppDefinition,
type GoogleWorkspaceConnectorProfileId,
} from "@paperclipai/shared";
@ -126,6 +128,38 @@ function fakeGmailConnector(companyId: string, userId: string): PaperclipCloudCo
return fakeGoogleWorkspaceConnector(companyId, userId);
}
function fakeGitHubConnector(companyId: string, subject: string): PaperclipCloudConnector {
const credentials = {
v: 1 as const,
accessToken: "ghu_non_expiring_access_token",
refreshToken: null,
tokenType: "Bearer",
accessTokenExpiresAt: null,
refreshTokenExpiresAt: null,
scopes: [...GITHUB_CONNECTOR_PROFILES["github.code"].scopes],
subject,
companyId,
instanceId: "test-instance",
environment: "development" as const,
provider: "github" as const,
profile: "github.code" as const,
appSlug: "paperclip-development",
};
return {
getCapabilities: vi.fn(async () => ["github.code" as const]),
startAuthorization: vi.fn(async ({ returnState }) => ({
authorizationUrl: `https://github.com/login/oauth/authorize?state=${encodeURIComponent(returnState)}`,
expiresAt: new Date(Date.now() + 600_000).toISOString(),
})),
claim: vi.fn(async () => credentials),
refresh: vi.fn(async () => credentials),
revoke: vi.fn(async () => undefined),
setWebhookBinding: vi.fn(async () => undefined),
leaseEvents: vi.fn(async () => null),
acknowledgeEvents: vi.fn(async () => 0),
};
}
function createToolGatewayService(
db: ReturnType<typeof createDb>,
options: NonNullable<Parameters<typeof createToolGatewayServiceBase>[1]> = {},
@ -278,8 +312,13 @@ async function withGalleryServerUrl<T>(
slug: string,
serverUrl: string,
operation: () => Promise<T>,
methodKey?: string,
): Promise<T> {
const method = getConnectableAppDefinition(slug)?.methods[0];
const definition = getConnectableAppDefinition(slug);
const methods = definition?.methods ?? [];
const method = methodKey
? methods.find((candidate) => candidate.key === methodKey)
: definition ? getAvailableConnectionMethod(definition, null) : undefined;
if (!method?.defaults) throw new Error(`Missing gallery method defaults for ${slug}`);
const originalServerUrl = method.defaults.serverUrl;
method.defaults.serverUrl = serverUrl;
@ -1241,6 +1280,31 @@ describeEmbeddedPostgres("tool access service", () => {
]));
});
it("prevents an unrelated member from health-checking a per-user connection", async () => {
const company = await createCompany(db);
const { connection } = await createBrokerConnection(db, company.id);
await db.update(toolConnections).set({
credentialPolicy: "per_user",
createdByUserId: "alice",
}).where(eq(toolConnections.id, connection.id));
await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: "alice",
status: "active",
isDefault: false,
});
const response = await request(createRouteApp(
db,
boardSessionActor(company.id, "member", "mallory"),
)).post(`/api/tool-connections/${connection.id}/health-check`);
expect(response.status).toBe(403);
expect(response.body.error).toContain("need access to this connection");
});
it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@ -1323,7 +1387,7 @@ describeEmbeddedPostgres("tool access service", () => {
.toHaveLength(0);
});
it("fails autonomous token minting closed until the named agent has a standing delegation", async () => {
it("uses the responsible user's personal grant for autonomous token minting", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
@ -1336,26 +1400,29 @@ describeEmbeddedPostgres("tool access service", () => {
connectionId: connection.id,
kind: "user",
subjectUserId: "user-for-run",
credentialSecretRefs: connection.credentialSecretRefs,
status: "active",
isDefault: false,
}).returning().then((rows) => rows[0]!);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 201,
json: async () => ({
token: "responsible-user-child-token",
expires_in: 600,
scope: "pages:publish:ns/dotta",
}),
} as Response);
const denied = await request(app)
const allowed = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({});
expect(denied.status).toBe(409);
expect(denied.body).toMatchObject({
code: "standing_delegation_required",
grantId: grant.id,
remediation: { action: "delegate_personal_grant", grantId: grant.id, agentId: agent.id },
});
.send({ scope: "pages:publish:ns/dotta" });
expect(allowed.status).toBe(200);
expect(allowed.body).toMatchObject({ token: "responsible-user-child-token" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
.toEqual([expect.objectContaining({
status: "pending",
addresseeUserId: "user-for-run",
idempotencyKey: `connection-delegation:${connection.id}:user-for-run:${agent.id}`,
})]);
.toEqual([]);
await db.update(companyMemberships).set({ status: "suspended" }).where(and(
eq(companyMemberships.companyId, company.id),
@ -1366,6 +1433,7 @@ describeEmbeddedPostgres("tool access service", () => {
.send({});
expect(inactiveOwner.status).toBe(403);
expect(inactiveOwner.body.error).toContain("no longer authorized");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("enforces organization grant audiences at token mint time", async () => {
@ -3694,9 +3762,10 @@ describeEmbeddedPostgres("tool access service", () => {
"google-chat",
"google-people",
"google-workspace-search",
"github",
]),
);
expect(res.body.apps).toHaveLength(35);
expect(res.body.apps).toHaveLength(36);
expect(res.body.apps.find((app: { slug: string }) => app.slug === "gmail").ownershipAvailability).toEqual({
platform_shared: false,
platform_provisioned: false,
@ -5034,6 +5103,134 @@ describeEmbeddedPostgres("tool access service", () => {
}
}, 15_000);
it("binds a non-expiring managed GitHub identity and installation to one agent", async () => {
const company = await createCompany(db);
const userId = `github-manager-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, [], "owner");
const agent = await createAgent(db, company.id);
const connector = fakeGitHubConnector(company.id, `agent:${agent.id}`);
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
const actor = { actorType: "user" as const, actorId: userId };
const githubDefinition = getConnectableAppDefinition("github")!;
const previousOwnershipAvailability = githubDefinition.ownershipAvailability;
githubDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true };
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://api.github.com/user") {
return mcpHttpResponse({ id: 42, login: "octocat", avatar_url: "https://avatars.example/octocat" });
}
if (href.includes("https://api.github.com/user/installations?")) {
return mcpHttpResponse({ installations: [{
id: 101,
repository_selection: "selected",
html_url: "https://github.com/settings/installations/101",
account: { login: "paperclipai" },
}] });
}
if (href.includes("https://api.github.com/user/installations/101/repositories?")) {
return mcpHttpResponse({ total_count: 3, repositories: [{ full_name: "paperclipai/do-not-store" }] });
}
if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) {
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "get_pull_request", annotations: { readOnlyHint: true } }] },
});
}
throw new Error(`unexpected fetch ${href}`);
});
try {
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "github",
connectionMethodKey: "managed",
grantKind: "agent",
subjectAgentId: agent.id,
name: "Agent GitHub",
}, actor);
expect(connected.connection.credentialPolicy).toBe("per_agent");
const started = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback",
actor,
subjectAgentId: agent.id,
});
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
await db.update(companyMemberships).set({ membershipRole: "operator" }).where(and(
eq(companyMemberships.companyId, company.id),
eq(companyMemberships.principalId, userId),
));
await expect(service.completePaperclipCloudConnectorCallback({
state,
claimId: "github-agent-claim",
actor,
})).rejects.toMatchObject({ status: 403 });
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: userId,
permissionKey: "tools:manage_connections",
scope: null,
grantedByUserId: "owner",
});
const completed = await service.completePaperclipCloudConnectorCallback({
state,
claimId: "github-agent-claim",
actor,
});
expect(completed.connection).toMatchObject({
credentialPolicy: "per_agent",
status: "active",
enabled: true,
});
const [grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.connectionId, connected.connectionId),
eq(connectionGrants.kind, "agent"),
eq(connectionGrants.subjectAgentId, agent.id),
));
expect(grant).toMatchObject({
status: "active",
subjectUserId: null,
isDefault: false,
providerTenant: {
name: "octocat",
oauth: {
strategy: "paperclip_cloud_connector",
accessTokenExpiresAt: null,
},
github: {
userId: "42",
login: "octocat",
installationCount: 1,
repositoryCount: 3,
repositorySelection: "selected",
installationIds: ["101"],
installationUrl: "https://github.com/apps/paperclip-development/installations/new",
managementUrl: "https://github.com/settings/installations/101",
appSlug: "paperclip-development",
},
},
});
expect(grant!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(["oauth.access_token"]);
expect(JSON.stringify(grant)).not.toContain("do-not-store");
expect(connector.setWebhookBinding).toHaveBeenCalledWith(expect.objectContaining({
subject: `agent:${agent.id}`,
companyId: company.id,
connectionId: connected.connectionId,
grantId: grant!.id,
installationId: "101",
active: true,
}));
await expect(db.select().from(toolConnectionInstalls).where(and(
eq(toolConnectionInstalls.connectionId, connected.connectionId),
eq(toolConnectionInstalls.targetType, "agent"),
eq(toolConnectionInstalls.targetId, agent.id),
))).resolves.toHaveLength(1);
} finally {
githubDefinition.ownershipAvailability = previousOwnershipAvailability;
}
});
it("routes a managed Drive callback into the personal vault, filtered catalog, and provider-specific activity", async () => {
const company = await createCompany(db);
const userId = `drive-member-${randomUUID()}`;
@ -9394,9 +9591,10 @@ describeEmbeddedPostgres("tool access service", () => {
const connect = await withGalleryServerUrl("github", PUBLIC_MCP_FIXTURE_URL, () =>
service.connectGalleryApp(company.id, {
galleryKey: "github",
connectionMethodKey: "mcp-key",
name: "GitHub workspace",
credentialValues: { "credentials.authorization": "zap-secret" },
}, { actorType: "user", actorId: "board" }));
}, { actorType: "user", actorId: "board" }), "mcp-key");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(

View File

@ -1297,6 +1297,95 @@ describeEmbeddedPostgres("tool gateway service", () => {
expect(vi.mocked(oauthGrantRefresher).mock.calls[1]?.[0]).toMatchObject({ forceRefresh: true });
});
it("marks a managed OAuth grant reconnect-required after one rejected refresh retry", async () => {
const { company, agent, run } = await createRunFixture(db);
const { connection } = await createRemoteMcpToolFixture(db, company.id);
const accessSecret = await secretService(db).create(company.id, {
provider: "local_encrypted",
name: "Managed OAuth access token",
key: `gateway.managed-oauth.${randomUUID()}`,
value: "stale-managed-token",
});
await db.insert(companySecretBindings).values({
companyId: company.id,
secretId: accessSecret.id,
targetType: "tool_connection",
targetId: connection.id,
configPath: "oauth.access_token",
});
await db.update(toolConnections).set({
authKind: "oauth",
credentialSource: "paperclip_vault",
config: {
url: "https://example.invalid/mcp",
oauth: {
strategy: "paperclip_cloud_connector",
connectorProfile: "github.code",
connectorSubjectUserId: "responsible-user",
},
},
}).where(eq(toolConnections.id, connection.id));
const [grant] = await db.select().from(connectionGrants).where(eq(
connectionGrants.connectionId,
connection.id,
));
await db.update(connectionGrants).set({
credentialSecretRefs: [{
secretId: accessSecret.id,
versionSelector: "latest",
configPath: "oauth.access_token",
required: true,
label: "OAuth access token",
}],
providerTenant: {
oauth: {
strategy: "paperclip_cloud_connector",
accessTokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
},
},
}).where(eq(connectionGrants.id, grant.id));
await db.insert(toolPolicies).values({
companyId: company.id,
name: "Allow managed OAuth reads",
policyType: "allow",
selectors: { riskLevel: "read" },
});
const oauthGrantRefresher: NonNullable<ToolGatewayServiceOptions["oauthGrantRefresher"]> = vi.fn(async (input) => {
if (input.forceRefresh) {
await secretService(db).rotate(accessSecret.id, { value: "fresh-managed-token" });
}
return db.select().from(connectionGrants).where(eq(connectionGrants.id, input.grantId))
.then((rows) => rows[0]!);
});
const authorizationHeaders: string[] = [];
const gateway = createTestToolGatewayService(db, {
oauthGrantRefresher,
remoteHttpRequest: async (_url, init) => {
authorizationHeaders.push(new Headers(init.headers).get("authorization") ?? "");
return new Response(null, { status: 401 });
},
});
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token))
.find((candidate) => candidate.providerType === "mcp_remote_http");
await expect(gateway.executeTool({
sessionToken: session.token,
tool: tool!.name,
parameters: {},
})).rejects.toMatchObject({ reasonCode: "mcp_remote_status" });
expect(authorizationHeaders).toEqual([
"Bearer stale-managed-token",
"Bearer fresh-managed-token",
]);
expect(oauthGrantRefresher).toHaveBeenCalledTimes(2);
expect(vi.mocked(oauthGrantRefresher).mock.calls[1]?.[0]).toMatchObject({ forceRefresh: true });
const [storedGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id));
expect(storedGrant?.status).toBe("needs_reauthorization");
});
it("fails clearly when remote MCP elicitation has no issue interaction path", async () => {
const company = await db.insert(companies).values({
name: `Gateway ${randomUUID()}`,

View File

@ -731,6 +731,104 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
}
});
it("keeps additive app-gallery assignments out of gateway-only runtimes", async () => {
const company = await createCompany(db);
const assigned = await createRemoteMcpTool(db, company.id, {
applicationKey: "gateway-assigned-app",
connectionName: "Dedicated GitHub identity",
toolName: "get_me",
riskLevel: "read",
});
const unassigned = await createRemoteMcpTool(db, company.id, {
applicationKey: "gateway-unassigned-app",
connectionName: "Personal GitHub identity",
toolName: "get_me",
riskLevel: "read",
});
const assignedToolName = expectedConnectedToolName({
applicationKey: assigned.application.applicationKey,
connectionId: assigned.connection.id,
toolName: assigned.catalogEntry.toolName,
});
const unassignedToolName = expectedConnectedToolName({
applicationKey: unassigned.application.applicationKey,
connectionId: unassigned.connection.id,
toolName: unassigned.catalogEntry.toolName,
});
const [gatewayProfile] = await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `runtime-gateway-${randomUUID()}`,
name: "Resolved runtime identity",
defaultAction: "deny",
}).returning();
await db.insert(toolProfileEntries).values({
companyId: company.id,
profileId: gatewayProfile.id,
selectorType: "connection",
effect: "include",
connectionId: assigned.connection.id,
});
const [appProfile] = await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `app:${unassigned.connection.id}`,
name: "Personal GitHub",
defaultAction: "deny",
metadata: { source: "app_gallery_finish", connectionId: unassigned.connection.id },
}).returning();
await db.insert(toolProfileEntries).values({
companyId: company.id,
profileId: appProfile.id,
selectorType: "connection",
effect: "include",
connectionId: unassigned.connection.id,
});
await db.insert(toolProfileBindings).values({
companyId: company.id,
profileId: appProfile.id,
targetType: "company",
targetId: company.id,
priority: 100,
metadata: { source: "app_gallery_finish" },
});
const gateway = createTestToolGatewayService(db);
const created = await gateway.createNamedGateway({
companyId: company.id,
body: {
name: "Resolved runtime GitHub",
profileId: gatewayProfile.id,
defaultProfileMode: "gateway_only",
},
});
const token = await gateway.createNamedGatewayToken({
companyId: company.id,
gatewayId: created.id,
body: { name: "Runtime token" },
});
const app = createGatewayRouteApp(db, gateway);
const listed = await request(app)
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
.set("authorization", `Bearer ${token.token}`)
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
.expect(200);
const visibleToolNames = listed.body.result.tools.map((tool: { name: string }) => tool.name);
expect(visibleToolNames).toContain(assignedToolName);
expect(visibleToolNames).not.toContain(unassignedToolName);
const denied = await request(app)
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
.set("authorization", `Bearer ${token.token}`)
.send({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: unassignedToolName, arguments: {} },
})
.expect(403);
expect(denied.body.error.data.reasonCode).toBe("deny_default");
});
it("proxies namespaced resources and prompts only for fully assigned MCP connections", async () => {
const company = await createCompany(db);
const remote = await startFakeRemoteMcpServer(async ({ body }) => {
@ -2290,7 +2388,7 @@ rl.on("line", (line) => {
}
});
it("requires an explicit named-agent delegation for autonomous personal-identity runs", async () => {
it("uses the responsible user's identity directly and requires delegation only without one", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
@ -2328,14 +2426,21 @@ rl.on("line", (line) => {
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.providerType === "mcp_remote_http")!;
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
.rejects.toMatchObject({ status: 409, reasonCode: "standing_delegation_required" });
expect(fake.requests).toHaveLength(0);
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
expect(fake.requests).toHaveLength(1);
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
.toEqual([expect.objectContaining({
status: "pending",
addresseeUserId: "alice",
idempotencyKey: `connection-delegation:${connection.id}:alice:${agent.id}`,
})]);
.toEqual([]);
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id));
const unattendedSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const unattendedTool = (await gateway.listToolsForSession(unattendedSession.token))
.find((item) => item.providerType === "mcp_remote_http")!;
await expect(gateway.executeTool({
sessionToken: unattendedSession.token,
tool: unattendedTool.name,
parameters: {},
})).rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
expect(fake.requests).toHaveLength(1);
await db.insert(connectionGrantDelegations).values({
companyId: company.id,
@ -2343,17 +2448,25 @@ rl.on("line", (line) => {
agentId: agent.id,
createdByUserId: "alice",
});
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
await expect(gateway.executeTool({
sessionToken: unattendedSession.token,
tool: unattendedTool.name,
parameters: {},
}))
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
expect(fake.requests).toHaveLength(1);
expect(fake.requests).toHaveLength(2);
await db.update(companyMemberships).set({ status: "suspended" }).where(and(
eq(companyMemberships.companyId, company.id),
eq(companyMemberships.principalId, "alice"),
));
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
await expect(gateway.executeTool({
sessionToken: unattendedSession.token,
tool: unattendedTool.name,
parameters: {},
}))
.rejects.toMatchObject({ status: 403, reasonCode: "grant_owner_membership_inactive" });
expect(fake.requests).toHaveLength(1);
expect(fake.requests).toHaveLength(2);
} finally {
await fake.close();
}

View File

@ -64,6 +64,7 @@ import {
executionWorkspaceService,
heartbeatService,
issueThreadInteractionService,
githubConnectionEventService,
issueService,
instanceSettingsService,
reconcileBuiltInAgentsOnStartup,
@ -1156,6 +1157,40 @@ export async function startServer(): Promise<StartedServer> {
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(runEnvironmentLeaseCleanupSweep(ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS));
};
const githubConnectionEvents = githubConnectionEventService(db as any, {
wakeup: environmentLeaseCleanupHeartbeat.wakeup,
});
const tools = toolAccessService(db as any, {
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
trustedLocalStdioRuntimeHost: process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
?? null,
});
const scheduleGitHubConnectionEventPoll = () => {
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(githubConnectionEvents.pollOnce()
.then((result) => {
if (result.leased > 0 || result.failed > 0) {
logger.info(result, "GitHub connection event poll completed");
}
})
.catch((err) => {
logger.error({ err }, "GitHub connection event poll failed");
}));
};
const scheduleGitHubConnectionContinuitySweep = () => {
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(tools.sweepGitHubConnectionContinuity()
.then((result) => {
if (result.due > 0 || result.failed > 0) {
logger.info(result, "GitHub connection continuity sweep completed");
}
})
.catch((err) => {
logger.error({ err }, "GitHub connection continuity sweep failed");
}));
};
await questionResponseDeliveries.sweepPending().then((result) => {
if (result.scanned > 0) {
@ -1164,6 +1199,8 @@ export async function startServer(): Promise<StartedServer> {
}).catch((err) => {
logger.error({ err }, "startup question-response delivery sweep failed");
});
scheduleGitHubConnectionEventPoll();
scheduleGitHubConnectionContinuitySweep();
if (heartbeat) {
const secretProposals = createSecretProposalsService(db as any);
@ -1292,13 +1329,6 @@ export async function startServer(): Promise<StartedServer> {
}));
};
const tools = toolAccessService(db as any, {
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
trustedLocalStdioRuntimeHost: process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
?? null,
});
const worktreeRunExecutionActivation = await resolveWorktreeRunExecutionActivationState({
getExperimental: () => instanceSettingsService(db).getExperimental(),
});
@ -1526,6 +1556,8 @@ export async function startServer(): Promise<StartedServer> {
if (heartbeatSchedulerStopped) return;
scheduleMergedPullRequestConfirmationSweep();
scheduleGitHubConnectionEventPoll();
scheduleGitHubConnectionContinuitySweep();
scheduleTerminalWorkspaceSweep();
scheduleAdapterLoginReaperSweep();
scheduleSetupTokenReaperSweep();
@ -1685,6 +1717,8 @@ export async function startServer(): Promise<StartedServer> {
startHeartbeatSchedulerInterval(() => {
scheduleExternalObjectRefreshSweep(new Date());
scheduleEnvironmentLeaseCleanupSweep();
scheduleGitHubConnectionEventPoll();
scheduleGitHubConnectionContinuitySweep();
});
}

View File

@ -5,7 +5,9 @@ import { and, eq, or } from "drizzle-orm";
import {
APP_STORE_DEFINITIONS,
DEFAULT_OWNERSHIP_AVAILABILITY,
GITHUB_CONNECTOR_PROFILES,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
isGitHubConnectorProfileId,
isGoogleWorkspaceConnectorProfileId,
TOOL_ACTION_REQUEST_STATUSES,
type DeploymentExposure,
@ -453,10 +455,30 @@ function connectorEnrollmentPrincipal(req: Request): string {
connection: ToolConnection,
outcome: "failed" | "denied",
code?: string | null,
providerRecovery?: { installationUrl?: unknown; managementUrl?: unknown },
) {
const detailPermissionsPath = await oauthAppPath(connection.companyId, connection.id);
const params = new URLSearchParams({ oauth: outcome });
if (code) params.set("code", code);
const addGitHubRecoveryUrls = (target: URLSearchParams) => {
if (code !== "github_installation_required") return;
for (const [key, value] of [
["installation_url", providerRecovery?.installationUrl],
["management_url", providerRecovery?.managementUrl],
] as const) {
if (typeof value !== "string") continue;
try {
const url = new URL(value);
if (url.protocol === "https:" && url.hostname.toLowerCase() === "github.com") {
target.set(key, url.toString());
}
} catch {
// Provider recovery links are optional. The retry path remains usable
// when an upstream response omits or malforms one.
}
}
};
addGitHubRecoveryUrls(params);
const source = connection.config?.sourceTemplateKey
?? connection.transportConfig?.sourceTemplateKey;
if (connection.status !== "draft" || typeof source !== "string" || !source.trim()) {
@ -471,6 +493,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
oauth: outcome,
});
if (code) setupParams.set("code", code);
addGitHubRecoveryUrls(setupParams);
const setupRoute = connection.credentialSource === "vercel_connect"
? "/apps/vercel-connect"
: "/apps/connect";
@ -774,7 +797,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
: options.paperclipCloudConnector
? await options.paperclipCloudConnector.getCapabilities()
: [];
const googleConnectorProfiles = new Set(advertisedProfiles);
const connectorProfiles = new Set<string>(advertisedProfiles);
const vercelConnect = vercelConnectIntegrationStatus();
res.json({
capabilities: await describeConnectionCreateCapabilities(req, companyId),
@ -794,7 +817,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
apps: APP_STORE_DEFINITIONS.map((app) => {
const methods = app.methods.filter((method) =>
!isPaperclipCloudConnectorStrategy(method.oauthStrategy)
|| Boolean(method.connectorProfile && googleConnectorProfiles.has(method.connectorProfile as never))
|| Boolean(method.connectorProfile && connectorProfiles.has(method.connectorProfile))
);
return {
...app,
@ -843,14 +866,22 @@ function connectorEnrollmentPrincipal(req: Request): string {
? await svc.getConnection(req.body.resumeConnectionId, companyId)
: null;
const effectiveGrantKind = resumedConnection
? resumedConnection.credentialPolicy === "per_user" ? "user" : "organization"
? resumedConnection.credentialPolicy === "per_user"
? "user"
: resumedConnection.credentialPolicy === "per_agent"
? "agent"
: "organization"
: req.body.grantKind ?? "organization";
// Personal connection creation remains available to ordinary active
// members, but sharing a credential with every human is a manager
// operation and must be enforced here, not inferred by the client.
const createsOrganizationGrant = effectiveGrantKind === "organization";
if (createsOrganizationGrant && !await isToolConnectionManagerQuiet(req, companyId)) {
throw forbidden(ORGANIZATION_GRANT_DENIAL_REASON);
const createsManagedGrant = effectiveGrantKind === "organization" || effectiveGrantKind === "agent";
if (createsManagedGrant && !await isToolConnectionManagerQuiet(req, companyId)) {
throw forbidden(
effectiveGrantKind === "agent"
? "Only connection managers can authorize a dedicated agent identity"
: ORGANIZATION_GRANT_DENIAL_REASON,
);
}
try {
const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req));
@ -862,10 +893,12 @@ function connectorEnrollmentPrincipal(req: Request): string {
// (PAP-17835). The service refuses any subject other than the actor,
// so this cannot start consent on someone else's behalf.
const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null;
const dedicatedSubjectAgentId = req.body.grantKind === "agent" ? req.body.subjectAgentId ?? null : null;
const start = await svc.startOAuth(companyId, result.connectionId, {
redirectUri: oauthRedirectUri(req),
actor: getActorInfo(req),
...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}),
...(dedicatedSubjectAgentId ? { subjectAgentId: dedicatedSubjectAgentId } : {}),
...(req.body.interactionId ? { interactionId: req.body.interactionId } : {}),
});
result.auth.startUrl = start.authorizationUrl;
@ -934,6 +967,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
const subjectUserId = req.body?.asCurrentUser === true ? req.actor.userId ?? null : null;
const subjectAgentId = req.body?.asAgentId ?? null;
if (req.body?.asCurrentUser === true && !subjectUserId) {
throw forbidden("Connecting an app as yourself requires a signed-in user");
}
@ -951,6 +985,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
actor: getActorInfo(req),
returnTo: oauthBrowserOrigin(req) ?? undefined,
...(subjectUserId ? { subjectUserId } : {}),
...(subjectAgentId ? { subjectAgentId } : {}),
...(req.body?.interactionId ? { interactionId: req.body.interactionId } : {}),
});
res.json(result);
@ -1068,11 +1103,15 @@ function connectorEnrollmentPrincipal(req: Request): string {
const connectorProfileValue = typeof oauthConfig?.connectorProfile === "string"
? oauthConfig.connectorProfile
: null;
const connectorProfile = connectorProfileValue && isGoogleWorkspaceConnectorProfileId(connectorProfileValue)
const connectorProfile = connectorProfileValue && (
isGoogleWorkspaceConnectorProfileId(connectorProfileValue) || isGitHubConnectorProfileId(connectorProfileValue)
)
? connectorProfileValue
: null;
const connectorDefinition = connectorProfile
? GOOGLE_WORKSPACE_CONNECTOR_PROFILES[connectorProfile]
? isGitHubConnectorProfileId(connectorProfile)
? GITHUB_CONNECTOR_PROFILES[connectorProfile]
: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[connectorProfile]
: null;
await logActivity(db, {
companyId: result.connection.companyId,
@ -1084,7 +1123,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
details: {
applicationId: result.application.id,
catalogEntryCount: result.catalog.length,
provider: connectorDefinition?.appSlug ?? "google",
provider: connectorDefinition?.appSlug ?? "managed",
...(connectorDefinition ? { profile: connectorProfile } : {}),
},
});
@ -1138,6 +1177,10 @@ function connectorEnrollmentPrincipal(req: Request): string {
pendingConnection,
outcome,
typeof details?.code === "string" ? details.code : null,
{
installationUrl: details?.installationUrl,
managementUrl: details?.managementUrl,
},
));
}
};
@ -2104,7 +2147,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
router.post("/tool-connections/:connectionId/health-check", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
if (existing.credentialPolicy === "per_user") await assertToolConnectionAccess(req, existing);
else await assertToolConnectionConfigureAccess(req, existing);
res.json(await svc.checkHealth(existing.id, getActorInfo(req)));
});

View File

@ -25,6 +25,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { toolAccessService } from "./tool-access.js";
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
@ -201,20 +202,47 @@ export function connectionIntentService(db: Db) {
&& connection.enabled
);
if (matching.length === 0) return null;
if (input.serviceSlug === "github") {
const selection = await resolveManagedGitHubIdentitySelection(db, input.companyId, {
agentId: input.agentId,
responsibleUserId: input.responsibleUserId,
});
return selection.grant
? matching.find((connection) => connection.id === selection.grant!.connectionId) ?? null
: null;
}
const effective = await access.getEffectiveProfilesForAgent(input.companyId, input.agentId);
const installedIds = new Set(effective.installedConnections.map((connection) => connection.id));
for (const connection of matching) {
if (!installedIds.has(connection.id)) continue;
const { grants } = await access.listConnectionGrants(connection.id, input.companyId);
const usable = grants.some((grant) => {
if (grant.status !== "active") return false;
if (grant.kind === "organization") return true;
return grant.subjectUserId === input.responsibleUserId
&& grant.delegations.some((delegation) => delegation.agentId === input.agentId);
});
if (usable) return connection;
const installed = matching.filter((connection) => installedIds.has(connection.id));
const grantsByConnection = await Promise.all(installed.map(async (connection) => ({
connection,
grants: (await access.listConnectionGrants(connection.id, input.companyId)).grants,
})));
// Keep readiness aligned with runtime identity resolution. A dedicated
// agent identity wins over the responsible person's personal identity,
// while an inactive or ambiguous higher-priority identity fails closed.
const dedicated = grantsByConnection.flatMap(({ connection, grants }) => grants
.filter((grant) => grant.kind === "agent" && grant.subjectAgentId === input.agentId)
.map((grant) => ({ connection, grant })));
if (dedicated.length > 0) {
const active = dedicated.filter(({ grant }) => grant.status === "active");
return active.length === 1 ? active[0]!.connection : null;
}
return null;
const personal = grantsByConnection.flatMap(({ connection, grants }) => grants
.filter((grant) => grant.kind === "user" && grant.subjectUserId === input.responsibleUserId)
.map((grant) => ({ connection, grant })));
if (personal.length > 0) {
const active = personal.filter(({ grant }) => grant.status === "active");
return active.length === 1 ? active[0]!.connection : null;
}
const organization = grantsByConnection.flatMap(({ connection, grants }) => grants
.filter((grant) => grant.kind === "organization")
.map((grant) => ({ connection, grant })));
const activeOrganization = organization.filter(({ grant }) => grant.status === "active");
return activeOrganization.length === 1 ? activeOrganization[0]!.connection : null;
}
async function search(claims: RuntimeToolsTokenClaims, query: string): Promise<ConnectionsSearchResult> {

View File

@ -1,6 +1,16 @@
import type { Db } from "@paperclipai/db";
import {
companySecrets,
companyMemberships,
connectionGrantDelegations,
connectionGrants,
toolConnectionInstalls,
toolConnections,
type Db,
} from "@paperclipai/db";
import { and, eq, inArray, or } from "drizzle-orm";
import { isGitHubDotCom } from "./github-fetch.js";
import { secretService } from "./secrets.js";
import { toolAccessService } from "./tool-access.js";
/**
* Server-side git credentials for managed project checkouts and execution-workspace base
@ -34,9 +44,10 @@ const GIT_CREDENTIAL_HELPER =
export type GitCredential = {
token: string;
source: "company_secret" | "server_env";
source: "managed_connection" | "company_secret" | "server_env";
/** The company-secret name the token came from; null for a server-environment token. */
secretName: string | null;
githubIdentity?: { userId: string; login: string };
};
/** A prepared, credential-bearing git invocation: config args plus the env that carries the token. */
@ -71,6 +82,17 @@ export function isGitHubHttpsRemoteUrl(remoteUrl: string): boolean {
return isGitHubDotCom(parsed.hostname);
}
function isSupportedGitHubRemoteUrl(remoteUrl: string): boolean {
if (isGitHubHttpsRemoteUrl(remoteUrl)) return true;
if (/^git@(?:www\.)?github\.com:[^\s]+$/i.test(remoteUrl)) return true;
try {
const parsed = new URL(remoteUrl);
return parsed.protocol === "ssh:" && parsed.username === "git" && !parsed.password && isGitHubDotCom(parsed.hostname);
} catch {
return false;
}
}
/**
* Mask credential material embedded in URLs so it never reaches warnings, run errors, or
* persisted payloads: userinfo on any scheme (`https://user:token@host`,
@ -86,6 +108,21 @@ export function scrubGitCredentialText(text: string): string {
}
export function buildGitAuthInvocation(credential: GitCredential): GitAuthInvocation {
const identity = credential.githubIdentity;
const noreplyEmail = identity ? `${identity.userId}+${identity.login}@users.noreply.github.com` : null;
const configEntries = [
["credential.helper", ""],
["credential.https://github.com.helper", GIT_CREDENTIAL_HELPER],
["credential.https://www.github.com.helper", GIT_CREDENTIAL_HELPER],
["url.https://github.com/.insteadOf", "git@github.com:"],
["url.https://github.com/.insteadOf", "ssh://git@github.com/"],
["url.https://github.com/.insteadOf", "git@www.github.com:"],
["url.https://github.com/.insteadOf", "ssh://git@www.github.com/"],
...(identity ? [
["user.name", identity.login],
["user.email", noreplyEmail!],
] : []),
];
return {
// The leading empty helper clears ambient helpers (gh, osxkeychain, credential-store) so
// they neither outrank the resolved token nor receive store/erase callbacks for it. The
@ -99,7 +136,20 @@ export function buildGitAuthInvocation(credential: GitCredential): GitAuthInvoca
],
env: {
[GIT_CREDENTIAL_TOKEN_ENV_KEY]: credential.token,
GH_TOKEN: credential.token,
GITHUB_TOKEN: credential.token,
GIT_TERMINAL_PROMPT: "0",
...(identity ? {
GIT_AUTHOR_NAME: identity.login,
GIT_AUTHOR_EMAIL: noreplyEmail!,
GIT_COMMITTER_NAME: identity.login,
GIT_COMMITTER_EMAIL: noreplyEmail!,
} : {}),
GIT_CONFIG_COUNT: String(configEntries.length),
...Object.fromEntries(configEntries.flatMap(([key, value], index) => [
[`GIT_CONFIG_KEY_${index}`, key],
[`GIT_CONFIG_VALUE_${index}`, value],
])),
},
source: credential.source,
secretName: credential.secretName,
@ -125,6 +175,8 @@ export function describeGitAuthFailure(input: {
if (input.used) {
const label = input.used.secretName
? `the ${input.used.secretName} company-secret GitHub credential`
: input.used.source === "managed_connection"
? "the resolved GitHub connection"
: "the server-environment GitHub credential";
return `The operation authenticated with ${label}, which was rejected or lacks access to this repository.`;
}
@ -139,14 +191,16 @@ type GitCredentialSecretsDeps = {
name: string,
) => Promise<{ id: string } | null | undefined> | ReturnType<SecretServiceLike["getByName"]>;
resolveSecretValue: SecretServiceLike["resolveSecretValue"];
resolveUserSecretValue?: SecretServiceLike["resolveUserSecretValue"];
};
/**
* Build the credential provider for one run. Resolution order: company secret by well-known
* name, then the server process env (`GITHUB_TOKEN`/`GH_TOKEN`) for self-hosted operators,
* then null. The lookup is memoized per provider instance so one run performs at most one
* secret resolution (and writes at most one audit event) no matter how many git operations
* it authenticates.
* Build the credential provider for one run. Resolution order: the managed GitHub identity
* resolver, then a company secret by well-known name, then the server process environment
* (`GITHUB_TOKEN`/`GH_TOKEN`) for self-hosted operators. A configured managed identity fails
* closed instead of falling through to legacy credentials. The lookup is memoized per
* provider instance so one run performs at most one secret resolution (and writes at most
* one audit event) no matter how many git operations it authenticates.
*/
export function createGitRemoteAuthProvider(
db: Db,
@ -155,6 +209,7 @@ export function createGitRemoteAuthProvider(
issueId?: string | null;
heartbeatRunId?: string | null;
responsibleUserId?: string | null;
agentId?: string | null;
},
deps?: {
secrets?: GitCredentialSecretsDeps;
@ -168,6 +223,16 @@ export function createGitRemoteAuthProvider(
let credentialPromise: Promise<GitCredential | null> | null = null;
const resolveCredential = async (): Promise<GitCredential | null> => {
// Unit callers historically pass a null DB through the typed test seam. Production
// always supplies a real DB and therefore always checks managed identities before
// considering legacy secrets or process environment credentials.
const managed = db
? await resolveManagedGitHubCredential(db, secrets, companyId, context ?? {})
: { configured: false as const };
if (managed.configured) {
if (!managed.credential) throw new Error(managed.error ?? "Managed GitHub connection is unavailable");
return managed.credential;
}
for (const secretName of secretNames) {
const secret = await Promise.resolve(secrets.getByName(companyId, secretName)).catch(() => null);
if (!secret) continue;
@ -194,10 +259,214 @@ export function createGitRemoteAuthProvider(
};
return async (remoteUrl: string) => {
if (!isGitHubHttpsRemoteUrl(remoteUrl)) return null;
if (!isSupportedGitHubRemoteUrl(remoteUrl)) return null;
credentialPromise ??= resolveCredential();
const credential = await credentialPromise;
if (!credential) return null;
return buildGitAuthInvocation(credential);
};
}
export async function resolveManagedGitHubIdentitySelection(
db: Db,
companyId: string,
context: {
responsibleUserId?: string | null;
agentId?: string | null;
},
): Promise<{
configured: boolean;
grant?: typeof connectionGrants.$inferSelect;
error?: string;
}> {
const connections = await db.select().from(toolConnections).where(and(
eq(toolConnections.companyId, companyId),
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
));
const githubConnections = connections.filter((connection) => {
const config = connection.config && typeof connection.config === "object" ? connection.config as Record<string, unknown> : {};
const transportConfig = connection.transportConfig && typeof connection.transportConfig === "object"
? connection.transportConfig as Record<string, unknown>
: {};
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
});
if (githubConnections.length === 0) return { configured: false };
const connectionIds = githubConnections.map((connection) => connection.id);
const installs = await db.select().from(toolConnectionInstalls).where(and(
eq(toolConnectionInstalls.companyId, companyId),
inArray(toolConnectionInstalls.connectionId, connectionIds),
));
const eligibleConnectionIds = new Set(githubConnections.filter((connection) => installs.some((install) =>
install.connectionId === connection.id && (
install.targetType === "company"
|| (install.targetType === "agent" && install.targetId === context.agentId)
)
)).map((connection) => connection.id));
// A GitHub connection installed only for another agent is not configured for
// this run. Treating the company-wide connection as configured here would
// make unrelated agents fail before their adapter starts and would also
// suppress their otherwise-eligible legacy credential fallback.
if (eligibleConnectionIds.size === 0) return { configured: false };
const grants = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, companyId),
inArray(connectionGrants.connectionId, [...eligibleConnectionIds]),
or(eq(connectionGrants.kind, "agent"), eq(connectionGrants.kind, "user")),
));
const dedicated = context.agentId
? grants.filter((grant) => grant.kind === "agent" && grant.subjectAgentId === context.agentId)
: [];
// Connections are already restricted above to the owner-selected install
// targets. Within that consent boundary the server-resolved responsible user
// is authoritative; standing delegation is only an ownerless-run fallback.
const personal = context.responsibleUserId
? grants.filter((grant) => grant.kind === "user" && grant.subjectUserId === context.responsibleUserId)
: [];
const delegated = !context.responsibleUserId && context.agentId
? await db.select({ grantId: connectionGrantDelegations.grantId }).from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, companyId),
eq(connectionGrantDelegations.agentId, context.agentId),
inArray(connectionGrantDelegations.grantId, grants.map((grant) => grant.id)),
)).then((rows) => {
const delegatedIds = new Set(rows.map((row) => row.grantId));
return grants.filter((grant) => grant.kind === "user" && delegatedIds.has(grant.id));
})
: [];
const candidates = dedicated.length > 0 ? dedicated : personal.length > 0 ? personal : delegated;
if (candidates.length !== 1) {
return {
configured: true,
error: candidates.length === 0
? "No managed GitHub identity is available for this run"
: "More than one managed GitHub identity matches this run",
};
}
const grant = candidates[0]!;
if (grant.status !== "active") return { configured: true, error: "The managed GitHub identity must be reconnected" };
return { configured: true, grant };
}
export async function filterResolvedGitHubConnectionsForRun<T extends {
id: string;
config?: unknown;
transportConfig?: unknown;
}>(input: {
db: Db;
companyId: string;
agentId: string;
responsibleUserId?: string | null;
connections: T[];
}): Promise<T[]> {
const githubConnections = input.connections.filter((connection) => {
const config = connection.config && typeof connection.config === "object"
? connection.config as Record<string, unknown>
: {};
const transportConfig = connection.transportConfig && typeof connection.transportConfig === "object"
? connection.transportConfig as Record<string, unknown>
: {};
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
});
if (githubConnections.length === 0) return input.connections;
const selection = await resolveManagedGitHubIdentitySelection(input.db, input.companyId, {
agentId: input.agentId,
responsibleUserId: input.responsibleUserId ?? null,
});
const selectedConnectionId = selection.grant?.connectionId ?? null;
const githubIds = new Set(githubConnections.map((connection) => connection.id));
return input.connections.filter((connection) =>
!githubIds.has(connection.id) || connection.id === selectedConnectionId,
);
}
async function resolveManagedGitHubCredential(
db: Db,
secrets: GitCredentialSecretsDeps,
companyId: string,
context: {
issueId?: string | null;
heartbeatRunId?: string | null;
responsibleUserId?: string | null;
agentId?: string | null;
},
): Promise<{ configured: boolean; credential?: GitCredential; error?: string }> {
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
if (!selection.configured) return { configured: false };
if (!selection.grant) return { configured: true, error: selection.error };
let grant = selection.grant;
if (grant.kind === "user" && grant.subjectUserId) {
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, grant.subjectUserId),
eq(companyMemberships.status, "active"),
)).limit(1);
if (!membership) return { configured: true, error: "The managed GitHub identity owner is not an active company member" };
}
const expiresAt = grant.providerTenant?.oauth?.accessTokenExpiresAt;
const refreshedAt = grant.providerTenant?.oauth?.refreshedAt;
const expiryMs = typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
const refreshedMs = typeof refreshedAt === "string" ? Date.parse(refreshedAt) : Number.NaN;
if (Number.isFinite(expiryMs) && (
expiryMs <= Date.now() + 60 * 60_000
|| !Number.isFinite(refreshedMs)
|| refreshedMs <= Date.now() - 30 * 24 * 60 * 60_000
)) {
grant = await toolAccessService(db).refreshOAuthGrantCredentials({
companyId,
connectionId: grant.connectionId,
grantId: grant.id,
actor: { actorType: "system", actorId: "workspace-git-credential" },
issueId: context.issueId,
heartbeatRunId: context.heartbeatRunId,
});
}
const accessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
const github = grant.providerTenant?.github;
if (!accessRef || !github) return { configured: true, error: "The managed GitHub identity is incomplete" };
if (github.installationCount < 1 || github.repositoryCount < 1) {
return { configured: true, error: "The managed GitHub identity no longer has repository access" };
}
const accessContext = {
consumerType: "system" as const,
consumerId: "workspace-git-credential",
actorType: "system" as const,
actorId: context.agentId ?? undefined,
issueId: context.issueId ?? null,
heartbeatRunId: context.heartbeatRunId ?? null,
responsibleUserId: context.responsibleUserId ?? null,
};
let token: string;
if (grant.kind === "user") {
if (!grant.subjectUserId || !secrets.resolveUserSecretValue) {
return { configured: true, error: "The personal GitHub credential cannot be resolved" };
}
const [secret] = await db.select({
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
}).from(companySecrets).where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.id, accessRef.secretId),
eq(companySecrets.ownerUserId, grant.subjectUserId),
)).limit(1);
if (!secret?.userSecretDefinitionId) return { configured: true, error: "The personal GitHub credential is invalid" };
const resolved = await secrets.resolveUserSecretValue(companyId, {
definitionId: secret.userSecretDefinitionId,
responsibleUserId: grant.subjectUserId,
version: accessRef.versionSelector ?? "latest",
required: true,
}, accessContext);
if (!resolved) return { configured: true, error: "The personal GitHub credential is missing" };
token = resolved.value;
} else {
token = await secrets.resolveSecretValue(companyId, accessRef.secretId, accessRef.versionSelector ?? "latest", { accessContext });
}
return {
configured: true,
credential: {
token,
source: "managed_connection",
secretName: null,
githubIdentity: { userId: github.userId, login: github.login },
},
};
}

View File

@ -0,0 +1,500 @@
import {
connectionEventDeliveries,
connectionGrants,
externalObjects,
toolConnections,
type Db,
} from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import {
logActivity,
publishActivity,
type ActivityPublication,
} from "./activity-log.js";
import {
createPaperclipCloudConnector,
paperclipCloudConnectorConfigFromEnv,
type PaperclipCloudConnector,
type SealedConnectorEvents,
} from "./paperclip-cloud-connector.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { logger } from "../middleware/logger.js";
type LeasedEvent = SealedConnectorEvents["events"][number];
type GitHubBinding = {
id: string;
companyId: string;
connectionId: string;
grantId: string;
subject: string;
installationId: string;
providerTenant: NonNullable<typeof connectionGrants.$inferSelect.providerTenant>;
};
export type GitHubConnectionEventPollResult = {
leased: number;
processed: number;
duplicate: number;
ignored: number;
failed: number;
};
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function positiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
}
function boundedString(value: unknown, maximum: number): string | null {
return typeof value === "string" && value.length > 0 && value.length <= maximum ? value : null;
}
function identifier(value: unknown): string | null {
return typeof value === "string" && /^[1-9][0-9]{0,30}$/.test(value) ? value : null;
}
function isoDate(value: unknown): string | null {
const candidate = boundedString(value, 100);
if (!candidate || Number.isNaN(Date.parse(candidate))) return null;
return new Date(candidate).toISOString();
}
function commitSha(value: unknown): string | null {
return typeof value === "string" && /^[0-9a-f]{40,64}$/i.test(value) ? value.toLowerCase() : null;
}
function githubUrl(value: unknown): string | null {
const candidate = boundedString(value, 2_000);
if (!candidate) return null;
try {
const url = new URL(candidate);
return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com" ? url.toString() : null;
} catch {
return null;
}
}
function compact(values: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== null && value !== undefined));
}
/**
* Treat the sealed Cloud batch as an untrusted boundary. Cloud already normalizes
* GitHub payloads, but the instance independently allowlists and bounds the small
* reconciliation record that it persists and processes.
*/
function normalizeLeasedPayload(event: LeasedEvent): Record<string, unknown> {
const payload = record(event.payload);
const base = {
event: boundedString(payload.event, 100),
action: boundedString(payload.action, 100),
installationId: identifier(payload.installationId),
repositoryId: identifier(payload.repositoryId),
repository: boundedString(payload.repository, 300),
senderId: identifier(payload.senderId),
senderLogin: boundedString(payload.senderLogin, 100),
};
if (event.event === "pull_request") {
return compact({
...base,
number: positiveInteger(payload.number),
url: githubUrl(payload.url),
state: boundedString(payload.state, 40),
merged: payload.merged === true,
mergedAt: isoDate(payload.mergedAt),
updatedAt: isoDate(payload.updatedAt),
headRef: boundedString(payload.headRef, 300),
headSha: commitSha(payload.headSha),
baseRef: boundedString(payload.baseRef, 300),
baseSha: commitSha(payload.baseSha),
});
}
if (event.event === "installation_repositories") {
const repositoryIds = (value: unknown) => Array.isArray(value)
? value.slice(0, 1_000).flatMap((item) => identifier(item) ?? [])
: [];
return compact({
...base,
repositorySelection: boundedString(payload.repositorySelection, 40),
repositoriesAdded: repositoryIds(payload.repositoriesAdded),
repositoriesRemoved: repositoryIds(payload.repositoriesRemoved),
});
}
if (event.event === "installation") {
return compact({
...base,
accountId: identifier(payload.accountId),
accountLogin: boundedString(payload.accountLogin, 100),
repositorySelection: boundedString(payload.repositorySelection, 40),
});
}
return compact(base);
}
function bindingRows(rows: Array<{
grant: typeof connectionGrants.$inferSelect;
connection: typeof toolConnections.$inferSelect;
}>): GitHubBinding[] {
return rows.flatMap(({ grant, connection }) => {
const config = record(connection.config);
const oauth = record(config.oauth);
if (config.sourceTemplateKey !== "github" || oauth.connectorProfile !== "github.code") return [];
const github = grant.providerTenant?.github;
if (!github || grant.status !== "active") return [];
const subject = grant.kind === "agent" && grant.subjectAgentId
? `agent:${grant.subjectAgentId}`
: grant.kind === "user" && grant.subjectUserId
? grant.subjectUserId
: null;
if (!subject) return [];
return github.installationIds.map((installationId) => ({
id: `${grant.id}_${installationId}`,
companyId: grant.companyId,
connectionId: connection.id,
grantId: grant.id,
subject,
installationId,
providerTenant: grant.providerTenant!,
}));
});
}
function githubSnapshotUpdate(payload: Record<string, unknown>) {
const repository = stringValue(payload.repository);
const number = positiveInteger(payload.number);
if (!repository || !number) return null;
const state = stringValue(payload.state) ?? "unknown";
const merged = payload.merged === true;
const [owner, repo, ...extra] = repository.split("/");
if (!owner || !repo || extra.length > 0) return null;
return {
repository,
owner,
repo,
number,
externalId: `${repository}#pull/${number}`,
state,
merged,
statusKey: merged ? "merged" : state === "closed" ? "closed" : "open",
statusLabel: merged ? "Merged" : state === "closed" ? "Closed" : "Open",
statusCategory: merged ? "succeeded" : state === "closed" ? "closed" : "open",
statusTone: merged ? "success" : state === "closed" ? "muted" : "info",
statusIconKey: merged ? "git-merge" : state === "closed" ? "x-circle" : "git-pull-request",
data: {
provider: "github",
owner,
repo,
number,
state,
merged,
...(stringValue(payload.url) ? { url: stringValue(payload.url) } : {}),
...(stringValue(payload.mergedAt) ? { mergedAt: stringValue(payload.mergedAt) } : {}),
...(stringValue(payload.headRef) ? { headRef: stringValue(payload.headRef) } : {}),
...(stringValue(payload.headSha) ? { headSha: stringValue(payload.headSha) } : {}),
...(stringValue(payload.baseRef) ? { baseRef: stringValue(payload.baseRef) } : {}),
...(stringValue(payload.baseSha) ? { baseSha: stringValue(payload.baseSha) } : {}),
},
remoteVersion: stringValue(payload.updatedAt),
} as const;
}
export function githubConnectionEventService(
db: Db,
options: {
connector?: PaperclipCloudConnector;
env?: NodeJS.ProcessEnv;
now?: () => Date;
wakeup?: NonNullable<Parameters<typeof issueThreadInteractionService>[1]>["wakeup"];
} = {},
) {
const now = options.now ?? (() => new Date());
let nextPollAt = 0;
let emptyPolls = 0;
async function activeBindings() {
const rows = await db.select({ grant: connectionGrants, connection: toolConnections })
.from(connectionGrants)
.innerJoin(toolConnections, and(
eq(toolConnections.id, connectionGrants.connectionId),
eq(toolConnections.companyId, connectionGrants.companyId),
))
.where(and(
eq(connectionGrants.status, "active"),
eq(toolConnections.status, "active"),
eq(toolConnections.enabled, true),
));
return bindingRows(rows);
}
async function applyPullRequestEvent(companyId: string, event: LeasedEvent) {
const snapshot = githubSnapshotUpdate(event.payload);
if (!snapshot) return;
const appliedAt = now();
await db.update(externalObjects).set({
statusKey: snapshot.statusKey,
statusLabel: snapshot.statusLabel,
statusCategory: snapshot.statusCategory,
statusTone: snapshot.statusTone,
statusIconKey: snapshot.statusIconKey,
isTerminal: snapshot.merged || snapshot.state === "closed",
data: sql`${externalObjects.data} || ${JSON.stringify(snapshot.data)}::jsonb`,
remoteVersion: snapshot.remoteVersion,
lastResolvedAt: appliedAt,
lastChangedAt: appliedAt,
nextRefreshAt: appliedAt,
updatedAt: appliedAt,
}).where(and(
eq(externalObjects.companyId, companyId),
eq(externalObjects.providerKey, "github"),
eq(externalObjects.objectType, "pull_request"),
sql`lower(${externalObjects.externalId}) = lower(${snapshot.externalId})`,
));
if (snapshot.merged && event.action === "closed") {
await issueThreadInteractionService(db, { wakeup: options.wakeup })
.sweepMergedPullRequestConfirmations([{
companyId,
owner: snapshot.owner,
repo: snapshot.repo,
number: snapshot.number,
}]);
}
}
async function applyInstallationEvent(database: Db, binding: GitHubBinding, event: LeasedEvent) {
const github = binding.providerTenant.github!;
const unavailable = event.event === "installation" && (event.action === "deleted" || event.action === "suspend");
const installationIds = unavailable
? github.installationIds.filter((id) => id !== binding.installationId)
: [...new Set([...github.installationIds, binding.installationId])];
const added = Array.isArray(event.payload.repositoriesAdded) ? event.payload.repositoriesAdded.length : 0;
const removed = Array.isArray(event.payload.repositoriesRemoved) ? event.payload.repositoriesRemoved.length : 0;
const repositoryCount = Math.max(
0,
unavailable && installationIds.length === 0
? 0
: unavailable
? github.repositoryCount
: github.repositoryCount + added - removed,
);
const providerTenant = {
...binding.providerTenant,
github: {
...github,
installationIds,
installationCount: installationIds.length,
repositoryCount,
repositorySelection: repositoryCount === 0 ? "none" as const : github.repositorySelection,
lastWebhookAt: now().toISOString(),
webhookHealth: unavailable ? "unhealthy" as const : "healthy" as const,
},
};
await database.update(connectionGrants).set({ providerTenant, updatedAt: now() })
.where(and(eq(connectionGrants.id, binding.grantId), eq(connectionGrants.companyId, binding.companyId)));
await database.update(toolConnections).set({
healthStatus: unavailable ? "failed" : "ok",
healthMessage: unavailable
? "GitHub installation access was removed or suspended. Manage repository access on GitHub."
: "GitHub installation and repository access are available.",
healthCheckedAt: now(),
lastHealthAt: now(),
lastError: unavailable ? "GitHub installation unavailable" : null,
updatedAt: now(),
}).where(and(eq(toolConnections.id, binding.connectionId), eq(toolConnections.companyId, binding.companyId)));
}
async function processForCompany(companyId: string, bindings: GitHubBinding[], event: LeasedEvent) {
const receiptAt = now();
const normalizedEvent = { ...event, payload: normalizeLeasedPayload(event) };
const [receipt] = await db.insert(connectionEventDeliveries).values({
companyId,
provider: event.provider,
providerDeliveryId: event.id,
event: event.event,
action: event.action,
installationId: event.installationId,
repositoryId: event.repositoryId,
normalizedPayload: normalizedEvent.payload,
providerCreatedAt: new Date(event.createdAt),
status: "received",
attempts: 1,
updatedAt: receiptAt,
}).onConflictDoNothing().returning();
if (!receipt) {
const [existing] = await db.select().from(connectionEventDeliveries).where(and(
eq(connectionEventDeliveries.companyId, companyId),
eq(connectionEventDeliveries.provider, event.provider),
eq(connectionEventDeliveries.providerDeliveryId, event.id),
)).limit(1);
if (existing?.status === "processed") return "duplicate" as const;
await db.update(connectionEventDeliveries).set({
status: "received",
attempts: sql`${connectionEventDeliveries.attempts} + 1`,
lastError: null,
updatedAt: receiptAt,
}).where(eq(connectionEventDeliveries.id, existing!.id));
}
const postCommitPublications: ActivityPublication[] = [];
try {
const applyAndFinalize = async (database: Db) => {
if (event.event === "pull_request") await applyPullRequestEvent(companyId, normalizedEvent);
if (event.event === "installation" || event.event === "installation_repositories") {
for (const binding of bindings) await applyInstallationEvent(database, binding, normalizedEvent);
} else {
const touchedAt = now();
for (const binding of bindings) {
const github = binding.providerTenant.github;
if (!github) continue;
await database.update(connectionGrants).set({
providerTenant: {
...binding.providerTenant,
github: { ...github, lastWebhookAt: touchedAt.toISOString(), webhookHealth: "healthy" },
},
updatedAt: touchedAt,
}).where(and(eq(connectionGrants.id, binding.grantId), eq(connectionGrants.companyId, companyId)));
}
}
const finishedAt = now();
await database.update(connectionEventDeliveries).set({
status: "processed",
processedAt: finishedAt,
lastError: null,
updatedAt: finishedAt,
}).where(and(
eq(connectionEventDeliveries.companyId, companyId),
eq(connectionEventDeliveries.provider, event.provider),
eq(connectionEventDeliveries.providerDeliveryId, event.id),
));
await logActivity(database, {
companyId,
actorType: "system",
actorId: "system:github-webhook",
action: "tool_connection.webhook_processed",
entityType: "tool_connection",
entityId: bindings[0]!.connectionId,
details: {
provider: "github",
event: event.event,
action: event.action,
deliveryId: event.id,
installationId: event.installationId,
repositoryId: event.repositoryId,
},
}, postCommitPublications);
};
if (event.event === "installation" || event.event === "installation_repositories") {
await db.transaction(async (tx) => applyAndFinalize(tx as unknown as Db));
} else {
await applyAndFinalize(db);
}
} catch (error) {
await db.update(connectionEventDeliveries).set({
status: "failed",
lastError: error instanceof Error ? error.message.slice(0, 500) : "GitHub event processing failed",
updatedAt: now(),
}).where(and(
eq(connectionEventDeliveries.companyId, companyId),
eq(connectionEventDeliveries.provider, event.provider),
eq(connectionEventDeliveries.providerDeliveryId, event.id),
));
throw error;
}
// Persistence is complete at this point (and installation deltas have
// committed). A synchronous live-event subscriber must not turn that
// durable success back into a retryable receipt and replay the delta.
for (const publication of postCommitPublications) {
try {
publishActivity(publication);
} catch (error) {
logger.warn({
err: error,
companyId,
providerDeliveryId: event.id,
}, "GitHub webhook activity publication failed after commit");
}
}
return "processed" as const;
}
return {
async pollOnce(): Promise<GitHubConnectionEventPollResult> {
if (now().getTime() < nextPollAt) {
return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
}
const bindings = await activeBindings();
if (bindings.length === 0) {
nextPollAt = now().getTime() + 5 * 60_000;
return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
}
const config = options.connector ? null : paperclipCloudConnectorConfigFromEnv(options.env);
const connector = options.connector ?? (config ? createPaperclipCloudConnector({ config }) : null);
if (!connector) {
nextPollAt = now().getTime() + 5 * 60_000;
return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
}
const first = bindings[0]!;
const lease = await connector.leaseEvents({ subject: first.subject, companyId: first.companyId });
if (!lease) {
emptyPolls += 1;
nextPollAt = now().getTime() + Math.min(5 * 60_000, 5_000 * (2 ** Math.min(emptyPolls, 6)));
return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
}
emptyPolls = 0;
nextPollAt = now().getTime() + 5_000;
const result: GitHubConnectionEventPollResult = {
leased: lease.events.length,
processed: 0,
duplicate: 0,
ignored: 0,
failed: 0,
};
const acknowledge: string[] = [];
for (const event of lease.events) {
const matched = bindings.filter((binding) => event.bindingIds.includes(binding.id));
if (matched.length === 0) {
result.ignored += 1;
acknowledge.push(event.id);
continue;
}
try {
const companies = new Map<string, GitHubBinding[]>();
for (const binding of matched) companies.set(binding.companyId, [...(companies.get(binding.companyId) ?? []), binding]);
for (const [companyId, companyBindings] of companies) {
const status = await processForCompany(companyId, companyBindings, event);
result[status] += 1;
}
acknowledge.push(event.id);
if (event.event === "installation" && (event.action === "deleted" || event.action === "suspend")) {
await Promise.all(matched.map((binding) => connector.setWebhookBinding({
subject: binding.subject,
companyId: binding.companyId,
id: binding.id,
installationId: binding.installationId,
connectionId: binding.connectionId,
grantId: binding.grantId,
active: false,
})));
}
} catch {
result.failed += 1;
}
}
if (acknowledge.length > 0) {
await connector.acknowledgeEvents({
subject: first.subject,
companyId: first.companyId,
leaseId: lease.leaseId,
deliveryIds: acknowledge,
});
}
return result;
},
};
}

View File

@ -102,6 +102,8 @@ import { logger } from "../middleware/logger.js";
import {
createGitRemoteAuthProvider,
describeGitAuthFailure,
filterResolvedGitHubConnectionsForRun,
GIT_CREDENTIAL_TOKEN_ENV_KEY,
scrubGitCredentialText,
type GitRemoteAuthProvider,
} from "./git-credentials.js";
@ -1275,6 +1277,9 @@ export async function resolveExecutionRunAdapterConfig(input: {
reason: string;
remediation: string;
};
/** Audited class-3 values resolved by an internal credential broker. */
trustedEnvProjection?: Record<string, string>;
trustedEnvSecretKeys?: string[];
}) {
const executionRunConfig = stripForbiddenEnvFromAdapterConfig(
input.executionRunConfig,
@ -1287,6 +1292,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
input.trustPreset?.kind === "low_trust_review"
? (input.trustPreset.boundary.allowedSecretBindingIds ?? [])
: undefined;
const allowTrustedEnvProjection = input.trustPreset?.kind !== "low_trust_review";
if (input.trustPreset?.kind === "low_trust_review") {
assertLowTrustEnvConfigAllowed(environmentEnv, "environment.env");
assertLowTrustEnvConfigAllowed(executionRunConfig.env, "agent.env");
@ -1297,6 +1303,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
const requiredScopedBindingsConfigured = requiredScopedEnvBinding
? requiredScopedEnvBinding.keys.some(
(key) =>
(allowTrustedEnvProjection && typeof input.trustedEnvProjection?.[key] === "string") ||
(requiredScopedEnvBinding.consumerScopes.includes("agent") &&
isConfiguredEnvBindingValue(agentEnv[key])) ||
(requiredScopedEnvBinding.consumerScopes.includes("project") &&
@ -1556,6 +1563,17 @@ export async function resolveExecutionRunAdapterConfig(input: {
secretKeys.add(key);
}
}
if (
allowTrustedEnvProjection
&& input.trustedEnvProjection
&& Object.keys(input.trustedEnvProjection).length > 0
) {
resolvedConfig.env = {
...parseObject(resolvedConfig.env),
...input.trustedEnvProjection,
};
for (const key of input.trustedEnvSecretKeys ?? []) secretKeys.add(key);
}
// Pre-dispatch credential gate for codex_local: a managed Codex home with no
// usable auth.json and an empty OPENAI_API_KEY would dispatch a run that
// immediately fails with "no Codex credentials provisioned" (adapter_failed),
@ -2236,6 +2254,7 @@ export interface ResolveAdditionalProjectWorkspaceDeps {
/** Build the real dependencies for {@link resolveAdditionalProjectWorkspace}. */
function defaultAdditionalProjectWorkspaceDeps(
db: Db,
resolveGitAuth?: GitRemoteAuthProvider,
): ResolveAdditionalProjectWorkspaceDeps {
return {
loadProjectWorkspaceRows: (companyId, projectId) =>
@ -2254,6 +2273,7 @@ function defaultAdditionalProjectWorkspaceDeps(
...input,
resolveGitAuth:
input.resolveGitAuth ??
resolveGitAuth ??
createGitRemoteAuthProvider(db, input.companyId),
}),
ensureManagedProjectWorkspace: (input) =>
@ -2261,6 +2281,7 @@ function defaultAdditionalProjectWorkspaceDeps(
...input,
resolveGitAuth:
input.resolveGitAuth ??
resolveGitAuth ??
createGitRemoteAuthProvider(db, input.companyId),
}),
// A realized workspace must hold real content. An empty directory gives the agent an empty
@ -4036,13 +4057,29 @@ export async function buildPaperclipRuntimeMcpServers(input: {
input.agent.companyId,
input.agent.id,
);
const [runIdentity] = await input.db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.agent.companyId),
eq(heartbeatRuns.agentId, input.agent.id),
))
.limit(1);
const resolvedInstalledConnections = await filterResolvedGitHubConnectionsForRun({
db: input.db,
companyId: input.agent.companyId,
agentId: input.agent.id,
responsibleUserId: runIdentity?.responsibleUserId ?? null,
connections: effective.installedConnections,
});
const permittedConnectionIds = new Set([
...effective.entries
.filter((entry) => entry.effect === "include" && entry.connectionId)
.map((entry) => entry.connectionId!),
...effective.allowedTools.map((tool) => tool.connectionId),
]);
const installedConnectionIds = new Set(
const allInstalledConnectionIds = new Set(
effective.installedConnections.map((connection) => connection.id),
);
const permittedConnections =
@ -4066,11 +4103,11 @@ export async function buildPaperclipRuntimeMcpServers(input: {
(connection) =>
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio") &&
!installedConnectionIds.has(connection.id),
!allInstalledConnectionIds.has(connection.id),
)
.map(({ id, name }) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
const assignedConnections = effective.installedConnections.filter(
const assignedConnections = resolvedInstalledConnections.filter(
(connection) =>
permittedConnectionIds.has(connection.id) &&
connection.status === "active" &&
@ -4079,7 +4116,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio"),
);
const unhealthyConnections = effective.installedConnections.filter(
const unhealthyConnections = resolvedInstalledConnections.filter(
(connection) =>
permittedConnectionIds.has(connection.id) &&
(connection.transport === "mcp_remote" ||
@ -4486,6 +4523,8 @@ export async function createManagedMcpRunConfig(input: {
enabled: toolConnections.enabled,
status: toolConnections.status,
healthStatus: toolConnections.healthStatus,
config: toolConnections.config,
transportConfig: toolConnections.transportConfig,
})
.from(toolConnectionInstalls)
.innerJoin(
@ -4501,17 +4540,35 @@ export async function createManagedMcpRunConfig(input: {
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${input.agent.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agent.id}))`,
),
);
const [runIdentity] = await input.db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.agent.companyId),
eq(heartbeatRuns.agentId, input.agent.id),
))
.limit(1);
const resolvedAvailableInstalls = await filterResolvedGitHubConnectionsForRun({
db: input.db,
companyId: input.agent.companyId,
agentId: input.agent.id,
responsibleUserId: runIdentity?.responsibleUserId ?? null,
connections: installRows.filter(
(install) =>
install.enabled &&
install.status === "active" &&
!["degraded", "failed", "error", "missing_secret"].includes(
install.healthStatus,
),
).map((install) => ({
id: install.connectionId,
config: install.config,
transportConfig: install.transportConfig,
})),
});
const availableInstalledConnectionIds = new Set(
installRows
.filter(
(install) =>
install.enabled &&
install.status === "active" &&
!["degraded", "failed", "error", "missing_secret"].includes(
install.healthStatus,
),
)
.map((install) => install.connectionId),
resolvedAvailableInstalls.map((install) => install.id),
);
const applicableGateways = rows.filter((gateway) =>
@ -10308,6 +10365,12 @@ export function heartbeatService(
): Promise<ResolvedAnchorWorkspaceForRun> {
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
responsibleUserId: readNonEmptyString(context.responsibleUserId)
?? readNonEmptyString(context.responsible_user_id),
agentId: agent.id,
});
const contextProjectId = readNonEmptyString(context.projectId);
const contextProjectWorkspaceId = readNonEmptyString(
context.projectWorkspaceId,
@ -10368,9 +10431,6 @@ export function heartbeatService(
if (preferredProjectWorkspaceId && !preferredWorkspace) {
preferredWorkspaceWarning = `Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`;
}
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
});
for (const workspace of projectWorkspaceRows) {
let projectCwd: string;
let managedWorkspaceWarning: string | null = null;
@ -10565,6 +10625,12 @@ export function heartbeatService(
const executionEnvironmentDriver = opts?.executionEnvironmentDriver ?? null;
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
responsibleUserId: readNonEmptyString(context.responsibleUserId)
?? readNonEmptyString(context.responsible_user_id),
agentId: agent.id,
});
const { additionalWorkspaces, warnings, failures } =
await resolveAdditionalRunWorkspaces(issueId, anchor.projectId, {
enabled: true,
@ -10588,7 +10654,7 @@ export function heartbeatService(
resolveProjectWorkspace: (project) =>
resolveAdditionalProjectWorkspace(
{ companyId: agent.companyId, project },
defaultAdditionalProjectWorkspaceDeps(db),
defaultAdditionalProjectWorkspaceDeps(db, resolveGitAuth),
),
});
@ -18608,6 +18674,12 @@ export function heartbeatService(
issueId,
explicitRunScopedSkillKeys: runScopedMentionedSkillKeys,
});
const githubRunAuth = await createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: run.id,
responsibleUserId,
agentId: agent.id,
})("https://github.com/paperclipai/credential-probe.git");
const { resolvedConfig, secretKeys, secretManifest } =
await resolveExecutionRunAdapterConfig({
companyId: agent.companyId,
@ -18626,6 +18698,10 @@ export function heartbeatService(
routineEnv: routineEnvContext.env,
secretsSvc,
trustPreset,
...(githubRunAuth ? {
trustedEnvProjection: githubRunAuth.env,
trustedEnvSecretKeys: ["GH_TOKEN", "GITHUB_TOKEN", GIT_CREDENTIAL_TOKEN_ENV_KEY],
} : {}),
requiredScopedEnvBinding: pushCapabilityPreflightRequired
? {
keys: [...PUSH_CAPABILITY_ENV_KEYS],
@ -18947,6 +19023,8 @@ export function heartbeatService(
{
issueId,
heartbeatRunId: run.id,
responsibleUserId: run.responsibleUserId,
agentId: agent.id,
},
);
const {

View File

@ -42,6 +42,7 @@ export {
type IssueFilters,
} from "./issues.js";
export { issueThreadInteractionService } from "./issue-thread-interactions.js";
export { githubConnectionEventService, type GitHubConnectionEventPollResult } from "./github-connection-events.js";
export {
assertIssueReviewVerdictActorAllowed,
type IssueReviewVerdictActor,

View File

@ -2185,7 +2185,12 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
await emitInteractionResolvedTelemetry(db, interaction);
return interaction;
},
sweepMergedPullRequestConfirmations: async () => {
sweepMergedPullRequestConfirmations: async (mergedHints: Array<{
companyId: string;
owner: string;
repo: string;
number: number;
}> = []) => {
const rows = await db
.select({
interaction: issueThreadInteractions,
@ -2225,6 +2230,10 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
const checkedAt = now().getTime();
const cacheTtlMs = opts.pullRequestCacheTtlMs ?? 5 * 60 * 1000;
for (const hint of mergedHints) {
const key = `${hint.companyId}:${hint.owner.toLowerCase()}/${hint.repo.toLowerCase()}#${hint.number}`;
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, { state: "merged", checkedAt });
}
const uniqueReferences = new Map<string, {
key: string;
companyId: string;

View File

@ -161,6 +161,22 @@ describe("native controller takeover fencing", () => {
});
});
it("does not treat a sub-second process-start precision difference as PID reuse", async () => {
await expect(
evaluateNativeControllerTakeover({
owner: owner({
controllerProcessStartedAt: new Date("2026-09-04T11:00:00.456Z"),
}),
now,
isProcessAlive: () => true,
readProcessStartedAt: async () => new Date("2026-09-04T11:00:00.000Z"),
}),
).resolves.toEqual({
allowed: false,
reason: "controller_still_alive",
});
});
it("does not steal a live controller lease", async () => {
const readStartedAt = vi.fn(async () => recordedStart);
await expect(
@ -256,4 +272,24 @@ describe("native provider process fencing", () => {
recycledPids: [],
});
});
it("fails closed on a sub-second provider process-start precision difference", async () => {
await expect(
evaluateNativeProviderProcesses({
identities: [
{
pid: 456,
processStartedAt: new Date("2026-09-04T11:00:00.456Z"),
},
],
isProcessAlive: () => true,
readProcessStartedAt: async () => new Date("2026-09-04T11:00:00.000Z"),
}),
).resolves.toEqual({
knownPids: [456],
livePids: [],
ambiguousLivePids: [456],
recycledPids: [],
});
});
});

View File

@ -129,6 +129,18 @@ function sameProcessStart(left: Date | null, right: Date | null): boolean {
return left !== null && right !== null && left.getTime() === right.getTime();
}
function definitivelyDifferentProcessStart(
left: Date | null,
right: Date | null,
): boolean {
if (left === null || right === null) return false;
// `ps -o lstart` has only whole-second precision on macOS and BSD. A
// sub-second disagreement can therefore be the same process when one probe
// fell back to a higher-precision spawn timestamp. Treat it as ambiguous and
// fail closed instead of declaring the live PID recycled.
return Math.abs(left.getTime() - right.getTime()) >= 1_000;
}
export async function evaluateNativeControllerTakeover(input: {
owner: Pick<
typeof nativeRunFinalizations.$inferSelect,
@ -176,7 +188,10 @@ export async function evaluateNativeControllerTakeover(input: {
if (
owner.controllerProcessStartedAt &&
observedStartedAt &&
!sameProcessStart(owner.controllerProcessStartedAt, observedStartedAt)
definitivelyDifferentProcessStart(
owner.controllerProcessStartedAt,
observedStartedAt,
)
) {
return { allowed: true, reason: "controller_pid_recycled" };
}
@ -236,8 +251,10 @@ export async function evaluateNativeProviderProcesses(input: {
ambiguousLivePids.push(pid);
} else if (sameProcessStart(expectedStartedAt, observedStartedAt)) {
livePids.push(pid);
} else {
} else if (definitivelyDifferentProcessStart(expectedStartedAt, observedStartedAt)) {
recycledPids.push(pid);
} else {
ambiguousLivePids.push(pid);
}
}
return {

View File

@ -1,7 +1,8 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import { isToolConnectionAttentionHealth } from "@paperclipai/shared";
import {
@ -21,6 +22,7 @@ import {
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
import { agentInstructionsService } from "../agent-instructions.js";
import { toolAccessService } from "../tool-access.js";
import { filterResolvedGitHubConnectionsForRun } from "../git-credentials.js";
const MAX_ASSET_FILES = 10_000;
const MAX_ASSET_BYTES = 64 * 1024 * 1024;
@ -166,9 +168,35 @@ async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>,
export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pick<RuntimeAgent, "id" | "companyId">; runId: string }) {
const effective = await toolAccessService(input.db).getEffectiveProfilesForAgent(input.agent.companyId, input.agent.id);
const permitted = new Set([...effective.entries.filter((entry) => entry.effect === "include" && entry.connectionId).map((entry) => entry.connectionId!), ...effective.allowedTools.map((tool) => tool.connectionId)]);
const hasGitHubConnection = effective.installedConnections.some((connection) => {
const config = connection.config && typeof connection.config === "object"
? connection.config as Record<string, unknown>
: {};
const transportConfig = connection.transportConfig && typeof connection.transportConfig === "object"
? connection.transportConfig as Record<string, unknown>
: {};
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
});
const [runIdentity] = hasGitHubConnection
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.agent.companyId),
eq(heartbeatRuns.agentId, input.agent.id),
))
.limit(1)
: [];
const resolvedInstalledConnections = await filterResolvedGitHubConnectionsForRun({
db: input.db,
companyId: input.agent.companyId,
agentId: input.agent.id,
responsibleUserId: runIdentity?.responsibleUserId ?? null,
connections: effective.installedConnections,
});
// App access is optional runtime context. Keep usable assignments pinned, but
// do not stop unrelated work because an assigned app needs attention.
const availableConnectionIds = new Set(effective.installedConnections.filter((connection) =>
const availableConnectionIds = new Set(resolvedInstalledConnections.filter((connection) =>
permitted.has(connection.id)
&& connection.status === "active"
&& connection.enabled

View File

@ -1,5 +1,6 @@
import {
createCipheriv,
createHash,
diffieHellman,
generateKeyPairSync,
hkdfSync,
@ -92,6 +93,47 @@ describe("Paperclip Cloud connector", () => {
});
});
it("binds active GitHub installations to proof from the current user token", async () => {
const keys = config();
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { request: string; binding: string };
const claims = JSON.parse(Buffer.from(body.request.split(".")[1]!, "base64url").toString("utf8"));
const binding = JSON.parse(body.binding);
expect(binding).toEqual({
id: "binding-1",
installationId: "42",
connectionId: "connection-1",
grantId: "grant-1",
active: true,
accessToken: "ghu-user-token",
});
expect(claims.sh).toBe(createHash("sha256").update(body.binding).digest("base64url"));
return Response.json({ active: true, installationId: "42" });
});
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
await expect(connector.setWebhookBinding({
subject,
companyId,
id: "binding-1",
installationId: "42",
connectionId: "connection-1",
grantId: "grant-1",
active: true,
accessToken: "ghu-user-token",
})).resolves.toBeUndefined();
await expect(connector.setWebhookBinding({
subject,
companyId,
id: "binding-2",
installationId: "43",
connectionId: "connection-1",
grantId: "grant-1",
active: true,
})).rejects.toMatchObject({ code: "CONNECTOR_CONFIG_INVALID" });
expect(request).toHaveBeenCalledTimes(1);
});
it("keeps legacy session responses compatible and rejects malformed handoff descriptors", async () => {
const keys = config();
const legacy = createPaperclipCloudConnector({
@ -132,6 +174,7 @@ describe("Paperclip Cloud connector", () => {
refreshToken: "refresh-secret",
tokenType: "Bearer",
accessTokenExpiresAt: "2026-08-21T20:00:00.000Z",
refreshTokenExpiresAt: null,
scopes: [...GMAIL_CONNECTOR_SCOPES],
subject,
companyId,
@ -165,6 +208,7 @@ describe("Paperclip Cloud connector", () => {
refreshToken: "drive-refresh-secret",
tokenType: "Bearer",
accessTokenExpiresAt: "2026-08-21T20:00:00.000Z",
refreshTokenExpiresAt: null,
scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes],
subject,
companyId,

View File

@ -10,8 +10,11 @@ import {
type KeyObject,
} from "node:crypto";
import {
GITHUB_CONNECTOR_PROFILES,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
isGitHubConnectorProfileId,
isGoogleWorkspaceConnectorProfileId,
type GitHubConnectorProfileId,
type GoogleWorkspaceConnectorProfileId,
} from "@paperclipai/shared";
import {
@ -27,7 +30,9 @@ export const GMAIL_CONNECTOR_SCOPES = [
export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES };
export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production";
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke";
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack";
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId;
export type PaperclipCloudConnectorProvider = "google" | "github";
export type PaperclipCloudConnectorConfig = {
baseUrl: string;
@ -37,29 +42,50 @@ export type PaperclipCloudConnectorConfig = {
sealPrivateKey: string;
};
export type SealedGmailCredentials = {
export type SealedConnectorCredentials = {
v: 1;
accessToken: string;
refreshToken: string | null;
tokenType: string;
accessTokenExpiresAt: string;
accessTokenExpiresAt: string | null;
refreshTokenExpiresAt: string | null;
scopes: string[];
subject: string;
companyId: string;
instanceId: string;
environment: PaperclipCloudConnectorEnvironment;
provider: "google";
provider: PaperclipCloudConnectorProvider;
profile: string;
appSlug?: string;
};
export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" };
export type SealedGoogleWorkspaceCredentials = SealedGmailCredentials;
export type SealedConnectorEvents = {
v: 1;
instanceId: string;
environment: PaperclipCloudConnectorEnvironment;
leaseId: string;
events: Array<{
id: string;
provider: "github";
event: string;
action: string | null;
installationId: string | null;
repositoryId: string | null;
payload: Record<string, unknown>;
createdAt: string;
bindingIds: string[];
}>;
};
type SealedEnvelope = {
v: 1;
alg: "X25519-HKDF-SHA256-A256GCM";
purpose: "initial" | "access";
provider: "google";
profile: GoogleWorkspaceConnectorProfileId;
purpose: "initial" | "access" | "events";
provider: PaperclipCloudConnectorProvider;
profile: PaperclipCloudConnectorProfileId;
epk: string;
iv: string;
ct: string;
@ -77,6 +103,9 @@ type ConnectorResponse = {
providers?: unknown;
active?: unknown;
status?: unknown;
leaseId?: unknown;
events?: unknown;
acknowledged?: unknown;
};
const ENDPOINTS: Record<PaperclipCloudConnectorOperation, string> = {
@ -85,6 +114,9 @@ const ENDPOINTS: Record<PaperclipCloudConnectorOperation, string> = {
claim: "/v1/connector/claims",
refresh: "/v1/connector/refresh",
revoke: "/v1/connector/revoke",
"webhook-bind": "/v1/connector/webhook-bindings",
"event-lease": "/v1/connector/events/lease",
"event-ack": "/v1/connector/events/ack",
};
const JWS_TYP = "paperclip-cloud-connector-request+jwt";
const SEAL_ALGORITHM = "X25519-HKDF-SHA256-A256GCM";
@ -185,8 +217,8 @@ export function createPaperclipCloudConnector(input: {
async function call(
operation: PaperclipCloudConnectorOperation,
claims: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string },
secret?: { field: "refreshToken" | "token"; value: string },
claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string },
secret?: { field: "refreshToken" | "token" | "binding" | "acknowledgement"; value: string },
): Promise<ConnectorResponse> {
const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString();
const issuedAt = Math.floor(now() / 1000);
@ -206,9 +238,10 @@ export function createPaperclipCloudConnector(input: {
if (claims.claimId !== undefined) payload.cl = claims.claimId;
if (claims.redemptionId !== undefined) payload.rid = claims.redemptionId;
if (claims.profile !== undefined) {
payload.prv = "google";
const definition = connectorProfileDefinition(claims.profile);
payload.prv = definition.provider;
payload.prf = claims.profile;
payload.scp = [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[claims.profile].scopes];
payload.scp = [...definition.scopes];
}
if (secret) payload.sh = await sha256Base64Url(secret.value);
const body = {
@ -246,32 +279,33 @@ export function createPaperclipCloudConnector(input: {
purpose: SealedEnvelope["purpose"],
subject: string,
companyId: string,
profile: GoogleWorkspaceConnectorProfileId,
): SealedGmailCredentials {
const envelope = parseEnvelope(response.sealed, purpose);
profile: PaperclipCloudConnectorProfileId,
): SealedConnectorCredentials {
const definition = connectorProfileDefinition(profile);
const envelope = parseEnvelope(response.sealed, purpose, definition.provider, profile);
const credentials = unseal(
envelope,
sealKey,
config.instanceId,
config.environment,
"google",
definition.provider,
profile,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes,
definition.scopes,
);
if (
credentials.instanceId !== config.instanceId
|| credentials.environment !== config.environment
|| credentials.subject !== subject
|| credentials.companyId !== companyId
|| credentials.provider !== "google"
|| credentials.provider !== definition.provider
) {
throw new PaperclipCloudConnectorError("Paperclip Cloud Gmail credential binding did not match", "CONNECTOR_BINDING_MISMATCH");
throw new PaperclipCloudConnectorError("Paperclip Cloud credential binding did not match", "CONNECTOR_BINDING_MISMATCH");
}
if (credentials.profile !== profile) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector profile binding did not match", "CONNECTOR_BINDING_MISMATCH");
}
if (!sameStringSet(credentials.scopes, GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes)) {
throw new PaperclipCloudConnectorError("Paperclip Cloud Gmail scope grant did not match", "REAUTHORIZATION_REQUIRED");
if (!sameStringSet(credentials.scopes, definition.scopes)) {
throw new PaperclipCloudConnectorError("Paperclip Cloud scope grant did not match", "REAUTHORIZATION_REQUIRED");
}
return credentials;
}
@ -293,7 +327,7 @@ export function createPaperclipCloudConnector(input: {
if (response.status === "removed" && response.active === false) return "removed";
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid instance status", "CONNECTOR_BAD_RESPONSE");
},
async getCapabilities(): Promise<GoogleWorkspaceConnectorProfileId[]> {
async getCapabilities(): Promise<PaperclipCloudConnectorProfileId[]> {
let response: ConnectorResponse;
try {
response = await call("status", {
@ -305,10 +339,10 @@ export function createPaperclipCloudConnector(input: {
}
if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles)) return [];
return [...new Set(response.profiles.flatMap((value) =>
typeof value === "string" && isGoogleWorkspaceConnectorProfileId(value) ? [value] : []
typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : []
))];
},
async startAuthorization(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; returnUri: string; returnState: string }) {
async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) {
const profile = values.profile ?? "gmail.draft";
const response = await call("session", { ...values, profile });
if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") {
@ -326,11 +360,11 @@ export function createPaperclipCloudConnector(input: {
...(handoff ? { handoff } : {}),
};
},
async claim(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; claimId: string; redemptionId: string }) {
async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; claimId: string; redemptionId: string }) {
const profile = values.profile ?? "gmail.draft";
return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile);
},
async refresh(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; refreshToken: string }) {
async refresh(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; refreshToken: string }) {
const profile = values.profile ?? "gmail.draft";
return openCredentials(
await call("refresh", { ...values, profile }, { field: "refreshToken", value: values.refreshToken }),
@ -340,9 +374,57 @@ export function createPaperclipCloudConnector(input: {
profile,
);
},
async revoke(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; token: string }) {
async revoke(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; token: string }) {
await call("revoke", { ...values, profile: values.profile ?? "gmail.draft" }, { field: "token", value: values.token });
},
async setWebhookBinding(values: {
subject: string;
companyId: string;
id: string;
installationId: string;
connectionId: string;
grantId: string;
active: boolean;
accessToken?: string;
}) {
if (values.active && !values.accessToken) {
throw new PaperclipCloudConnectorError(
"GitHub access token is required to authorize a webhook binding",
"CONNECTOR_CONFIG_INVALID",
);
}
const binding = JSON.stringify({
id: values.id,
installationId: values.installationId,
connectionId: values.connectionId,
grantId: values.grantId,
active: values.active,
...(values.active ? { accessToken: values.accessToken } : {}),
});
await call("webhook-bind", { ...values, profile: "github.code" }, { field: "binding", value: binding });
},
async leaseEvents(values: { subject: string; companyId: string }): Promise<{ leaseId: string; events: SealedConnectorEvents["events"] } | null> {
const response = await call("event-lease", values);
if (Array.isArray(response.events) && response.events.length === 0) return null;
if (typeof response.leaseId !== "string") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid event lease", "CONNECTOR_BAD_RESPONSE");
}
const envelope = parseEnvelope(response.sealed, "events", "github", "github.code");
const opened = unsealEvents(envelope, sealKey, config.instanceId, config.environment);
if (opened.leaseId !== response.leaseId) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector event lease did not match", "CONNECTOR_BINDING_MISMATCH");
}
return { leaseId: opened.leaseId, events: opened.events };
},
async acknowledgeEvents(values: { subject: string; companyId: string; leaseId: string; deliveryIds: string[] }): Promise<number> {
const acknowledgement = JSON.stringify({ leaseId: values.leaseId, deliveryIds: values.deliveryIds });
const response = await call("event-ack", values, { field: "acknowledgement", value: acknowledgement });
const acknowledged = response.acknowledged;
if (typeof acknowledged !== "number" || !Number.isSafeInteger(acknowledged) || acknowledged < 0) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid acknowledgement", "CONNECTOR_BAD_RESPONSE");
}
return acknowledged;
},
};
}
@ -373,11 +455,11 @@ export function isPaperclipCloudConnectorStrategy(value: unknown): boolean {
return value === "paperclip_cloud_connector" || value === "paperclip_id_connector";
}
let capabilityCache: { key: string; expiresAt: number; profiles: GoogleWorkspaceConnectorProfileId[] } | null = null;
let capabilityCache: { key: string; expiresAt: number; profiles: PaperclipCloudConnectorProfileId[] } | null = null;
export async function paperclipCloudConnectorCapabilitiesFromEnv(
env: NodeJS.ProcessEnv = process.env,
): Promise<GoogleWorkspaceConnectorProfileId[]> {
): Promise<PaperclipCloudConnectorProfileId[]> {
let config: PaperclipCloudConnectorConfig | null;
try {
config = paperclipCloudConnectorConfigFromEnv(env);
@ -426,11 +508,16 @@ function privateKey(value: string, curve: "ed25519" | "x25519"): KeyObject {
}
}
function parseEnvelope(value: unknown, purpose: SealedEnvelope["purpose"]): SealedEnvelope {
function parseEnvelope(
value: unknown,
purpose: SealedEnvelope["purpose"],
provider: PaperclipCloudConnectorProvider,
profile: PaperclipCloudConnectorProfileId,
): SealedEnvelope {
if (!value || typeof value !== "object" || Array.isArray(value)) throw badEnvelope();
const candidate = value as Partial<SealedEnvelope>;
if (candidate.v !== 1 || candidate.alg !== SEAL_ALGORITHM || candidate.purpose !== purpose
|| candidate.provider !== "google" || !candidate.profile || !isGoogleWorkspaceConnectorProfileId(candidate.profile)
|| candidate.provider !== provider || candidate.profile !== profile
|| typeof candidate.epk !== "string" || typeof candidate.iv !== "string" || typeof candidate.ct !== "string") {
throw badEnvelope();
}
@ -442,77 +529,128 @@ function unseal(
recipientPrivateKey: KeyObject,
instanceId: string,
environment: string,
provider: "google",
profile: GoogleWorkspaceConnectorProfileId,
provider: PaperclipCloudConnectorProvider,
profile: PaperclipCloudConnectorProfileId,
scopes: readonly string[],
): SealedGmailCredentials {
): SealedConnectorCredentials {
try {
const ephemeralRaw = Buffer.from(envelope.epk, "base64url");
if (ephemeralRaw.length !== 32) throw badEnvelope();
const ephemeralKey = createPublicKey({
key: Buffer.concat([X25519_SPKI_PREFIX, ephemeralRaw]),
format: "der",
type: "spki",
});
const recipientJwk = createPublicKey(recipientPrivateKey).export({ format: "jwk" }) as { x?: string };
if (!recipientJwk.x) throw badEnvelope();
const recipientRaw = Buffer.from(recipientJwk.x, "base64url");
if (envelope.provider !== provider || envelope.profile !== profile) throw badEnvelope();
const aad = Buffer.from([
1,
SEAL_ALGORITHM,
envelope.purpose,
instanceId,
environment,
provider,
profile,
[...scopes].sort().join(" "),
].join("\n"), "utf8");
const key = Buffer.from(hkdfSync(
"sha256",
diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }),
Buffer.concat([ephemeralRaw, recipientRaw]),
aad,
32,
));
const iv = Buffer.from(envelope.iv, "base64url");
const combined = Buffer.from(envelope.ct, "base64url");
if (iv.length !== 12 || combined.length <= AES_TAG_BYTES) throw badEnvelope();
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: AES_TAG_BYTES });
decipher.setAAD(aad);
decipher.setAuthTag(combined.subarray(-AES_TAG_BYTES));
const plaintext = Buffer.concat([
decipher.update(combined.subarray(0, -AES_TAG_BYTES)),
decipher.final(),
]);
const parsed = JSON.parse(plaintext.toString("utf8")) as Partial<SealedGmailCredentials>;
const parsed = decryptEnvelope(envelope, recipientPrivateKey, instanceId, environment, provider, profile, scopes) as Partial<SealedConnectorCredentials>;
if (parsed.v !== 1 || typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0
|| !(parsed.refreshToken === null || typeof parsed.refreshToken === "string")
|| typeof parsed.tokenType !== "string" || typeof parsed.accessTokenExpiresAt !== "string"
|| typeof parsed.tokenType !== "string"
|| !(parsed.accessTokenExpiresAt === null || typeof parsed.accessTokenExpiresAt === "string")
|| !(parsed.refreshTokenExpiresAt === null || typeof parsed.refreshTokenExpiresAt === "string")
|| !Array.isArray(parsed.scopes) || !parsed.scopes.every((scope) => typeof scope === "string")
|| typeof parsed.subject !== "string" || typeof parsed.companyId !== "string"
|| typeof parsed.instanceId !== "string" || typeof parsed.environment !== "string"
|| !(parsed.appSlug === undefined || (typeof parsed.appSlug === "string" && /^[a-z0-9-]{1,100}$/.test(parsed.appSlug)))
|| parsed.provider !== provider || parsed.profile !== profile) {
throw badEnvelope();
}
return parsed as SealedGmailCredentials;
return parsed as SealedConnectorCredentials;
} catch (error) {
if (error instanceof PaperclipCloudConnectorError) throw error;
throw badEnvelope();
}
}
function unsealEvents(
envelope: SealedEnvelope,
recipientPrivateKey: KeyObject,
instanceId: string,
environment: PaperclipCloudConnectorEnvironment,
): SealedConnectorEvents {
try {
const parsed = decryptEnvelope(envelope, recipientPrivateKey, instanceId, environment, "github", "github.code", []) as Partial<SealedConnectorEvents>;
if (parsed.v !== 1 || parsed.instanceId !== instanceId || parsed.environment !== environment
|| typeof parsed.leaseId !== "string" || !Array.isArray(parsed.events)) throw badEnvelope();
for (const event of parsed.events) {
if (!isRecord(event) || typeof event.id !== "string" || event.provider !== "github"
|| typeof event.event !== "string" || !isRecord(event.payload) || !Array.isArray(event.bindingIds)
|| !event.bindingIds.every((id) => typeof id === "string")) throw badEnvelope();
}
return parsed as SealedConnectorEvents;
} catch (error) {
if (error instanceof PaperclipCloudConnectorError) throw error;
throw badEnvelope();
}
}
function decryptEnvelope(
envelope: SealedEnvelope,
recipientPrivateKey: KeyObject,
instanceId: string,
environment: string,
provider: PaperclipCloudConnectorProvider,
profile: PaperclipCloudConnectorProfileId,
scopes: readonly string[],
): unknown {
const ephemeralRaw = Buffer.from(envelope.epk, "base64url");
if (ephemeralRaw.length !== 32) throw badEnvelope();
const ephemeralKey = createPublicKey({
key: Buffer.concat([X25519_SPKI_PREFIX, ephemeralRaw]),
format: "der",
type: "spki",
});
const recipientJwk = createPublicKey(recipientPrivateKey).export({ format: "jwk" }) as { x?: string };
if (!recipientJwk.x) throw badEnvelope();
const recipientRaw = Buffer.from(recipientJwk.x, "base64url");
if (envelope.provider !== provider || envelope.profile !== profile) throw badEnvelope();
const aad = Buffer.from([
1,
SEAL_ALGORITHM,
envelope.purpose,
instanceId,
environment,
provider,
profile,
[...scopes].sort().join(" "),
].join("\n"), "utf8");
const key = Buffer.from(hkdfSync(
"sha256",
diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }),
Buffer.concat([ephemeralRaw, recipientRaw]),
aad,
32,
));
const iv = Buffer.from(envelope.iv, "base64url");
const combined = Buffer.from(envelope.ct, "base64url");
if (iv.length !== 12 || combined.length <= AES_TAG_BYTES) throw badEnvelope();
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: AES_TAG_BYTES });
decipher.setAAD(aad);
decipher.setAuthTag(combined.subarray(-AES_TAG_BYTES));
const plaintext = Buffer.concat([
decipher.update(combined.subarray(0, -AES_TAG_BYTES)),
decipher.final(),
]);
return JSON.parse(plaintext.toString("utf8")) as unknown;
}
function badEnvelope() {
return new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid sealed credential", "CONNECTOR_BAD_RESPONSE");
}
function sealPurpose(
kind: "initial" | "access",
_profile: GoogleWorkspaceConnectorProfileId,
_profile: PaperclipCloudConnectorProfileId,
): SealedEnvelope["purpose"] {
return kind;
}
function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): {
provider: PaperclipCloudConnectorProvider;
scopes: readonly string[];
} {
if (isGitHubConnectorProfileId(profile)) {
return { provider: "github", scopes: GITHUB_CONNECTOR_PROFILES[profile].scopes };
}
return { provider: "google", scopes: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes };
}
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value);
}
async function sha256Base64Url(value: string): Promise<string> {
return createHash("sha256").update(value, "utf8").digest("base64url");
}

View File

@ -15,6 +15,7 @@ import {
toolConnections,
toolCallEvents,
toolInvocations,
toolMcpGateways,
toolPolicies,
toolProfileBindings,
toolProfileEntries,
@ -1012,7 +1013,22 @@ export function toolAccessPolicyService(db: Db) {
eq(toolProfiles.companyId, ctx.companyId),
inArray(toolProfiles.id, candidateProfileIds),
));
const activeBindings = effectiveToolProfileBindings(matchingBindings, candidateProfiles, ctx.connectionId);
const [gateway] = ctx.gatewayId
? await db
.select({ defaultProfileMode: toolMcpGateways.defaultProfileMode })
.from(toolMcpGateways)
.where(and(
eq(toolMcpGateways.companyId, ctx.companyId),
eq(toolMcpGateways.id, ctx.gatewayId),
))
.limit(1)
: [];
const activeBindings = effectiveToolProfileBindings(
matchingBindings,
candidateProfiles,
ctx.connectionId,
{ includeAdditiveAppProfiles: gateway?.defaultProfileMode !== "gateway_only" },
);
const profileIds = profileIdsInBindingOrder(activeBindings);
const profilesById = new Map(candidateProfiles.map((profile) => [profile.id, profile]));
const activeProfiles = profileIds

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,9 @@ import type {
UpdateToolMcpGateway,
} from "@paperclipai/shared";
import {
isGitHubConnectorProfileId,
isGoogleWorkspaceConnectorProfileId,
type GitHubConnectorProfileId,
type GoogleWorkspaceConnectorProfileId,
} from "@paperclipai/shared";
import type { AgentToolDescriptor, PluginToolDispatcher } from "./plugin-tool-dispatcher.js";
@ -122,10 +124,11 @@ const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
const DEFAULT_TOOL_TIMEOUT_MS = 10_000;
export function resolveCredentialGrantKind(
policy: "shared" | "per_user" | "per_user_with_fallback",
policy: "shared" | "per_user" | "per_user_with_fallback" | "per_agent",
actingUserId: string | null,
hasUserGrant: boolean,
): "organization" | "user" | "user_authorization_required" {
if (policy === "per_agent") return "user_authorization_required";
if (policy === "shared") return "organization";
if (actingUserId && hasUserGrant) return "user";
return policy === "per_user" ? "user_authorization_required" : "organization";
@ -2636,33 +2639,46 @@ export function createToolGatewayService(
return resolved.value;
}
async function maybeRefreshPaperclipCloudGoogleGrant(
async function maybeRefreshPaperclipCloudGrant(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
forceRefresh = false,
): Promise<typeof connectionGrants.$inferSelect> {
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (!oauth || !isPaperclipCloudConnectorStrategy(oauth.strategy)) return grant;
const configuredProfile = oauth.connectorProfile;
const connectorProfile: GoogleWorkspaceConnectorProfileId = configuredProfile === undefined
const connectorProfile: GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId = configuredProfile === undefined
? "gmail.draft"
: typeof configuredProfile === "string" && isGoogleWorkspaceConnectorProfileId(configuredProfile)
: typeof configuredProfile === "string" && (
isGoogleWorkspaceConnectorProfileId(configuredProfile) || isGitHubConnectorProfileId(configuredProfile)
)
? configuredProfile
: (() => {
throw new ToolGatewayHttpError(422, "Google authorization has an invalid connector profile", "google_connector_profile_invalid", {
throw new ToolGatewayHttpError(422, "Managed authorization has an invalid connector profile", "connector_profile_invalid", {
connectionId: connection.id,
grantId: grant.id,
});
})();
const connectorSubject = typeof oauth.connectorSubjectUserId === "string"
const connectorSubject = typeof oauth.connectorSubjectAgentId === "string"
? `agent:${oauth.connectorSubjectAgentId}`
: typeof oauth.connectorSubjectUserId === "string"
? oauth.connectorSubjectUserId
: grant.subjectUserId;
: grant.kind === "agent" && grant.subjectAgentId
? `agent:${grant.subjectAgentId}`
: grant.subjectUserId;
const grantOauth = asRecord(asRecord(grant.providerTenant)?.oauth);
const expiresAt = typeof grantOauth?.accessTokenExpiresAt === "string"
? Date.parse(grantOauth.accessTokenExpiresAt)
: Number.NaN;
const currentTime = options.now?.() ?? Date.now();
if (Number.isFinite(expiresAt) && expiresAt > currentTime + 60_000) return grant;
// The preferred GitHub App policy yields a non-expiring ghu_ token and no
// refresh token. Absence of an expiry is deliberate, not an invitation to
// enter the rotation path.
if (grantOauth?.accessTokenExpiresAt === null || grantOauth?.accessTokenExpiresAt === undefined) return grant;
const refreshedAt = typeof grantOauth.refreshedAt === "string" ? Date.parse(grantOauth.refreshedAt) : Number.NaN;
const rotationDue = !Number.isFinite(refreshedAt) || refreshedAt <= currentTime - 30 * 24 * 60 * 60_000;
if (!forceRefresh && Number.isFinite(expiresAt) && expiresAt > currentTime + 60 * 60_000 && !rotationDue) return grant;
if (oauth.strategy === "paperclip_id_connector") {
// Paperclip ID used different endpoints, signing metadata, envelope
// purposes, and a different Google client. Its refresh token cannot be
@ -2671,7 +2687,7 @@ export function createToolGatewayService(
// and provider reconnect instead of sending it to the wrong client.
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(currentTime) })
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(409, "Legacy Google authorization must be reconnected through Paperclip Cloud", "google_reauthorization_required", {
throw new ToolGatewayHttpError(409, "Legacy authorization must be reconnected through Paperclip Cloud", "connector_reauthorization_required", {
connectionId: connection.id,
grantId: grant.id,
});
@ -2683,7 +2699,7 @@ export function createToolGatewayService(
if (!cloudConnector || !connectorSubject) {
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(currentTime) })
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(409, "Google authorization must be reconnected", "google_reauthorization_required", {
throw new ToolGatewayHttpError(409, "Managed authorization must be reconnected", "connector_reauthorization_required", {
connectionId: connection.id,
grantId: grant.id,
});
@ -2693,7 +2709,7 @@ export function createToolGatewayService(
if (!accessRef || !refreshRef) {
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(currentTime) })
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(409, "Google authorization must be reconnected", "google_reauthorization_required", {
throw new ToolGatewayHttpError(409, "Managed authorization must be reconnected", "connector_reauthorization_required", {
connectionId: connection.id,
grantId: grant.id,
});
@ -2718,13 +2734,15 @@ export function createToolGatewayService(
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
scopes: credentials.scopes,
tokenType: credentials.tokenType,
refreshedAt: new Date(options.now?.() ?? Date.now()).toISOString(),
...(credentials.refreshTokenExpiresAt ? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt } : {}),
},
};
const [updated] = await db.update(connectionGrants).set({ providerTenant, updatedAt: new Date(options.now?.() ?? Date.now()) })
.where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.status, "active")))
.returning();
if (!updated) {
throw new ToolGatewayHttpError(409, "Google authorization is no longer active", "google_reauthorization_required", {
throw new ToolGatewayHttpError(409, "Managed authorization is no longer active", "connector_reauthorization_required", {
connectionId: connection.id,
grantId: grant.id,
});
@ -2735,12 +2753,12 @@ export function createToolGatewayService(
if (error instanceof PaperclipCloudConnectorError && error.code === "REAUTHORIZATION_REQUIRED") {
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(options.now?.() ?? Date.now()) })
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(409, "Google authorization must be reconnected", "google_reauthorization_required", {
throw new ToolGatewayHttpError(409, "Managed authorization must be reconnected", "connector_reauthorization_required", {
connectionId: connection.id,
grantId: grant.id,
});
}
throw new ToolGatewayHttpError(502, "Google authorization could not be refreshed", "google_refresh_failed", {
throw new ToolGatewayHttpError(502, "Managed authorization could not be refreshed", "connector_refresh_failed", {
connectionId: connection.id,
grantId: grant.id,
});
@ -2820,7 +2838,7 @@ export function createToolGatewayService(
session,
connection,
responsibleUserId,
grant.kind,
grant.kind === "user" ? "user" : "organization",
);
}
}
@ -2846,12 +2864,15 @@ export function createToolGatewayService(
});
}
}
grant = await maybeRefreshPaperclipCloudGoogleGrant(session, connection, grant);
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (isPaperclipCloudConnectorStrategy(oauth?.strategy) && !options.oauthGrantRefresher) {
// Compatibility fallback for isolated service consumers. The production
// app supplies tool-access's lease/CAS refresher below.
grant = await maybeRefreshPaperclipCloudGrant(session, connection, grant, resolveOptions.forceRefresh === true);
}
if (
connection.authKind === "oauth"
&& connection.credentialSource === "paperclip_vault"
&& !isPaperclipCloudConnectorStrategy(oauth?.strategy)
&& options.oauthGrantRefresher
) {
try {
@ -3089,71 +3110,6 @@ export function createToolGatewayService(
});
}
async function createStandingDelegationInteraction(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
userId: string,
) {
if (!session.issueId || !session.agentId || !session.runId) return;
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies)
.where(eq(companies.id, session.companyId)).limit(1);
const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/permissions`;
const idempotencyKey = `connection-delegation:${connection.id}:${userId}:${session.agentId}`;
const payload = {
version: 1 as const,
prompt: `Allow this agent to use your ${connection.name} account for autonomous runs`,
acceptLabel: "Review delegation",
rejectLabel: "Not now",
detailsMarkdown: "This autonomous run is paused. Paperclip will not use your personal identity until you explicitly delegate it to this named agent.",
target: {
type: "custom" as const,
key: `connection:${connection.uid}:delegation:${userId}:${session.agentId}`,
revisionId: connection.updatedAt.toISOString(),
label: `Delegate ${connection.name}`,
href,
},
};
const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, session.companyId),
eq(issueThreadInteractions.issueId, session.issueId),
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
)).limit(1);
if (existing) {
await db.update(issueThreadInteractions).set({
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
addresseeUserId: userId,
payload,
result: null,
resolvedAt: null,
updatedAt: new Date(),
}).where(eq(issueThreadInteractions.id, existing.id));
return;
}
await db.insert(issueThreadInteractions).values({
companyId: session.companyId,
issueId: session.issueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
idempotencyKey,
sourceRunId: session.runId,
title: `Delegate your ${connection.name}`,
summary: "An explicit standing delegation is required for this autonomous run.",
createdByAgentId: session.agentId,
addresseeUserId: userId,
payload,
});
}
async function resolveConnectionGrant(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
@ -3231,35 +3187,73 @@ export function createToolGatewayService(
return grant;
};
const userGrant = connection.credentialPolicy === "shared" ? undefined : await findUserGrant();
const resolution = resolveCredentialGrantKind(connection.credentialPolicy, actingUserId, Boolean(userGrant));
if (resolution === "user" && userGrant) {
if (autonomous) {
if (!session.agentId) {
throw new ToolGatewayHttpError(409, "Standing delegation requires a named agent", "standing_delegation_required", {
connectionId: connection.id,
grantId: userGrant.id,
actingUserId,
});
}
const [delegation] = await db.select({ id: connectionGrantDelegations.id }).from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.grantId, userGrant.id),
eq(connectionGrantDelegations.agentId, session.agentId),
if (connection.credentialPolicy === "per_agent") {
if (!session.agentId) {
throw new ToolGatewayHttpError(409, "A dedicated agent authorization is required", "agent_authorization_required", {
connectionId: connection.id,
});
}
const [agentGrant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "agent"),
eq(connectionGrants.subjectAgentId, session.agentId),
eq(connectionGrants.status, "active"),
)).limit(1);
if (!agentGrant) {
throw new ToolGatewayHttpError(409, "This agent's dedicated authorization is not connected", "agent_authorization_required", {
connectionId: connection.id,
agentId: session.agentId,
});
}
return agentGrant;
}
// The owner-selected connection install is the consent boundary for agent use.
// `responsibleUserId` is resolved and persisted by the control plane, never
// accepted from agent input, so a run carrying it uses that owner's grant
// directly. Delegation is reserved for genuinely ownerless unattended runs.
let userGrant = connection.credentialPolicy === "shared" ? undefined : await findUserGrant();
if (!userGrant && !actingUserId && autonomous && session.agentId && connection.credentialPolicy !== "shared") {
const delegated = await db.select({ grant: connectionGrants }).from(connectionGrantDelegations).innerJoin(
connectionGrants,
and(
eq(connectionGrants.id, connectionGrantDelegations.grantId),
eq(connectionGrants.companyId, connectionGrantDelegations.companyId),
),
).where(and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.agentId, session.agentId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.status, "active"),
));
if (delegated.length > 1) {
throw new ToolGatewayHttpError(409, "More than one delegated personal authorization matches this autonomous run", "ambiguous_personal_grant", {
connectionId: connection.id,
agentId: session.agentId,
});
}
userGrant = delegated[0]?.grant;
if (userGrant?.subjectUserId) {
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userGrant.subjectUserId),
eq(companyMemberships.status, "active"),
)).limit(1);
if (!delegation) {
await createStandingDelegationInteraction(session, connection, actingUserId!);
throw new ToolGatewayHttpError(409, "Standing delegation is required for this autonomous run", "standing_delegation_required", {
if (!membership) {
throw new ToolGatewayHttpError(403, "The delegated personal grant owner is not an active company member", "grant_owner_membership_inactive", {
connectionId: connection.id,
grantId: userGrant.id,
actingUserId,
agentId: session.agentId,
remediation: { action: "delegate_personal_grant", grantId: userGrant.id, agentId: session.agentId },
});
}
}
return userGrant;
}
const resolution = userGrant
? "user"
: resolveCredentialGrantKind(connection.credentialPolicy, actingUserId, false);
if (resolution === "user" && userGrant) return userGrant;
if (resolution === "user_authorization_required") {
if (actingUserId) await createUserAuthorizationInteraction(session, connection, actingUserId);
throw new ToolGatewayHttpError(409, "User authorization is required", "user_authorization_required", {
@ -4184,6 +4178,33 @@ export function createToolGatewayService(
response = await dispatchRemote(endpoint, retryInit);
}
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (
response.status === 401
&& connection.authKind === "oauth"
&& connection.credentialSource === "paperclip_vault"
&& isPaperclipCloudConnectorStrategy(oauth?.strategy)
) {
credentialHeaders = {
...projectedConnectionHeaders(connection),
...await resolveCredentialHeaders(session, connection, grant, { forceRefresh: true }),
};
builtHeaders = buildRemoteHeaders({ session, connection, credentialHeaders, callerHeaders });
headers = builtHeaders.headers;
headerSummary = builtHeaders.summary;
response = await dispatchRemote(endpoint, {
...requestInit,
headers: mcpHttpRequestHeaders(headers),
});
if (response.status === 401) {
await db.update(connectionGrants).set({
status: "needs_reauthorization",
updatedAt: new Date(options.now?.() ?? Date.now()),
}).where(and(
eq(connectionGrants.id, grant.id),
eq(connectionGrants.companyId, connection.companyId),
));
}
}
if (
response.status === 401
&& connection.authKind === "oauth"

View File

@ -64,6 +64,37 @@ describe("tool profile binding precedence", () => {
)).toEqual([agentBinding, appBinding]);
});
it("does not carry wizard-managed app assignments into a gateway-only profile", () => {
const appBinding = {
profileId: "app-profile",
targetType: "company" as const,
targetId: "company-1",
priority: 100,
createdAt,
};
const gatewayBinding = {
profileId: "gateway-profile",
targetType: "gateway" as const,
targetId: "gateway-1",
priority: 10,
createdAt,
};
expect(effectiveToolProfileBindings(
[appBinding, gatewayBinding],
[
{
id: "app-profile",
profileKey: "app:connection-1",
metadata: { source: "app_gallery_finish", connectionId: "connection-1" },
},
{ id: "gateway-profile", profileKey: "runtime-gateway", metadata: {} },
],
"connection-1",
{ includeAdditiveAppProfiles: false },
)).toEqual([gatewayBinding]);
});
it("does not overlay a wizard profile onto another connection", () => {
const appBinding = {
profileId: "app-profile",

View File

@ -75,7 +75,11 @@ export function effectiveToolProfileBindings<T extends BindingLike>(
bindings: T[],
profiles: ProfileLike[],
connectionId?: string | null,
options?: { includeAdditiveAppProfiles?: boolean },
): T[] {
if (options?.includeAdditiveAppProfiles === false) {
return narrowestScopeBindings(bindings);
}
const appProfileIds = new Set(
profiles.filter((profile) => isWizardAppProfile(profile, connectionId)).map((profile) => profile.id),
);

View File

@ -171,7 +171,7 @@
{
"slug": "github",
"provider": "GitHub",
"catalogVisible": false,
"catalogVisible": true,
"localAsset": "/brands/apps/github.svg",
"darkAsset": "/brands/apps/github-dark.svg",
"officialSourceUrl": "https://github.com/logos",

View File

@ -306,6 +306,7 @@ export const toolsApi = {
connectionId: string,
input: {
asCurrentUser?: boolean;
asAgentId?: string;
interactionId?: string;
} = {},
) =>

View File

@ -96,6 +96,28 @@ import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayloa
type Step = "gallery" | "access" | "key" | "success";
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
function githubRecoveryUrl(value: string | null): string | null {
if (!value) return null;
try {
const url = new URL(value);
return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com"
? url.toString()
: null;
} catch {
return null;
}
}
function oauthCallbackErrorMessage(outcome: string | null, code: string | null): string {
if (outcome === "denied") {
return "Authorization was cancelled or declined. Your saved connection was not changed.";
}
if (code === "github_installation_required") {
return "GitHub access is required. Install Paperclip and grant at least one repository, then try again.";
}
return "Authorization did not complete. Your saved connection is still here, so you can try again.";
}
const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
access: "access",
key: "setup",
@ -226,9 +248,17 @@ const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"];
*/
function defaultGrantKindFor(method: ConnectionMethodDef | null): ConnectionGrantKind {
if (method?.grantKinds?.length === 1) return method.grantKinds[0]!;
if (method?.grantKinds && !method.grantKinds.includes("organization")) return method.grantKinds[0]!;
return "organization";
}
function configuredAgentIdentity(connection: ToolConnection): string | undefined {
const oauth = connection.config?.oauth;
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) return undefined;
const value = (oauth as Record<string, unknown>).connectorSubjectAgentId;
return typeof value === "string" ? value : undefined;
}
function isGoogleSheetsRobotMethod(
entry: AppDefinition | null,
method: ConnectionMethodDef | string | null | undefined,
@ -263,8 +293,11 @@ function recommendedSetupConnectionMethod(
const recommended = getRecommendedConnectionMethod(methods);
// Capability choices (for example Google Workspace read versus write) have
// an intentional default. Unrelated region/authentication variants should
// still ask the operator to choose unless only one is available.
return methods.length === 1 || recommended?.capabilityProfile
// still ask the operator to choose unless only one is available. A method
// that supports an agent-owned identity must also be selected before the
// Access step: that ownership decision cannot be represented by a legacy
// compatibility method such as GitHub's advanced PAT option.
return methods.length === 1 || recommended?.capabilityProfile || recommended?.grantKinds?.includes("agent")
? recommended
: null;
}
@ -409,6 +442,9 @@ export function ConnectionSetupFlow({
const createNewConnection = searchParams.get("new") === "1";
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
const oauthCallbackOutcome = searchParams.get("oauth");
const oauthCallbackCode = searchParams.get("code");
const githubInstallationUrl = githubRecoveryUrl(searchParams.get("installation_url"));
const githubManagementUrl = githubRecoveryUrl(searchParams.get("management_url"));
const reconnectConnectionId = searchParams.get("reconnect")?.trim() || null;
const reconnectGrantKindHint: ConnectionGrantKind | null = searchParams.get("identity") === "user"
? "user"
@ -495,9 +531,7 @@ export function ConnectionSetupFlow({
);
const [oauthError, setOAuthError] = useState<string | null>(() => {
if (!resumingAfterOAuthFailure) return null;
return oauthCallbackOutcome === "denied"
? "Authorization was cancelled or declined. Your saved connection was not changed."
: "Authorization did not complete. Your saved connection is still here, so you can try again.";
return oauthCallbackErrorMessage(oauthCallbackOutcome, oauthCallbackCode);
});
/** Host of the page the operator is about to be sent to, shown while redirecting. */
const [authorizationHost, setAuthorizationHost] = useState<string | null>(null);
@ -792,6 +826,8 @@ export function ConnectionSetupFlow({
const reconnectGrantKind: ConnectionGrantKind | null = identityConnection
? identityConnection.credentialPolicy === "per_user"
? "user"
: identityConnection.credentialPolicy === "per_agent"
? "agent"
: "organization"
: reconnectGrantKindHint;
const resumableOAuthConnection = resumeConnection?.authKind === "oauth"
@ -800,6 +836,8 @@ export function ConnectionSetupFlow({
const existingOAuthGrantKind: ConnectionGrantKind | null = existingOAuthConnection
? existingOAuthConnection.credentialPolicy === "per_user"
? "user"
: existingOAuthConnection.credentialPolicy === "per_agent"
? "agent"
: "organization"
: null;
const fixedGrantKind = reconnectGrantKind ?? existingOAuthGrantKind;
@ -839,6 +877,9 @@ export function ConnectionSetupFlow({
// scoped, while a personal one must put its token back on that user grant.
mutationFn: (connection: ToolConnection) => toolsApi.startOAuth(connection.id, {
asCurrentUser: connection.credentialPolicy === "per_user",
...(connection.credentialPolicy === "per_agent"
? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] }
: {}),
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
}),
onSuccess: (start) => void prepareAndOpenOAuth(start),
@ -870,7 +911,8 @@ export function ConnectionSetupFlow({
* apply the same selection.
*/
const applyAccessInstalls = async (connectionId: string) => {
const installState = installChoice === "all"
const dedicatedIdentity = (fixedGrantKind ?? grantKind) === "agent";
const installState = !dedicatedIdentity && installChoice === "all"
? { onAll: true, agentIds: new Set<string>() }
: { onAll: false, agentIds: installAgentIds };
await toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, installState));
@ -916,7 +958,8 @@ export function ConnectionSetupFlow({
: undefined,
applicationId: prefill.applicationId,
...(resumeConnectionId ? { resumeConnectionId } : {}),
...(requestedGrantKind === "user" ? { grantKind: requestedGrantKind } : {}),
...(requestedGrantKind !== "organization" ? { grantKind: requestedGrantKind } : {}),
...(requestedGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}),
});
} else {
const genericPayload = genericConnectPayload({
@ -947,7 +990,8 @@ export function ConnectionSetupFlow({
: {}),
name: connectionName,
applicationId: prefill.applicationId,
...(effectiveGrantKind === "user" ? { grantKind: effectiveGrantKind } : {}),
...(effectiveGrantKind !== "organization" ? { grantKind: effectiveGrantKind } : {}),
...(effectiveGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}),
});
}
// A resumable draft already owns its identity and install reach. Replacing
@ -1226,11 +1270,7 @@ export function ConnectionSetupFlow({
// intentionally blank.
if (oauthCallbackOutcome === "failed" || oauthCallbackOutcome === "denied") {
setOAuthPhase("error");
setOAuthError(
oauthCallbackOutcome === "denied"
? "Authorization was cancelled or declined. Your saved connection was not changed."
: "Authorization did not complete. Your saved connection is still here, so you can try again.",
);
setOAuthError(oauthCallbackErrorMessage(oauthCallbackOutcome, oauthCallbackCode));
} else {
setOAuthPhase("entry");
setOAuthError(null);
@ -1238,7 +1278,7 @@ export function ConnectionSetupFlow({
setStep("key");
hydratedResumeConnectionIdRef.current = resumeConnection.id;
setHydratedResumeConnectionId(resumeConnection.id);
}, [credentialSource, entry, oauthCallbackOutcome, resumeConnection]);
}, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection]);
/**
* Commit the connection: action defaults, agent reach, and installs.
@ -1510,6 +1550,10 @@ export function ConnectionSetupFlow({
resuming={Boolean(resumeConnectionId)}
phase={oauthPhase}
error={oauthError}
recoveryActions={oauthCallbackCode === "github_installation_required" ? {
installationUrl: githubInstallationUrl,
managementUrl: githubManagementUrl,
} : undefined}
authorizationHost={authorizationHost}
onRetry={async () => {
setOAuthError(null);
@ -1550,7 +1594,13 @@ export function ConnectionSetupFlow({
);
if (!directOAuthAccessConfirmedRef.current && !resumeConnectionId) {
if (refreshedConnection) {
setGrantKind(refreshedConnection.credentialPolicy === "per_user" ? "user" : "organization");
setGrantKind(
refreshedConnection.credentialPolicy === "per_user"
? "user"
: refreshedConnection.credentialPolicy === "per_agent"
? "agent"
: "organization",
);
}
setOAuthPhase("entry");
setOAuthError(null);
@ -1913,6 +1963,7 @@ export function ConnectionSetupFlow({
setInstallAgentIds={setInstallAgentIds}
lockedAgentId={requestedAgentId}
capabilities={galleryQuery.data?.capabilities}
githubIdentity={entry?.slug === "github"}
submitLabel={accessSubmitLabel}
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
@ -2017,6 +2068,7 @@ export function OAuthConnectStateScreen({
resuming = false,
phase,
error,
recoveryActions,
authorizationHost,
onRetry,
onBack,
@ -2030,6 +2082,7 @@ export function OAuthConnectStateScreen({
resuming?: boolean;
phase: OAuthConnectPhase;
error?: string | null;
recoveryActions?: { installationUrl: string | null; managementUrl: string | null };
/**
* Host of the authorization page being opened. A valid HTTPS authorization
* page can still be a phishing page, so the operator sees exactly which host
@ -2097,6 +2150,26 @@ export function OAuthConnectStateScreen({
</div>
</div>
{phase === "error" && recoveryActions && (recoveryActions.installationUrl || recoveryActions.managementUrl) ? (
<div className="mt-4 flex flex-wrap gap-2">
{recoveryActions.installationUrl ? (
<Button type="button" variant="outline" asChild>
<a href={recoveryActions.installationUrl} target="_blank" rel="noreferrer">
Install Paperclip on GitHub
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</a>
</Button>
) : null}
{recoveryActions.managementUrl ? (
<Button type="button" variant="ghost" asChild>
<a href={recoveryActions.managementUrl} target="_blank" rel="noreferrer">
Manage repositories on GitHub
</a>
</Button>
) : null}
</div>
) : null}
<div className="mt-6 flex items-center gap-2">
{phase === "error" || phase === "entry" ? (
<Button type="button" onClick={onRetry}>
@ -3337,6 +3410,7 @@ export function AccessStep({
setInstallAgentIds,
lockedAgentId,
capabilities,
githubIdentity = false,
submitLabel,
identityLoading = false,
preserveAgentAccess = false,
@ -3360,6 +3434,7 @@ export function AccessStep({
companyInstallReason?: string | null;
editableAgentIds?: string[];
} | null;
githubIdentity?: boolean;
submitLabel: string;
/** Wait for a durable OAuth connection before showing a reconnect identity. */
identityLoading?: boolean;
@ -3390,11 +3465,14 @@ export function AccessStep({
const allowedGrantKinds = grantKinds ?? (["user", "organization"] satisfies ConnectionGrantKind[]);
const identityChoiceAllowed = !needsIdentityChoice
|| grantKind === "user"
|| grantKind === "agent"
|| canCreateOrganizationGrant;
const canContinue = identityChoiceAllowed && (preserveAgentAccess
? true
: lockedAgentId
? installAgentIds.has(lockedAgentId)
: grantKind === "agent"
? installChoice === "specific" && installAgentIds.size === 1
: installChoice === "all"
? canSetCompanyInstall
: installAgentIds.size > 0);
@ -3407,7 +3485,7 @@ export function AccessStep({
<div className="overflow-hidden rounded-xl border border-border">
<div className="divide-y divide-border">
<section className="p-6">
<h2 className="text-sm font-semibold text-foreground">Which humans can use this credential?</h2>
<h2 className="text-sm font-semibold text-foreground">{githubIdentity ? "Which GitHub identity should this use?" : "Which humans can use this credential?"}</h2>
{identityLoading ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
<Skeleton className="h-20 w-full rounded-md" />
@ -3417,11 +3495,17 @@ export function AccessStep({
<div className="mt-4 flex items-center gap-3 rounded-md border border-border p-4">
{allowedGrantKinds[0] === "user" ? (
<UserRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
) : allowedGrantKinds[0] === "agent" ? (
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
) : (
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<div className="text-sm font-medium text-foreground">
{allowedGrantKinds[0] === "user" ? "Just me" : "Any human in the company"}
{allowedGrantKinds[0] === "user"
? githubIdentity ? "My GitHub account" : "Just me"
: allowedGrantKinds[0] === "agent"
? "A dedicated account for an agent"
: "Any human in the company"}
</div>
</div>
) : needsIdentityChoice ? (
@ -3429,13 +3513,25 @@ export function AccessStep({
ariaLabel="Which humans can use this credential?"
className="mt-4 sm:grid-cols-2"
value={grantKind}
onValueChange={(next) => setGrantKind(next as ConnectionGrantKind)}
onValueChange={(next) => {
const kind = next as ConnectionGrantKind;
setGrantKind(kind);
if (kind === "agent") {
setInstallChoice("specific");
if (installAgentIds.size > 1) setInstallAgentIds(new Set([[...installAgentIds][0]!]));
}
}}
options={[
{
value: "user",
title: "Just me",
title: githubIdentity ? "My GitHub account" : "Just me",
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
},
{
value: "agent",
title: "A dedicated account for an agent",
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
},
{
value: "organization",
title: "Any human in the company",
@ -3460,7 +3556,7 @@ export function AccessStep({
</section>
<section className="p-6">
<h2 className="text-sm font-semibold text-foreground">Which agents can use this connection?</h2>
<h2 className="text-sm font-semibold text-foreground">{grantKind === "agent" ? "Which agent owns this GitHub account?" : "Which agents can use this connection?"}</h2>
{preserveAgentAccess ? (
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
@ -3477,7 +3573,9 @@ export function AccessStep({
Existing connection access is left unchanged.
</p>
) : (
<RadioCardGroup
grantKind === "agent" ? (
<p className="mt-2 text-sm text-muted-foreground">Choose exactly one agent. This identity cannot be shared with other agents.</p>
) : <RadioCardGroup
ariaLabel="Which agents can use this connection?"
className="mt-4 sm:grid-cols-2"
value={installChoice}
@ -3510,7 +3608,9 @@ export function AccessStep({
<AgentMultiSelect
agents={agents}
selectedAgentIds={installAgentIds}
onChange={setInstallAgentIds}
onChange={(next) => setInstallAgentIds(
grantKind === "agent" && next.size > 1 ? new Set([[...next].at(-1)!]) : next,
)}
loading={agentsQuery.isLoading}
emptyMessage="You cannot edit any agents yet."
showSelectionPreview={false}
@ -3565,6 +3665,8 @@ export function accessSummaryLines(input: {
? "No identity required"
: input.grantKind === "user"
? "Your identity"
: input.grantKind === "agent"
? "Dedicated agent identity"
: "Organization identity";
const availableTo = input.installChoice === "all"
? "Any agent"

View File

@ -16,6 +16,7 @@ const mockToolsApi = vi.hoisted(() => ({
listConnections: vi.fn(),
listPolicies: vi.fn(),
listCatalog: vi.fn(),
listConnectionGrants: vi.fn(),
listAudit: vi.fn(),
putConnectionInstalls: vi.fn(),
}));
@ -111,7 +112,15 @@ describe("AgentToolsTab", () => {
mockToolsApi.listConnections.mockReset();
mockToolsApi.listPolicies.mockReset();
mockToolsApi.listCatalog.mockReset();
mockToolsApi.listConnectionGrants.mockReset();
mockToolsApi.putConnectionInstalls.mockReset();
mockToolsApi.listConnectionGrants.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [],
currentUserId: "user-1",
members: [],
capabilities: {},
});
mockToolsApi.putConnectionInstalls.mockResolvedValue({ connectionId: "conn-1", installs: [] });
});
@ -269,6 +278,134 @@ describe("AgentToolsTab", () => {
const text = container.textContent ?? "";
expect(text).toContain("No tools are allowed for this agent");
expect(text).toContain("No active profile applies");
expect(text).toContain("Use responsible person's GitHub");
expect(container.querySelector('a[href="/apps/connect?source=github"]')?.textContent).toBe("Connect my GitHub");
});
it("shows an active dedicated GitHub identity as the agent override", async () => {
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [],
bindings: [],
allowedTools: [],
allowedToolNames: [],
installedConnections: [],
} satisfies ToolProfileEffectiveSummary);
mockToolsApi.listConnections.mockResolvedValue({
connections: [{
id: "conn-github",
companyId: "company-1",
name: "Agent GitHub",
enabled: true,
status: "active",
config: { sourceTemplateKey: "github" },
transportConfig: {},
installs: [{ targetType: "company", targetId: "company-1" }],
}],
});
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [] });
mockToolsApi.listConnectionGrants.mockResolvedValue({
connection: { id: "conn-github", uid: "conn-github" },
grants: [{
id: "grant-agent",
kind: "agent",
subjectAgentId: "agent-1",
subjectUserId: null,
status: "active",
providerTenant: {
github: {
userId: "123",
login: "dottabot",
installationCount: 1,
repositoryCount: 1,
repositorySelection: "selected",
installationIds: ["456"],
installationOwnerLogins: ["paperclipai"],
},
},
}],
currentUserId: "user-1",
members: [],
capabilities: {},
});
await renderTab();
const text = container.textContent ?? "";
expect(text).toContain("@dottabot");
expect(text).toContain("takes precedence over the responsible person's GitHub");
expect(container.querySelector('a[href="/apps/conn-github/permissions"]')?.textContent).toBe("Manage GitHub identity");
expect(text).not.toContain("Connect my GitHub");
});
it("does not display a grant from a disabled or uninstalled GitHub connection", async () => {
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [],
bindings: [],
allowedTools: [],
allowedToolNames: [],
installedConnections: [],
} satisfies ToolProfileEffectiveSummary);
mockToolsApi.listConnections.mockResolvedValue({
connections: [{
id: "conn-github",
companyId: "company-1",
name: "Disabled GitHub",
enabled: false,
status: "active",
config: { sourceTemplateKey: "github" },
transportConfig: {},
installs: [{ targetType: "company", targetId: "company-1" }],
}],
});
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [] });
await renderTab();
const text = container.textContent ?? "";
expect(mockToolsApi.listConnectionGrants).not.toHaveBeenCalled();
expect(text).toContain("Use responsible person's GitHub");
expect(text).not.toContain("@dottabot");
});
it("shows a retryable error instead of connection setup when grants cannot be loaded", async () => {
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [],
bindings: [],
allowedTools: [],
allowedToolNames: [],
installedConnections: [],
} satisfies ToolProfileEffectiveSummary);
mockToolsApi.listConnections.mockResolvedValue({
connections: [{
id: "conn-github",
companyId: "company-1",
name: "Agent GitHub",
enabled: true,
status: "active",
config: { sourceTemplateKey: "github" },
transportConfig: {},
installs: [{ targetType: "company", targetId: "company-1" }],
}],
});
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [] });
mockToolsApi.listConnectionGrants.mockRejectedValue(new Error("temporary failure"));
await renderTab();
const text = container.textContent ?? "";
expect(text).toContain("Could not load GitHub identity");
expect(text).toContain("Your existing setup was not changed");
expect(text).not.toContain("Connect my GitHub");
expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Retry")).toBe(true);
});
it("autosaves installed apps for the current agent", async () => {

View File

@ -3,6 +3,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea
import { HelpCircle, PackageCheck } from "lucide-react";
import type {
AgentDetail as AgentDetailRecord,
ConnectionGrant,
ToolCatalogEntry,
ToolConnection,
ToolPolicy,
@ -11,6 +12,8 @@ import { Link } from "@/lib/router";
import { queryKeys } from "../lib/queryKeys";
import { toolsApi } from "../api/tools";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { GithubIcon } from "@/components/icons/github-icon";
import { InlineBanner } from "@/components/InlineBanner";
import { EnforcementBanner } from "../components/EnforcementBanner";
import {
@ -23,6 +26,109 @@ import { cn } from "../lib/utils";
import { brandChipBadge } from "../lib/status-colors";
import { installPayload, installStateFrom, isAgentInstalled, INSTALLED_HINT } from "../lib/tool-installs";
function isGitHubConnection(connection: ToolConnection): boolean {
return (connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey) === "github";
}
function isEligibleGitHubIdentityConnection(connection: ToolConnection, agentId: string): boolean {
return isGitHubConnection(connection)
&& connection.enabled
&& connection.status === "active"
&& isAgentInstalled(installStateFrom(connection.installs), agentId);
}
function GitHubIdentitySection({
agentName,
dedicatedIdentity,
personalIdentity,
loading,
loadError,
onRetry,
}: {
agentName: string;
dedicatedIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null;
personalIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null;
loading: boolean;
loadError: boolean;
onRetry: () => void;
}) {
const dedicatedLogin = dedicatedIdentity?.grant.providerTenant?.github?.login;
const personalLogin = personalIdentity?.grant.providerTenant?.github?.login;
return (
<section className="rounded-lg border border-border bg-card">
<div className="flex flex-wrap items-start justify-between gap-3 px-3 py-3">
<div className="flex min-w-0 items-start gap-3">
<div className="rounded-md border border-border bg-muted/40 p-2 text-foreground">
<GithubIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">GitHub identity</h3>
{loading ? (
<p className="mt-0.5 text-xs text-muted-foreground">Checking GitHub identity</p>
) : loadError ? (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">Could not load GitHub identity</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Paperclip could not verify this agent&apos;s current connection. Your existing setup was not changed.
</p>
</>
) : dedicatedIdentity ? (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">
{dedicatedLogin ? `@${dedicatedLogin}` : "Dedicated GitHub account"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Used only by {agentName}. This dedicated connection takes precedence over the responsible person&apos;s GitHub.
</p>
</>
) : (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">Use responsible person&apos;s GitHub</p>
<p className="mt-0.5 text-xs text-muted-foreground">
At run start, Paperclip uses the personal GitHub connection of the person responsible for the task.
</p>
{personalIdentity ? (
<p className="mt-1 text-xs text-muted-foreground">
Your account is connected{personalLogin ? ` as @${personalLogin}` : ""}.
</p>
) : null}
</>
)}
</div>
</div>
{!loading ? (
<div className="flex flex-wrap items-center gap-2">
{loadError ? (
<Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>
) : dedicatedIdentity ? (
<Button variant="outline" size="sm" asChild>
<Link to={`/apps/${dedicatedIdentity.connection.id}/permissions`}>Manage GitHub identity</Link>
</Button>
) : (
<>
{personalIdentity ? (
<Button variant="outline" size="sm" asChild>
<Link to={`/apps/${personalIdentity.connection.id}/permissions`}>Manage my GitHub</Link>
</Button>
) : (
<Button size="sm" asChild>
<Link to="/apps/connect?source=github">Connect my GitHub</Link>
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to="/apps/connect?source=github">Use a dedicated account</Link>
</Button>
</>
)}
</div>
) : null}
</div>
</section>
);
}
/** Normalize a selector value (string or string[]) into a flat string list. */
function selectorStringList(value: unknown): string[] {
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
@ -249,6 +355,34 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
});
const connectionList = connectionsQuery.data?.connections ?? [];
const eligibleGitHubConnections = useMemo(
() => connectionList.filter((connection) => isEligibleGitHubIdentityConnection(connection, agent.id)),
[agent.id, connectionList],
);
const githubGrantQueries = useQueries({
queries: eligibleGitHubConnections.map((connection) => ({
queryKey: queryKeys.tools.connectionGrants(connection.id),
queryFn: () => toolsApi.listConnectionGrants(connection.id),
staleTime: 30_000,
})),
});
const githubIdentityRows = eligibleGitHubConnections.flatMap((connection, index) =>
(githubGrantQueries[index]?.data?.grants ?? []).map((grant) => ({
connection,
grant,
currentUserId: githubGrantQueries[index]?.data?.currentUserId,
})),
);
const dedicatedGitHubIdentity = githubIdentityRows.find(({ grant }) => (
grant.kind === "agent"
&& grant.subjectAgentId === agent.id
&& grant.status === "active"
)) ?? null;
const personalGitHubIdentity = githubIdentityRows.find(({ grant, currentUserId }) => (
grant.kind === "user"
&& grant.subjectUserId === currentUserId
&& grant.status === "active"
)) ?? null;
const connectionInstallSignature = useMemo(
() =>
connectionList
@ -441,6 +575,20 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
}
/>
<GitHubIdentitySection
agentName={agent.name}
dedicatedIdentity={dedicatedGitHubIdentity}
personalIdentity={personalGitHubIdentity}
loading={connectionsQuery.isLoading || githubGrantQueries.some((query) => query.isLoading)}
loadError={connectionsQuery.isError || githubGrantQueries.some((query) => query.isError)}
onRetry={() => {
if (connectionsQuery.isError) void connectionsQuery.refetch();
for (const query of githubGrantQueries) {
if (query.isError) void query.refetch();
}
}}
/>
<InstalledAppsSection
agentId={agent.id}
agentName={agent.name}

View File

@ -140,11 +140,18 @@ export function AppDetail() {
const currentUserPersonalGrant = grantRows.find((grant) => (
grant.kind === "user" && grant.subjectUserId === grantsQuery.data?.currentUserId
)) ?? null;
const retainedAgentGrant = connection?.credentialPolicy === "per_agent"
? grantRows.find((grant) => grant.kind === "agent" && grant.status === "active")
?? grantRows.find((grant) => grant.kind === "agent")
?? null
: null;
const retainedOrganizationGrant = grantRows.find((grant) => (
grant.kind === "organization" && grant.isDefault
)) ?? grantRows.find((grant) => grant.kind === "organization") ?? null;
const managedIdentityGrant = connection?.credentialPolicy === "per_user"
? retainedPersonalGrant
: connection?.credentialPolicy === "per_agent"
? retainedAgentGrant
: connection?.credentialPolicy === "per_user_with_fallback"
? currentUserPersonalGrant ?? retainedOrganizationGrant
: retainedOrganizationGrant;
@ -280,7 +287,7 @@ export function AppDetail() {
});
const startOAuth = useMutation({
mutationFn: () => toolsApi.startOAuth(connectionId),
mutationFn: (input?: { asAgentId?: string }) => toolsApi.startOAuth(connectionId, input),
onSuccess: async (start) => {
try {
const target = await prepareOAuthNavigation(start);
@ -392,6 +399,24 @@ export function AppDetail() {
tone: "error",
}),
});
const refreshGitHubAccess = useMutation({
mutationFn: () => toolsApi.checkConnectionHealth(connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
pushToast({
title: "GitHub access refreshed",
body: "Account, installation, and repository access are current.",
tone: "success",
});
},
onError: (error) => pushToast({
title: "Couldn't refresh GitHub access",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const apply = (mutate: {
enabled?: Set<string>;
@ -537,6 +562,9 @@ export function AppDetail() {
credentialPolicy={connection.credentialPolicy}
ownerUserId={connection.createdByUserId}
connectedUser={owner}
dedicatedAgent={managedIdentityGrant?.kind === "agent"
? agents.find((agent) => agent.id === managedIdentityGrant.subjectAgentId) ?? null
: null}
grantsQuery={grantsQuery.data}
loading={grantsQuery.isLoading}
error={grantsQuery.isError}
@ -554,6 +582,9 @@ export function AppDetail() {
}}
onConnectAsMe={() => startPersonalAuth.mutate()}
onConnectOrganization={() => startOAuth.mutate()}
onConnectAgent={(agentId) => startOAuth.mutate({ asAgentId: agentId })}
onRefreshAccess={() => refreshGitHubAccess.mutate()}
refreshAccessPending={refreshGitHubAccess.isPending}
onReplaceAudience={(grant, memberUserIds) =>
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
/>

View File

@ -319,7 +319,12 @@ function newConnectionHref({
const params = new URLSearchParams({ applicationId, name: appName, new: "1" });
if (previousConnection) {
params.set("reconnect", previousConnection.id);
params.set("identity", previousConnection.credentialPolicy === "per_user" ? "user" : "organization");
params.set("identity",
previousConnection.credentialPolicy === "per_user"
? "user"
: previousConnection.credentialPolicy === "per_agent"
? "agent"
: "organization");
}
if (sourceSlug) params.set("source", sourceSlug);
else params.set("byo", "1");

View File

@ -27,6 +27,13 @@ const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github")!;
const GITHUB_MANAGED = {
...GITHUB,
ownershipAvailability: {
...GITHUB.ownershipAvailability,
platform_shared: true,
},
};
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!;
const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!;
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
@ -37,6 +44,7 @@ const GOOGLE_CALENDAR = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "
const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!;
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!;
const COMPOSIO = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "composio")!;
vi.mock("@/api/tools", () => ({
toolsApi: {
@ -334,39 +342,39 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// credential is entered.
// -------------------------------------------------------------------------
it("asks both access questions and defaults to company-wide access", async () => {
it("asks for a GitHub identity and defaults to the current user and every agent", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
await render();
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which GitHub identity should this use?");
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).not.toContain("Choose access before adding credentials");
expect(container.textContent).not.toContain("Set the identity and agent reach first");
expect(container.textContent).not.toContain("Whose GitHub account should agents act as?");
expect(container.textContent).not.toContain("Which humans can use this credential?");
expect(container.textContent).not.toContain("Choose where this connection will be available.");
// Nothing about the credential itself is on screen yet.
expect(container.querySelector('input[type="password"]')).toBeNull();
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
const justMe = radios.find((r) => r.textContent?.includes("Just me"));
const wholeOrg = radios.find((r) => r.textContent?.includes("Any human in the company"));
const myAccount = radios.find((r) => r.textContent?.includes("My GitHub account"));
const dedicated = radios.find((r) => r.textContent?.includes("A dedicated account for an agent"));
const agentsIPick = radios.find((r) => r.textContent?.includes("Just agents I pick"));
const anyAgent = radios.find((r) => r.textContent?.includes("Any agent"));
expect(justMe).toBeTruthy();
expect(wholeOrg).toBeTruthy();
expect(justMe?.textContent).toBe("Just me");
expect(wholeOrg?.textContent).toBe("Any human in the company");
expect(myAccount).toBeTruthy();
expect(dedicated).toBeTruthy();
expect(myAccount?.textContent).toBe("My GitHub account");
expect(dedicated?.textContent).toBe("A dedicated account for an agent");
expect(agentsIPick?.textContent).toBe("Just agents I pick");
expect(anyAgent?.textContent).toBe("Any agent");
expect(justMe?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(wholeOrg?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(myAccount?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(dedicated?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(agentsIPick?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(anyAgent?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(2);
// A flexible connection method defaults to the company identity...
expect(wholeOrg?.getAttribute("aria-checked")).toBe("true");
expect(justMe?.getAttribute("aria-checked")).toBe("false");
// ...and every agent is the product default for both access and install.
expect(myAccount?.getAttribute("aria-checked")).toBe("true");
expect(dedicated?.getAttribute("aria-checked")).toBe("false");
// Every agent is the default reach for the responsible person's identity.
expect(agentsIPick?.getAttribute("aria-checked")).toBe("false");
expect(anyAgent?.getAttribute("aria-checked")).toBe("true");
});
@ -414,7 +422,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((r) => r.textContent?.includes("Just me"))
.find((r) => r.textContent?.includes("My GitHub account"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -438,7 +446,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// Moving backward must not silently reset the identity the operator chose.
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
expect(radios.find((r) => r.textContent?.includes("Just me"))?.getAttribute("aria-checked"))
expect(radios.find((r) => r.textContent?.includes("My GitHub account"))?.getAttribute("aria-checked"))
.toBe("true");
expect(radios.find((r) => r.textContent?.includes("Any agent"))?.getAttribute("aria-checked"))
.toBe("true");
@ -460,7 +468,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
* methods must still let the operator deliberately choose a personal identity.
*/
it("defaults flexible methods to company identity and keeps personal credentials submittable", async () => {
listGalleryMock.mockResolvedValue({ apps: [GITHUB, POSTHOG] });
listGalleryMock.mockResolvedValue({ apps: [COMPOSIO, POSTHOG] });
const identityChoices = () => {
const radios = Array.from(
@ -475,7 +483,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// --- API-key-only method: shared by default, personal still offered ------
let root = await render();
await act(async () => {
buttonContaining("GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonContaining("Composio")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -505,7 +513,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
await act(async () => setInputValue(keyField!, "github-personal-token"));
await act(async () => setInputValue(keyField!, "composio-personal-token"));
await flushReact();
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -516,7 +524,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// a personal grant. A disabled "Just me" would make this unreachable.
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({
galleryKey: "github",
galleryKey: "composio",
grantKind: "user",
});
@ -786,9 +794,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
* returned with the gallery reaches the real create flow.
*/
it("disables Any agent and blocks Continue when the member cannot install company-wide", async () => {
mockParams.appKey = "github";
mockParams.appKey = "posthog";
listGalleryMock.mockResolvedValueOnce({
apps: [GITHUB],
apps: [POSTHOG],
capabilities: {
canCreateOrganizationGrant: false,
organizationGrantReason: "Only connection managers can share this credential.",
@ -836,7 +844,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// A deep-linked app lands on Access first: identity and reach are chosen
// before the credential (PAP-17835).
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which GitHub identity should this use?");
await passAccessStep();
expect(container.textContent).toContain("Connect GitHub");
@ -2191,7 +2199,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which GitHub identity should this use?");
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));

View File

@ -25,7 +25,7 @@ describe("app connect policy", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=notion"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=jira"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=asana"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown"))).toBe(false);
@ -43,8 +43,8 @@ describe("app connect policy", () => {
});
it("admits retained hidden-provider reconnects without opening fresh setup", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=github&reconnect=connection-1"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=slack"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=slack&reconnect=connection-1"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown&reconnect=connection-1"))).toBe(false);
});

View File

@ -77,11 +77,15 @@ export function IdentitiesSection({
credentialPolicy,
ownerUserId,
connectedUser,
dedicatedAgent,
grantsQuery,
loading,
error,
onConnectAsMe,
onConnectOrganization,
onConnectAgent,
onRefreshAccess,
refreshAccessPending = false,
onReplaceAudience,
connectPending,
audiencePending,
@ -94,11 +98,15 @@ export function IdentitiesSection({
credentialPolicy: ToolConnectionCredentialPolicy;
ownerUserId: string | null;
connectedUser: { label: string; image: string | null } | null;
dedicatedAgent: { id: string; name: string } | null;
grantsQuery: ConnectionGrantsResponse | undefined;
loading: boolean;
error: boolean;
onConnectAsMe: () => void;
onConnectOrganization: () => void;
onConnectAgent: (agentId: string) => void;
onRefreshAccess?: () => void;
refreshAccessPending?: boolean;
onReplaceAudience: (grant: ConnectionGrant, memberUserIds: string[]) => void;
connectPending: boolean;
audiencePending: boolean;
@ -126,6 +134,12 @@ export function IdentitiesSection({
?? personalGrants[0]
?? null;
}, [grants, myGrant, ownerUserId]);
const agentGrant = useMemo(
() => grants.find((grant) => grant.kind === "agent" && grant.subjectAgentId === dedicatedAgent?.id)
?? grants.find((grant) => grant.kind === "agent")
?? null,
[dedicatedAgent?.id, grants],
);
const personalSubjectLabel = memberLabel(
members,
personalGrant?.subjectUserId ?? ownerUserId ?? currentUserId,
@ -156,6 +170,30 @@ export function IdentitiesSection({
);
}
if (credentialPolicy === "per_agent") {
const github = agentGrant?.providerTenant?.github;
return (
<section className="space-y-5">
<h2 className="text-sm font-semibold text-foreground">GitHub identity</h2>
<IdentityRow
title={github ? `@${github.login}` : "Dedicated GitHub account"}
status={agentGrant?.status ?? null}
detail={dedicatedAgent ? `Used only by ${dedicatedAgent.name}` : "Dedicated to one agent"}
actions={!agentGrant && dedicatedAgent && capabilities?.canConfigure ? (
<Button size="sm" disabled={connectPending} onClick={() => onConnectAgent(dedicatedAgent.id)}>
{connectPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Connect dedicated account
</Button>
) : null}
/>
{github ? <GitHubConnectionSummary grant={agentGrant} onRefreshAccess={onRefreshAccess} refreshPending={refreshAccessPending} /> : null}
<InlineBanner tone="warning" compact>
Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls.
</InlineBanner>
</section>
);
}
return (
<section className="space-y-5">
<IdentitiesHeading />
@ -175,6 +213,14 @@ export function IdentitiesSection({
}}
/>
{(usesPersonalIdentity ? personalGrant : orgGrant)?.providerTenant?.github ? (
<GitHubConnectionSummary
grant={(usesPersonalIdentity ? personalGrant : orgGrant)!}
onRefreshAccess={onRefreshAccess}
refreshPending={refreshAccessPending}
/>
) : null}
<div>
{usesPersonalIdentity ? (
personalGrant ? null : (
@ -231,6 +277,49 @@ export function IdentitiesSection({
);
}
function GitHubConnectionSummary({
grant,
onRefreshAccess,
refreshPending,
}: {
grant: ConnectionGrant;
onRefreshAccess?: () => void;
refreshPending: boolean;
}) {
const github = grant.providerTenant?.github;
if (!github) return null;
return (
<div className="space-y-4 rounded-lg border border-border p-4">
<div className="grid gap-3 text-sm text-muted-foreground sm:grid-cols-2">
<p><span className="font-medium text-foreground">Installation</span><br />{github.installationOwnerLogins.join(", ") || "GitHub"}</p>
<p><span className="font-medium text-foreground">Repositories</span><br />{github.repositoryCount} · {github.repositorySelection === "all" ? "All repositories" : "Selected repositories"}</p>
<p><span className="font-medium text-foreground">Token continuity</span><br />{grant.providerTenant?.oauth?.accessTokenExpiresAt ? "Automatically refreshed" : "Long-lived"}</p>
<p><span className="font-medium text-foreground">Webhook health</span><br />{github.webhookHealth === "healthy" ? "Healthy" : github.webhookHealth === "unhealthy" ? "Needs attention" : "Pending first event"}</p>
<p><span className="font-medium text-foreground">Last event</span><br />{github.lastWebhookAt ? new Date(github.lastWebhookAt).toLocaleString() : "No event received yet"}</p>
<p><span className="font-medium text-foreground">Last access refresh</span><br />{github.lastAccessRefreshAt ? new Date(github.lastAccessRefreshAt).toLocaleString() : "Not refreshed yet"}</p>
</div>
{github.repositorySelection === "all" ? (
<InlineBanner tone="warning" compact>
This installation can access every current and future repository in its GitHub account. Selected repositories is the safer default.
</InlineBanner>
) : null}
<div className="flex flex-wrap gap-2">
{github.managementUrl ? (
<Button asChild size="sm" variant="outline">
<a href={github.managementUrl} target="_blank" rel="noreferrer">Manage repositories on GitHub</a>
</Button>
) : null}
{onRefreshAccess ? (
<Button size="sm" variant="outline" disabled={refreshPending} onClick={onRefreshAccess}>
{refreshPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Refresh access
</Button>
) : null}
</div>
</div>
);
}
function IdentitiesHeading() {
return <h2 className="text-sm font-semibold text-foreground">Which humans can use this credential?</h2>;
}

View File

@ -15,13 +15,17 @@ import type {
* card cannot drift into three different names for the same thing.
*/
export type ConnectionTypeLabel = "Personal" | "Company";
export type ConnectionTypeLabel = "Personal" | "Dedicated agent" | "Company";
/** The two connection types shown throughout the product. */
export function connectionTypeLabel(
credentialPolicy: ToolConnectionCredentialPolicy,
): ConnectionTypeLabel {
return credentialPolicy === "per_user" ? "Personal" : "Company";
return credentialPolicy === "per_user"
? "Personal"
: credentialPolicy === "per_agent"
? "Dedicated agent"
: "Company";
}
const COMPANY_NAME_SUFFIX = " for the company";
@ -93,6 +97,7 @@ export function grantAccountLabel(
const tenantName = grant?.providerTenant?.name?.trim();
if (tenantName) return tenantName;
if (grant?.kind === "user") return options.subjectLabel?.trim() || "Connected account";
if (grant?.kind === "agent") return options.subjectLabel?.trim() || "Dedicated account";
return "Shared credential";
}

View File

@ -248,11 +248,13 @@ function ConnectedHost() {
credentialPolicy="per_user"
ownerUserId="board-user"
connectedUser={{ label: "Dotta", image: null }}
dedicatedAgent={null}
grantsQuery={personalGrantsResponse(grant)}
loading={false}
error={false}
onConnectAsMe={() => undefined}
onConnectOrganization={() => undefined}
onConnectAgent={() => undefined}
onReplaceAudience={() => undefined}
connectPending={false}
audiencePending={false}

View File

@ -274,7 +274,7 @@ function SeededAccessStep({
},
}: {
authKind: "oauth" | "api_key" | "none";
initialGrantKind: "user" | "organization";
initialGrantKind: "user" | "organization" | "agent";
initialChoice: "specific" | "all";
initialAgentIds: Set<string>;
capabilities?: {

View File

@ -132,6 +132,7 @@ function IdentitiesHarness({
credentialPolicy={credentialPolicy}
ownerUserId={CURRENT_USER}
connectedUser={{ label: "Carol", image: null }}
dedicatedAgent={null}
grantsQuery={loading || error ? undefined : response}
loading={loading}
error={error}
@ -143,6 +144,7 @@ function IdentitiesHarness({
onCloseAudience={() => setOpenAudience(null)}
onConnectAsMe={() => {}}
onConnectOrganization={() => {}}
onConnectAgent={() => {}}
onReplaceAudience={() => {}}
/>
</div>