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:
parent
263f181fed
commit
0ffc091473
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)!;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ export const toolsApi = {
|
|||
connectionId: string,
|
||||
input: {
|
||||
asCurrentUser?: boolean;
|
||||
asAgentId?: string;
|
||||
interactionId?: string;
|
||||
} = {},
|
||||
) =>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'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's GitHub.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-0.5 text-sm font-medium text-foreground">Use responsible person'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}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue