diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 31a01547076..5b88b533ec1 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -341,6 +341,8 @@ export interface PaperclipPluginManifestV1 { database?: PluginDatabaseDeclaration; apiRoutes?: PluginApiRouteDeclaration[]; environmentDrivers?: PluginEnvironmentDriverDeclaration[]; + /** Requires `repository.providers.register`. See §13.11. */ + repositoryProviders?: PluginRepositoryProviderDeclaration[]; agents?: PluginManagedAgentDeclaration[]; projects?: PluginManagedProjectDeclaration[]; routines?: PluginManagedRoutineDeclaration[]; @@ -634,6 +636,42 @@ The host provides: The worker executes the tool and returns a typed result (string content, structured data, or error). +### 13.11 `repositoryProvider*` + +Serves a first-class repository provider contributed by a trusted extension +package. Requires the `repository.providers.register` capability and a matching +`repositoryProviders[]` manifest declaration. + +Methods: `repositoryProviderBeginInstallation`, +`repositoryProviderCompleteInstallation`, `repositoryProviderDiscover`, +`repositoryProviderRefreshMetadata`, `repositoryProviderSync`, +`repositoryProviderDisconnect`, and +`repositoryProviderResolveCloneCredential`. + +Connector identity is `providerKey` plus a normalized `host`, so the same +provider key can serve github.com and an enterprise install without either +shadowing the other. Every call carries the company the host resolved, plus a +secret-free connection snapshot — the worker never receives a stored credential. + +Host rules the worker cannot opt out of: + +- **Deny by default.** A connector is registered only while the plugin is + `ready`, holds the capability, declares the exact identity, and has a running + worker. Stop, disable, unload, error, or crash drops the registration, and the + capability listing (`GET /companies/:companyId/repository-providers`) stops + advertising it — core UI never offers a provider the host could not serve. +- **Untrusted output.** Results are re-validated: malformed rows are dropped, + metadata keys that look like secrets are stripped, error text is sanitized, + and a result claiming another company or host is rejected outright. +- **No-store credentials.** `repositoryProviderResolveCloneCredential` returns a + transient token. The host marks it no-store and redacts it on serialization, + so it cannot reach a row, a log line, portability output, or a prompt. The + audit line is rebuilt from host-known values. + +Extensions verify their implementation with the conformance suite exported at +`@paperclipai/plugin-sdk/repository-provider-conformance`, which the host also +runs against the bridge. + ## 14. SDK Surface Plugins do not talk to the DB directly. @@ -853,6 +891,7 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr - `http.outbound` - `secrets.read-ref` - `environment.drivers.register` +- `repository.providers.register` ### Agent Tools diff --git a/packages/plugins/sdk/package.json b/packages/plugins/sdk/package.json index 6085c0c7ade..f36964cd955 100644 --- a/packages/plugins/sdk/package.json +++ b/packages/plugins/sdk/package.json @@ -42,6 +42,10 @@ "types": "./dist/testing.d.ts", "import": "./dist/testing.js" }, + "./repository-provider-conformance": { + "types": "./dist/repository-provider-conformance.d.ts", + "import": "./dist/repository-provider-conformance.js" + }, "./bundlers": { "types": "./dist/bundlers.d.ts", "import": "./dist/bundlers.js" @@ -85,6 +89,10 @@ "types": "./dist/testing.d.ts", "import": "./dist/testing.js" }, + "./repository-provider-conformance": { + "types": "./dist/repository-provider-conformance.d.ts", + "import": "./dist/repository-provider-conformance.js" + }, "./bundlers": { "types": "./dist/bundlers.d.ts", "import": "./dist/bundlers.js" diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index d2fc5232226..fb62f96078e 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -67,6 +67,19 @@ import type { PluginEnvironmentCancelInteractiveSetupResult, PluginEnvironmentDeleteTemplateParams, PluginEnvironmentDeleteTemplateResult, + PluginRepositoryProviderBeginInstallationParams, + PluginRepositoryProviderBeginInstallationResult, + PluginRepositoryProviderCompleteInstallationParams, + PluginRepositoryProviderCompleteInstallationResult, + PluginRepositoryProviderDisconnectParams, + PluginRepositoryProviderDiscoverParams, + PluginRepositoryProviderDiscoverResult, + PluginRepositoryProviderRefreshMetadataParams, + PluginRepositoryProviderRefreshMetadataResult, + PluginRepositoryProviderResolveCloneCredentialParams, + PluginRepositoryProviderResolveCloneCredentialResult, + PluginRepositoryProviderSyncParams, + PluginRepositoryProviderSyncResult, PluginEnvironmentLease, PluginEnvironmentProbeParams, PluginEnvironmentProbeResult, @@ -432,6 +445,64 @@ export interface PluginDefinition { onEnvironmentDeleteTemplate?( params: PluginEnvironmentDeleteTemplateParams, ): Promise; + + // ------------------------------------------------------------------------- + // Repository providers — require `repository.providers.register` + // ------------------------------------------------------------------------- + // + // A trusted extension implements these to serve a manifest-declared + // `repositoryProviders[]` identity (provider key + normalized host). The host + // registers a connector for that identity only while the plugin is ready and + // its worker is running, and unregisters it on stop/unload. + // + // The host owns every persisted row: the worker answers provider questions + // and never sees or supplies a connection secret. Anything returned here is + // re-validated and sanitized before the host acts on it. + // + // A plugin that implements `onRepositoryProviderBeginInstallation` MUST also + // implement `onRepositoryProviderCompleteInstallation`, + // `onRepositoryProviderDiscover`, `onRepositoryProviderRefreshMetadata`, + // `onRepositoryProviderSync`, and + // `onRepositoryProviderResolveCloneCredential` — the host refuses to register + // a partially implemented connector rather than failing mid-flow. + + /** Called to start the provider's install/authorize flow for a company. */ + onRepositoryProviderBeginInstallation?( + params: PluginRepositoryProviderBeginInstallationParams, + ): Promise; + + /** Called with the provider callback's signed state to finish installation. */ + onRepositoryProviderCompleteInstallation?( + params: PluginRepositoryProviderCompleteInstallationParams, + ): Promise; + + /** Called to list repositories visible to a connection, paginated/searchable. */ + onRepositoryProviderDiscover?( + params: PluginRepositoryProviderDiscoverParams, + ): Promise; + + /** Called to re-read one repository's metadata (rename/transfer/visibility). */ + onRepositoryProviderRefreshMetadata?( + params: PluginRepositoryProviderRefreshMetadataParams, + ): Promise; + + /** Called to enumerate a connection's repositories for idempotent sync. */ + onRepositoryProviderSync?( + params: PluginRepositoryProviderSyncParams, + ): Promise; + + /** Called when a connection is disconnected so the provider can revoke state. */ + onRepositoryProviderDisconnect?( + params: PluginRepositoryProviderDisconnectParams, + ): Promise; + + /** + * Called to mint a short-lived clone credential. The returned token is + * transient: the host marks it no-store and never persists or logs it. + */ + onRepositoryProviderResolveCloneCredential?( + params: PluginRepositoryProviderResolveCloneCredentialParams, + ): Promise; } // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index c8f8f4d4067..7fdbcd99eff 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -202,6 +202,23 @@ export type { PluginEnvironmentCancelInteractiveSetupResult, PluginEnvironmentDeleteTemplateParams, PluginEnvironmentDeleteTemplateResult, + PluginRepositoryConnectionSnapshot, + PluginRepositoryProviderBaseParams, + PluginRepositoryProviderBeginInstallationParams, + PluginRepositoryProviderBeginInstallationResult, + PluginRepositoryProviderCompleteInstallationParams, + PluginRepositoryProviderCompleteInstallationResult, + PluginRepositoryProviderDisconnectParams, + PluginRepositoryProviderDiscoverParams, + PluginRepositoryProviderDiscoverResult, + PluginDiscoveredRepository, + PluginRepositorySnapshot, + PluginRepositoryProviderRefreshMetadataParams, + PluginRepositoryProviderRefreshMetadataResult, + PluginRepositoryProviderResolveCloneCredentialParams, + PluginRepositoryProviderResolveCloneCredentialResult, + PluginRepositoryProviderSyncParams, + PluginRepositoryProviderSyncResult, PluginModalBoundsRequest, PluginRenderCloseEvent, PluginLauncherRenderContextSnapshot, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 3fb8e8035a9..e721964edc5 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -923,6 +923,175 @@ export interface PluginEnvironmentDeleteTemplateResult { metadata?: Record; } +// --------------------------------------------------------------------------- +// Repository provider payloads +// --------------------------------------------------------------------------- + +/** + * Wire shapes for the `repositoryProvider*` host→worker RPCs. + * + * These mirror the host's in-process `RepositoryProviderConnector` contract + * with two deliberate differences: + * + * 1. **Timestamps are ISO-8601 strings.** JSON-RPC has no Date, and letting a + * worker return an arbitrary object where the host expects a Date is how + * expiry checks get bypassed. The host parses and re-validates them. + * 2. **Everything a worker returns is untrusted.** The host re-normalizes + * identity (provider + host), strips sensitive metadata keys, sanitizes + * error text, and rewrites audit fields from values it already knows. + * + * Requires the `repository.providers.register` capability, and the host only + * dispatches to identities the manifest declared. + */ +export interface PluginRepositoryProviderBaseParams { + /** Declared provider key this call is for. */ + providerKey: string; + /** Declared, normalized provider host this call is for. */ + host: string; + /** Company the call is scoped to. Workers must not cross this boundary. */ + companyId: string; +} + +/** Secret-free snapshot of a persisted connection row handed to the worker. */ +export interface PluginRepositoryConnectionSnapshot { + id: string; + companyId: string; + provider: string; + host: string; + installationId: string | null; + accountId: string | null; + accountName: string | null; + status: string; + syncStatus: string; + syncCursor: string | null; + lastSyncedAt: string | null; + /** Sanitized provider metadata as persisted by the host. */ + providerMetadata: Record | null; +} + +export interface PluginRepositoryProviderBeginInstallationParams + extends PluginRepositoryProviderBaseParams { + /** User initiating the connection; the worker binds it into signed state. */ + userId: string; + redirectPath?: string | null; +} + +export interface PluginRepositoryProviderBeginInstallationResult { + installUrl: string; + state: string; + /** ISO-8601 timestamp for when the signed state stops being valid. */ + expiresAt: string; +} + +export interface PluginRepositoryProviderCompleteInstallationParams + extends PluginRepositoryProviderBaseParams { + state: string; + installationId: string; +} + +export interface PluginRepositoryProviderCompleteInstallationResult { + companyId: string; + userId: string; + installationId: string; + accountId: string | null; + accountName: string | null; + host: string; + providerMetadata?: Record | null; +} + +export interface PluginRepositoryProviderDiscoverParams + extends PluginRepositoryProviderBaseParams { + connection: PluginRepositoryConnectionSnapshot; + query?: string | null; + cursor?: string | null; + pageSize?: number; +} + +export interface PluginDiscoveredRepository { + providerRepositoryId: string; + owner: string; + name: string; + cloneUrl: string; + webUrl?: string | null; + defaultBranch?: string | null; + visibility?: string; + archived?: boolean; + metadata?: Record | null; +} + +export interface PluginRepositoryProviderDiscoverResult { + items: PluginDiscoveredRepository[]; + nextCursor: string | null; + total: number | null; +} + +export interface PluginRepositorySnapshot { + providerRepositoryId: string; + cloneUrl: string; + webUrl?: string | null; + defaultBranch?: string | null; + visibility?: string; + state?: string; + unavailableReason?: string | null; + providerMetadata?: Record | null; +} + +export interface PluginRepositoryProviderRefreshMetadataParams + extends PluginRepositoryProviderBaseParams { + connection: PluginRepositoryConnectionSnapshot; + providerRepositoryId: string; +} + +export interface PluginRepositoryProviderRefreshMetadataResult { + repository: PluginRepositorySnapshot | null; +} + +export interface PluginRepositoryProviderSyncParams + extends PluginRepositoryProviderBaseParams { + connection: PluginRepositoryConnectionSnapshot; + cursor: string | null; +} + +export interface PluginRepositoryProviderSyncResult { + repositories: PluginRepositorySnapshot[]; + cursor?: string | null; +} + +export interface PluginRepositoryProviderDisconnectParams + extends PluginRepositoryProviderBaseParams { + connection: PluginRepositoryConnectionSnapshot; +} + +export interface PluginRepositoryProviderResolveCloneCredentialParams + extends PluginRepositoryProviderBaseParams { + connection: PluginRepositoryConnectionSnapshot; + repository: { + id: string; + providerRepositoryId: string | null; + owner: string; + name: string; + cloneUrl: string; + host: string; + }; +} + +/** + * A transient clone credential. The host marks the resolved value no-store: it + * is never written to a row, a log line, an API response, a prompt, or + * portability output, and the host-side object redacts itself on serialization. + */ +export interface PluginRepositoryProviderResolveCloneCredentialResult { + username: string; + token: string; + authenticatedCloneUrl: string; + /** ISO-8601 expiry. The host rejects non-future values. */ + expiresAt: string; + /** Optional secret-free extras for the audit line. Host-known fields win. */ + audit?: { + installationId?: string | null; + }; +} + // --------------------------------------------------------------------------- // UI launcher / modal host interaction payloads // --------------------------------------------------------------------------- @@ -1064,6 +1233,34 @@ export interface HostToWorkerMethods { params: PluginEnvironmentDeleteTemplateParams, result: PluginEnvironmentDeleteTemplateResult, ]; + repositoryProviderBeginInstallation: [ + params: PluginRepositoryProviderBeginInstallationParams, + result: PluginRepositoryProviderBeginInstallationResult, + ]; + repositoryProviderCompleteInstallation: [ + params: PluginRepositoryProviderCompleteInstallationParams, + result: PluginRepositoryProviderCompleteInstallationResult, + ]; + repositoryProviderDiscover: [ + params: PluginRepositoryProviderDiscoverParams, + result: PluginRepositoryProviderDiscoverResult, + ]; + repositoryProviderRefreshMetadata: [ + params: PluginRepositoryProviderRefreshMetadataParams, + result: PluginRepositoryProviderRefreshMetadataResult, + ]; + repositoryProviderSync: [ + params: PluginRepositoryProviderSyncParams, + result: PluginRepositoryProviderSyncResult, + ]; + repositoryProviderDisconnect: [ + params: PluginRepositoryProviderDisconnectParams, + result: void, + ]; + repositoryProviderResolveCloneCredential: [ + params: PluginRepositoryProviderResolveCloneCredentialParams, + result: PluginRepositoryProviderResolveCloneCredentialResult, + ]; } /** Union of all host→worker method names. */ @@ -1105,6 +1302,13 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] "environmentCaptureTemplate", "environmentCancelInteractiveSetup", "environmentDeleteTemplate", + "repositoryProviderBeginInstallation", + "repositoryProviderCompleteInstallation", + "repositoryProviderDiscover", + "repositoryProviderRefreshMetadata", + "repositoryProviderSync", + "repositoryProviderDisconnect", + "repositoryProviderResolveCloneCredential", ] as const; // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/repository-provider-conformance.ts b/packages/plugins/sdk/src/repository-provider-conformance.ts new file mode 100644 index 00000000000..0925c9ee07c --- /dev/null +++ b/packages/plugins/sdk/src/repository-provider-conformance.ts @@ -0,0 +1,465 @@ +/** + * Repository provider conformance harness. + * + * A trusted extension that declares `repositoryProviders[]` and implements the + * `onRepositoryProvider*` hooks must pass this suite before the host will treat + * it as a first-class provider. It is the executable half of the contract: the + * host's bridge runs it against a fake worker, and an extension package (e.g. + * the Paperclip EE GitHub Enterprise provider) runs the identical checks + * against its own handlers. + * + * Deliberately framework-agnostic — it returns named checks instead of calling + * `it()`/`expect()` — so the SDK stays free of a test-runner dependency and the + * same suite runs under vitest, jest, or `node:test`: + * + * ```ts + * import { repositoryProviderConformanceChecks } from "@paperclipai/plugin-sdk/repository-provider-conformance"; + * + * describe("github enterprise provider", () => { + * for (const check of repositoryProviderConformanceChecks(makeWorld)) { + * it(check.name, check.run); + * } + * }); + * ``` + */ +import type { + PluginRepositoryConnectionSnapshot, + PluginRepositoryProviderBeginInstallationParams, + PluginRepositoryProviderBeginInstallationResult, + PluginRepositoryProviderCompleteInstallationParams, + PluginRepositoryProviderCompleteInstallationResult, + PluginRepositoryProviderDisconnectParams, + PluginRepositoryProviderDiscoverParams, + PluginRepositoryProviderDiscoverResult, + PluginRepositoryProviderRefreshMetadataParams, + PluginRepositoryProviderRefreshMetadataResult, + PluginRepositoryProviderResolveCloneCredentialParams, + PluginRepositoryProviderResolveCloneCredentialResult, + PluginRepositoryProviderSyncParams, + PluginRepositoryProviderSyncResult, +} from "./protocol.js"; + +/** + * The handler surface under test. Matches the correspondingly named + * `onRepositoryProvider*` hooks on a plugin definition, so an extension can + * pass its definition object straight through. + */ +export interface RepositoryProviderConformanceHandlers { + onRepositoryProviderBeginInstallation( + params: PluginRepositoryProviderBeginInstallationParams, + ): Promise; + onRepositoryProviderCompleteInstallation( + params: PluginRepositoryProviderCompleteInstallationParams, + ): Promise; + onRepositoryProviderDiscover( + params: PluginRepositoryProviderDiscoverParams, + ): Promise; + onRepositoryProviderRefreshMetadata( + params: PluginRepositoryProviderRefreshMetadataParams, + ): Promise; + onRepositoryProviderSync( + params: PluginRepositoryProviderSyncParams, + ): Promise; + onRepositoryProviderResolveCloneCredential( + params: PluginRepositoryProviderResolveCloneCredentialParams, + ): Promise; + onRepositoryProviderDisconnect?( + params: PluginRepositoryProviderDisconnectParams, + ): Promise; +} + +export interface ConformanceSeedRepository { + providerRepositoryId: string; + owner: string; + name: string; +} + +export interface RepositoryProviderConformanceWorld { + handlers: RepositoryProviderConformanceHandlers; + /** Declared provider key under test. */ + providerKey: string; + /** Declared, normalized provider host under test. */ + host: string; + companyId: string; + userId: string; + installationId: string; + accountName: string; + /** Replace the repository set visible to the installation. */ + seed(repos: ConformanceSeedRepository[]): void; + /** Rename/transfer a repository, keeping its provider id stable. */ + rename(providerRepositoryId: string, next: { owner: string; name: string }): void; +} + +export type RepositoryProviderConformanceWorldFactory = () => + | RepositoryProviderConformanceWorld + | Promise; + +export interface RepositoryProviderConformanceCheck { + name: string; + run(): Promise; +} + +/** Company id used to prove state is bound to the company that requested it. */ +const FOREIGN_COMPANY_ID = "00000000-0000-4000-8000-0000000000ff"; + +class ConformanceError extends Error { + constructor(message: string) { + super(message); + this.name = "RepositoryProviderConformanceError"; + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new ConformanceError(message); +} + +function assertEqual(actual: unknown, expected: unknown, message: string) { + if (actual !== expected) { + throw new ConformanceError(`${message} (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`); + } +} + +async function assertRejects(operation: () => Promise, message: string) { + let resolved = false; + try { + await operation(); + resolved = true; + } catch { + return; + } + if (resolved) throw new ConformanceError(message); +} + +function assertIsoInFuture(value: unknown, message: string) { + assert(typeof value === "string" && value.length > 0, `${message}: expected an ISO-8601 string`); + const parsed = Date.parse(value as string); + assert(Number.isFinite(parsed), `${message}: expected a parseable ISO-8601 string`); + assert(parsed > Date.now(), `${message}: expected a future timestamp`); +} + +function baseParams(world: RepositoryProviderConformanceWorld) { + return { providerKey: world.providerKey, host: world.host, companyId: world.companyId }; +} + +function connectionFrom( + world: RepositoryProviderConformanceWorld, + meta: PluginRepositoryProviderCompleteInstallationResult, +): PluginRepositoryConnectionSnapshot { + return { + id: "00000000-0000-4000-8000-000000000001", + companyId: world.companyId, + provider: world.providerKey, + host: meta.host, + installationId: meta.installationId, + accountId: meta.accountId, + accountName: meta.accountName, + status: "active", + syncStatus: "idle", + syncCursor: null, + lastSyncedAt: null, + providerMetadata: meta.providerMetadata ?? null, + }; +} + +async function install(world: RepositoryProviderConformanceWorld) { + const begin = await world.handlers.onRepositoryProviderBeginInstallation({ + ...baseParams(world), + userId: world.userId, + }); + const meta = await world.handlers.onRepositoryProviderCompleteInstallation({ + ...baseParams(world), + state: begin.state, + installationId: world.installationId, + }); + return { begin, meta, connection: connectionFrom(world, meta) }; +} + +/** + * Builds the conformance checks. The factory must return a fresh, isolated + * world per invocation — checks mutate their world's repository set. + */ +export function repositoryProviderConformanceChecks( + makeWorld: RepositoryProviderConformanceWorldFactory, +): RepositoryProviderConformanceCheck[] { + return [ + { + name: "begins installation with expiring state bound to the requesting company", + async run() { + const world = await makeWorld(); + world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]); + const begin = await world.handlers.onRepositoryProviderBeginInstallation({ + ...baseParams(world), + userId: world.userId, + }); + assert(typeof begin.state === "string" && begin.state.length > 0, "beginInstallation must return signed state"); + assert( + typeof begin.installUrl === "string" && begin.installUrl.startsWith("https://"), + "beginInstallation must return an https install URL", + ); + assertIsoInFuture(begin.expiresAt, "beginInstallation expiresAt"); + }, + }, + { + name: "rejects tampered and cross-company installation state", + async run() { + const world = await makeWorld(); + world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]); + const begin = await world.handlers.onRepositoryProviderBeginInstallation({ + ...baseParams(world), + userId: world.userId, + }); + + await assertRejects( + () => world.handlers.onRepositoryProviderCompleteInstallation({ + ...baseParams(world), + state: `${begin.state}tampered`, + installationId: world.installationId, + }), + "completeInstallation must reject tampered state", + ); + + await assertRejects( + () => world.handlers.onRepositoryProviderCompleteInstallation({ + ...baseParams(world), + companyId: FOREIGN_COMPANY_ID, + state: begin.state, + installationId: world.installationId, + }), + "completeInstallation must reject state issued for another company", + ); + + const meta = await world.handlers.onRepositoryProviderCompleteInstallation({ + ...baseParams(world), + state: begin.state, + installationId: world.installationId, + }); + assertEqual(meta.companyId, world.companyId, "completeInstallation must echo the requesting company"); + assertEqual(meta.userId, world.userId, "completeInstallation must echo the initiating user"); + assertEqual(meta.installationId, world.installationId, "completeInstallation must echo the installation id"); + assertEqual(meta.host, world.host, "completeInstallation must report the declared normalized host"); + }, + }, + { + name: "discovers repositories with pagination and search", + async run() { + const world = await makeWorld(); + world.seed([ + { providerRepositoryId: "1", owner: "acme", name: "alpha" }, + { providerRepositoryId: "2", owner: "acme", name: "beta" }, + { providerRepositoryId: "3", owner: "acme", name: "gamma" }, + ]); + const { connection } = await install(world); + + const page1 = await world.handlers.onRepositoryProviderDiscover({ + ...baseParams(world), + connection, + pageSize: 2, + }); + assertEqual(page1.items.length, 2, "first discovery page must respect pageSize"); + assert(page1.nextCursor != null, "first discovery page must return a next cursor"); + assertEqual(page1.total, 3, "discovery must report the total match count when known"); + + const page2 = await world.handlers.onRepositoryProviderDiscover({ + ...baseParams(world), + connection, + pageSize: 2, + cursor: page1.nextCursor, + }); + assertEqual(page2.items.length, 1, "last discovery page must return the remainder"); + assertEqual(page2.nextCursor, null, "last discovery page must return a null cursor"); + + const ids = [...page1.items, ...page2.items].map((item) => item.providerRepositoryId).sort(); + assertEqual(ids.join(","), "1,2,3", "discovery pages must cover every repository exactly once"); + + const searched = await world.handlers.onRepositoryProviderDiscover({ + ...baseParams(world), + connection, + query: "gamma", + }); + assertEqual(searched.items.length, 1, "discovery search must filter by owner/name"); + assertEqual(searched.items[0]?.providerRepositoryId, "3", "discovery search must return the matching repository"); + + for (const item of page1.items) { + assert( + item.cloneUrl.includes(world.host), + "discovered clone URLs must point at the declared provider host", + ); + } + }, + }, + { + name: "fails closed on an invalid discovery cursor", + async run() { + const world = await makeWorld(); + world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]); + const { connection } = await install(world); + await assertRejects( + () => world.handlers.onRepositoryProviderDiscover({ + ...baseParams(world), + connection, + cursor: "not-a-cursor", + }), + "discovery must reject an unparseable cursor instead of silently restarting", + ); + }, + }, + { + name: "refreshes metadata for a known repository and reports unknown ones as missing", + async run() { + const world = await makeWorld(); + world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]); + const { connection } = await install(world); + + const known = await world.handlers.onRepositoryProviderRefreshMetadata({ + ...baseParams(world), + connection, + providerRepositoryId: "1", + }); + assert(known.repository != null, "refreshMetadata must return a snapshot for a visible repository"); + assertEqual( + known.repository?.providerRepositoryId, + "1", + "refreshMetadata must echo the requested provider repository id", + ); + + const unknown = await world.handlers.onRepositoryProviderRefreshMetadata({ + ...baseParams(world), + connection, + providerRepositoryId: "does-not-exist", + }); + assertEqual(unknown.repository, null, "refreshMetadata must return null for a repository it cannot see"); + }, + }, + { + name: "converges on rename/transfer without changing provider repository identity", + async run() { + const world = await makeWorld(); + world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]); + const { connection } = await install(world); + + const before = await world.handlers.onRepositoryProviderSync({ + ...baseParams(world), + connection, + cursor: null, + }); + assertEqual(before.repositories.length, 1, "sync must enumerate the installation's repositories"); + assert( + before.repositories[0]?.cloneUrl.includes("alpha"), + "sync must report the current clone URL", + ); + + world.rename("1", { owner: "acme-renamed", name: "alpha-renamed" }); + + const after = await world.handlers.onRepositoryProviderSync({ + ...baseParams(world), + connection, + cursor: null, + }); + assertEqual(after.repositories.length, 1, "sync must stay idempotent across a rename"); + assertEqual( + after.repositories[0]?.providerRepositoryId, + "1", + "provider repository id must survive rename/transfer", + ); + assert( + after.repositories[0]?.cloneUrl.includes("alpha-renamed"), + "sync must report the renamed clone URL", + ); + }, + }, + { + name: "resolves a short-lived clone credential with a secret-free audit trail", + async run() { + const world = await makeWorld(); + world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]); + const { connection } = await install(world); + + const resolved = await world.handlers.onRepositoryProviderResolveCloneCredential({ + ...baseParams(world), + connection, + repository: { + id: "00000000-0000-4000-8000-0000000000a1", + providerRepositoryId: "1", + owner: "acme", + name: "alpha", + cloneUrl: `https://${world.host}/acme/alpha.git`, + host: world.host, + }, + }); + + assert(typeof resolved.token === "string" && resolved.token.length > 0, "clone credential must include a token"); + assert( + typeof resolved.username === "string" && resolved.username.length > 0, + "clone credential must include a username", + ); + assertIsoInFuture(resolved.expiresAt, "clone credential expiresAt"); + assert( + resolved.authenticatedCloneUrl.includes(resolved.token), + "authenticated clone URL must carry the transient token", + ); + assert( + resolved.authenticatedCloneUrl.includes(world.host), + "authenticated clone URL must point at the declared provider host", + ); + assert( + !JSON.stringify(resolved.audit ?? {}).includes(resolved.token), + "audit metadata must never contain the credential", + ); + + const second = await world.handlers.onRepositoryProviderResolveCloneCredential({ + ...baseParams(world), + connection, + repository: { + id: "00000000-0000-4000-8000-0000000000a1", + providerRepositoryId: "1", + owner: "acme", + name: "alpha", + cloneUrl: `https://${world.host}/acme/alpha.git`, + host: world.host, + }, + }); + assert(second.token !== resolved.token, "clone credentials must be minted fresh, not cached and replayed"); + }, + }, + { + name: "rejects calls for a connection belonging to another company", + async run() { + const world = await makeWorld(); + world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]); + const { connection } = await install(world); + + await assertRejects( + () => world.handlers.onRepositoryProviderDiscover({ + ...baseParams(world), + companyId: FOREIGN_COMPANY_ID, + connection, + }), + "provider calls must reject a company id that does not own the connection", + ); + }, + }, + ]; +} + +export interface RepositoryProviderConformanceFailure { + name: string; + error: Error; +} + +/** + * Runs every check and resolves with the failures. Convenient for extensions + * that want a single assertion (`expect(failures).toEqual([])`) or a CLI check. + */ +export async function runRepositoryProviderConformance( + makeWorld: RepositoryProviderConformanceWorldFactory, +): Promise { + const failures: RepositoryProviderConformanceFailure[] = []; + for (const check of repositoryProviderConformanceChecks(makeWorld)) { + try { + await check.run(); + } catch (err) { + failures.push({ name: check.name, error: err instanceof Error ? err : new Error(String(err)) }); + } + } + return failures; +} diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index ccad211040f..15e4fcd0f3f 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -100,6 +100,13 @@ import type { PluginEnvironmentCaptureTemplateParams, PluginEnvironmentCancelInteractiveSetupParams, PluginEnvironmentDeleteTemplateParams, + PluginRepositoryProviderBeginInstallationParams, + PluginRepositoryProviderCompleteInstallationParams, + PluginRepositoryProviderDisconnectParams, + PluginRepositoryProviderDiscoverParams, + PluginRepositoryProviderRefreshMetadataParams, + PluginRepositoryProviderResolveCloneCredentialParams, + PluginRepositoryProviderSyncParams, PluginInvocationContext, WorkerToHostMethodName, WorkerToHostMethods, @@ -1582,6 +1589,35 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost case "environmentDeleteTemplate": return handleEnvironmentDeleteTemplate(params as PluginEnvironmentDeleteTemplateParams); + case "repositoryProviderBeginInstallation": + return handleRepositoryProviderBeginInstallation( + params as PluginRepositoryProviderBeginInstallationParams, + ); + + case "repositoryProviderCompleteInstallation": + return handleRepositoryProviderCompleteInstallation( + params as PluginRepositoryProviderCompleteInstallationParams, + ); + + case "repositoryProviderDiscover": + return handleRepositoryProviderDiscover(params as PluginRepositoryProviderDiscoverParams); + + case "repositoryProviderRefreshMetadata": + return handleRepositoryProviderRefreshMetadata( + params as PluginRepositoryProviderRefreshMetadataParams, + ); + + case "repositoryProviderSync": + return handleRepositoryProviderSync(params as PluginRepositoryProviderSyncParams); + + case "repositoryProviderDisconnect": + return handleRepositoryProviderDisconnect(params as PluginRepositoryProviderDisconnectParams); + + case "repositoryProviderResolveCloneCredential": + return handleRepositoryProviderResolveCloneCredential( + params as PluginRepositoryProviderResolveCloneCredentialParams, + ); + default: throw Object.assign( new Error(`Unknown method: ${method}`), @@ -1633,6 +1669,13 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (plugin.definition.onEnvironmentCaptureTemplate) supportedMethods.push("environmentCaptureTemplate"); if (plugin.definition.onEnvironmentCancelInteractiveSetup) supportedMethods.push("environmentCancelInteractiveSetup"); if (plugin.definition.onEnvironmentDeleteTemplate) supportedMethods.push("environmentDeleteTemplate"); + if (plugin.definition.onRepositoryProviderBeginInstallation) supportedMethods.push("repositoryProviderBeginInstallation"); + if (plugin.definition.onRepositoryProviderCompleteInstallation) supportedMethods.push("repositoryProviderCompleteInstallation"); + if (plugin.definition.onRepositoryProviderDiscover) supportedMethods.push("repositoryProviderDiscover"); + if (plugin.definition.onRepositoryProviderRefreshMetadata) supportedMethods.push("repositoryProviderRefreshMetadata"); + if (plugin.definition.onRepositoryProviderSync) supportedMethods.push("repositoryProviderSync"); + if (plugin.definition.onRepositoryProviderDisconnect) supportedMethods.push("repositoryProviderDisconnect"); + if (plugin.definition.onRepositoryProviderResolveCloneCredential) supportedMethods.push("repositoryProviderResolveCloneCredential"); return { ok: true, supportedMethods }; } @@ -1981,6 +2024,63 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return plugin.definition.onEnvironmentDeleteTemplate(params); } + async function handleRepositoryProviderBeginInstallation( + params: PluginRepositoryProviderBeginInstallationParams, + ) { + if (!plugin.definition.onRepositoryProviderBeginInstallation) { + throw methodNotImplemented("repositoryProviderBeginInstallation"); + } + return plugin.definition.onRepositoryProviderBeginInstallation(params); + } + + async function handleRepositoryProviderCompleteInstallation( + params: PluginRepositoryProviderCompleteInstallationParams, + ) { + if (!plugin.definition.onRepositoryProviderCompleteInstallation) { + throw methodNotImplemented("repositoryProviderCompleteInstallation"); + } + return plugin.definition.onRepositoryProviderCompleteInstallation(params); + } + + async function handleRepositoryProviderDiscover(params: PluginRepositoryProviderDiscoverParams) { + if (!plugin.definition.onRepositoryProviderDiscover) { + throw methodNotImplemented("repositoryProviderDiscover"); + } + return plugin.definition.onRepositoryProviderDiscover(params); + } + + async function handleRepositoryProviderRefreshMetadata( + params: PluginRepositoryProviderRefreshMetadataParams, + ) { + if (!plugin.definition.onRepositoryProviderRefreshMetadata) { + throw methodNotImplemented("repositoryProviderRefreshMetadata"); + } + return plugin.definition.onRepositoryProviderRefreshMetadata(params); + } + + async function handleRepositoryProviderSync(params: PluginRepositoryProviderSyncParams) { + if (!plugin.definition.onRepositoryProviderSync) { + throw methodNotImplemented("repositoryProviderSync"); + } + return plugin.definition.onRepositoryProviderSync(params); + } + + async function handleRepositoryProviderDisconnect(params: PluginRepositoryProviderDisconnectParams) { + if (!plugin.definition.onRepositoryProviderDisconnect) { + throw methodNotImplemented("repositoryProviderDisconnect"); + } + return plugin.definition.onRepositoryProviderDisconnect(params); + } + + async function handleRepositoryProviderResolveCloneCredential( + params: PluginRepositoryProviderResolveCloneCredentialParams, + ) { + if (!plugin.definition.onRepositoryProviderResolveCloneCredential) { + throw methodNotImplemented("repositoryProviderResolveCloneCredential"); + } + return plugin.definition.onRepositoryProviderResolveCloneCredential(params); + } + // ----------------------------------------------------------------------- // Event filter helper // ----------------------------------------------------------------------- diff --git a/packages/plugins/sdk/tests/repository-provider-conformance.test.ts b/packages/plugins/sdk/tests/repository-provider-conformance.test.ts new file mode 100644 index 00000000000..0972b46a71e --- /dev/null +++ b/packages/plugins/sdk/tests/repository-provider-conformance.test.ts @@ -0,0 +1,255 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import type { + PluginRepositoryProviderBeginInstallationParams, + PluginRepositoryProviderCompleteInstallationParams, + PluginRepositoryProviderDiscoverParams, + PluginRepositoryProviderRefreshMetadataParams, + PluginRepositoryProviderResolveCloneCredentialParams, + PluginRepositoryProviderSyncParams, +} from "../src/protocol.js"; +import { + repositoryProviderConformanceChecks, + runRepositoryProviderConformance, + type RepositoryProviderConformanceHandlers, + type RepositoryProviderConformanceWorld, +} from "../src/repository-provider-conformance.js"; + +/** + * Reference extension used to prove the harness accepts a correct provider and + * rejects the specific ways one can be wrong. An EE package implements the same + * hooks against a real API; this fake keeps the contract executable in the SDK. + */ +const HOST = "github.acme-corp.example"; +const PROVIDER = "github-enterprise"; +const COMPANY_ID = "11111111-1111-4111-8111-111111111111"; +const USER_ID = "22222222-2222-4222-8222-222222222222"; +const INSTALLATION_ID = "install-1"; +const STATE_SECRET = "conformance-state-secret"; + +interface FakeRepo { + providerRepositoryId: string; + owner: string; + name: string; +} + +function signState(payload: Record) { + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const mac = createHmac("sha256", STATE_SECRET).update(body).digest("base64url"); + return `${body}.${mac}`; +} + +function verifyState(state: string) { + const [body, mac] = state.split("."); + if (!body || !mac) throw new Error("invalid state"); + const expected = createHmac("sha256", STATE_SECRET).update(body).digest("base64url"); + const provided = Buffer.from(mac); + const computed = Buffer.from(expected); + if (provided.length !== computed.length || !timingSafeEqual(provided, computed)) { + throw new Error("invalid state signature"); + } + const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as { + companyId: string; + userId: string; + exp: number; + }; + if (payload.exp <= Date.now()) throw new Error("state expired"); + return payload; +} + +function createFakeWorld( + overrides: Partial = {}, +): RepositoryProviderConformanceWorld { + let repos: FakeRepo[] = []; + let tokenCounter = 0; + + const cloneUrl = (repo: FakeRepo) => `https://${HOST}/${repo.owner}/${repo.name}.git`; + + function assertScope(params: { companyId: string; connection?: { companyId: string } }) { + if (params.connection && params.connection.companyId !== params.companyId) { + throw new Error("connection belongs to another company"); + } + } + + const handlers: RepositoryProviderConformanceHandlers = { + async onRepositoryProviderBeginInstallation(params: PluginRepositoryProviderBeginInstallationParams) { + const expiresAt = new Date(Date.now() + 10 * 60_000); + const state = signState({ + companyId: params.companyId, + userId: params.userId, + exp: expiresAt.getTime(), + }); + return { + installUrl: `https://${HOST}/github-apps/acme/installations/new?state=${encodeURIComponent(state)}`, + state, + expiresAt: expiresAt.toISOString(), + }; + }, + + async onRepositoryProviderCompleteInstallation(params: PluginRepositoryProviderCompleteInstallationParams) { + const payload = verifyState(params.state); + if (payload.companyId !== params.companyId) throw new Error("state company mismatch"); + return { + companyId: payload.companyId, + userId: payload.userId, + installationId: params.installationId, + accountId: "account-1", + accountName: "acme", + host: HOST, + providerMetadata: { account_login: "acme" }, + }; + }, + + async onRepositoryProviderDiscover(params: PluginRepositoryProviderDiscoverParams) { + assertScope(params); + const pageSize = params.pageSize ?? 2; + const page = params.cursor ? Number.parseInt(params.cursor, 10) : 1; + if (!Number.isFinite(page) || page < 1) throw new Error("invalid cursor"); + const query = params.query?.trim().toLowerCase(); + const matches = repos.filter((repo) => !query || `${repo.owner}/${repo.name}`.toLowerCase().includes(query)); + const start = (page - 1) * pageSize; + const slice = matches.slice(start, start + pageSize); + return { + items: slice.map((repo) => ({ + providerRepositoryId: repo.providerRepositoryId, + owner: repo.owner, + name: repo.name, + cloneUrl: cloneUrl(repo), + webUrl: `https://${HOST}/${repo.owner}/${repo.name}`, + defaultBranch: "main", + visibility: "private", + archived: false, + metadata: { owner: repo.owner }, + })), + nextCursor: start + pageSize < matches.length ? String(page + 1) : null, + total: matches.length, + }; + }, + + async onRepositoryProviderRefreshMetadata(params: PluginRepositoryProviderRefreshMetadataParams) { + assertScope(params); + const repo = repos.find((entry) => entry.providerRepositoryId === params.providerRepositoryId); + return { + repository: repo + ? { + providerRepositoryId: repo.providerRepositoryId, + cloneUrl: cloneUrl(repo), + defaultBranch: "main", + visibility: "private", + state: "active", + providerMetadata: { owner: repo.owner, name: repo.name }, + } + : null, + }; + }, + + async onRepositoryProviderSync(params: PluginRepositoryProviderSyncParams) { + assertScope(params); + return { + repositories: repos.map((repo) => ({ + providerRepositoryId: repo.providerRepositoryId, + cloneUrl: cloneUrl(repo), + defaultBranch: "main", + visibility: "private", + state: "active", + })), + cursor: null, + }; + }, + + async onRepositoryProviderResolveCloneCredential( + params: PluginRepositoryProviderResolveCloneCredentialParams, + ) { + assertScope(params); + tokenCounter += 1; + const token = `ghs_conformance_${tokenCounter}`; + return { + username: "x-access-token", + token, + authenticatedCloneUrl: + `https://x-access-token:${token}@${params.repository.host}/${params.repository.owner}/${params.repository.name}.git`, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + audit: { installationId: params.connection.installationId }, + }; + }, + }; + + return { + handlers: { ...handlers, ...overrides }, + providerKey: PROVIDER, + host: HOST, + companyId: COMPANY_ID, + userId: USER_ID, + installationId: INSTALLATION_ID, + accountName: "acme", + seed(next) { + repos = next.map((repo) => ({ ...repo })); + }, + rename(providerRepositoryId, next) { + const repo = repos.find((entry) => entry.providerRepositoryId === providerRepositoryId); + if (!repo) throw new Error(`unknown repository ${providerRepositoryId}`); + repo.owner = next.owner; + repo.name = next.name; + }, + }; +} + +describe("repository provider conformance harness", () => { + for (const check of repositoryProviderConformanceChecks(() => createFakeWorld())) { + it(check.name, check.run); + } + + it("fails a provider that accepts tampered installation state", async () => { + const failures = await runRepositoryProviderConformance(() => createFakeWorld({ + async onRepositoryProviderCompleteInstallation(params) { + // Trusts whatever the callback echoed back — the classic forged-state bug. + return { + companyId: params.companyId, + userId: USER_ID, + installationId: params.installationId, + accountId: "account-1", + accountName: "acme", + host: HOST, + providerMetadata: null, + }; + }, + })); + expect(failures.map((failure) => failure.name)).toContain( + "rejects tampered and cross-company installation state", + ); + }); + + it("fails a provider that leaks its credential into the audit trail", async () => { + const failures = await runRepositoryProviderConformance(() => createFakeWorld({ + async onRepositoryProviderResolveCloneCredential(params) { + const token = "ghs_leaked_token"; + return { + username: "x-access-token", + token, + authenticatedCloneUrl: `https://x-access-token:${token}@${params.repository.host}/a/b.git`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + audit: { installationId: token }, + }; + }, + })); + expect(failures.map((failure) => failure.name)).toContain( + "resolves a short-lived clone credential with a secret-free audit trail", + ); + }); + + it("fails a provider that serves a connection from another company", async () => { + const failures = await runRepositoryProviderConformance(() => createFakeWorld({ + async onRepositoryProviderDiscover() { + return { items: [], nextCursor: null, total: 0 }; + }, + })); + expect(failures.map((failure) => failure.name)).toContain( + "rejects calls for a connection belonging to another company", + ); + }); + + it("passes cleanly for the reference implementation", async () => { + const failures = await runRepositoryProviderConformance(() => createFakeWorld()); + expect(failures).toEqual([]); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 09799af06ca..9fcd988e2c7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1348,6 +1348,11 @@ export const PLUGIN_CAPABILITIES = [ "http.outbound", "secrets.read-ref", "environment.drivers.register", + // Register a first-class repository provider connector (installation flow, + // discovery, sync, short-lived clone credentials) with the host registry. + // Trusted-extension surface: the host still owns persistence, company + // scoping, and secret redaction; the plugin only answers provider questions. + "repository.providers.register", "local.folders", // Agent Tools "agent.tools.register", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 19f329cdb89..7e0ac23893c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1390,6 +1390,7 @@ export type { PluginApiRouteDeclaration, PluginObjectReferenceRefreshPolicy, PluginObjectReferenceProviderDeclaration, + PluginRepositoryProviderDeclaration, PaperclipPluginManifestV1, PluginRecord, PluginDatabaseNamespaceRecord, @@ -1443,6 +1444,13 @@ export { type VerifyDocumentAnchorSelectorResult, } from "./document-anchors.js"; +export { + REPOSITORY_PROVIDER_ANY_HOST, + normalizeRepositoryProviderHost, + normalizeRepositoryProviderKey, + repositoryProviderIdentityKey, +} from "./repository-provider-identity.js"; + export { formatExternalObjectMentionSourceLabel, type ExternalObjectCanonicalIdentity, @@ -2186,6 +2194,7 @@ export { pluginLauncherDeclarationSchema, pluginDatabaseDeclarationSchema, pluginApiRouteDeclarationSchema, + pluginRepositoryProviderDeclarationSchema, pluginManifestV1Schema, installPluginSchema, upsertPluginConfigSchema, diff --git a/packages/shared/src/repository-provider-identity.ts b/packages/shared/src/repository-provider-identity.ts new file mode 100644 index 00000000000..2e6e6affbdb --- /dev/null +++ b/packages/shared/src/repository-provider-identity.ts @@ -0,0 +1,69 @@ +/** + * Canonical identity for a repository provider connector. + * + * A connector is identified by its provider key **plus** its normalized host, + * so `github` on `github.com` and `github` on `github.acme-corp.com` are two + * distinct registrations that can never shadow each other. This module is the + * single normalization source shared by the plugin manifest validator, the + * host-side connector registry, and any extension package that needs to + * compute the same key. + * + * Pure and dependency-free on purpose: it is imported by `@paperclipai/shared` + * consumers, by the server registry, and by trusted extension packages. + */ + +/** Wildcard host used when a connector serves any host for its provider. */ +export const REPOSITORY_PROVIDER_ANY_HOST = "*"; + +const PROVIDER_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +/** + * Normalizes a provider key to its canonical lowercase form. + * Returns `null` when the value is not a usable provider key. + */ +export function normalizeRepositoryProviderKey(rawProvider: unknown): string | null { + if (typeof rawProvider !== "string") return null; + const provider = rawProvider.trim().toLowerCase(); + if (!provider || provider.length > 64) return null; + return PROVIDER_KEY_PATTERN.test(provider) ? provider : null; +} + +/** + * Normalizes a provider host to a bare lowercase `host[:port]`. + * + * Rejects anything carrying credentials, a path, a query, or a fragment — a + * host that smuggles those is a redirect/SSRF vector, and it would also make + * connector identity ambiguous. Returns `null` instead of throwing so callers + * (manifest validation, untrusted worker output) decide their own failure mode. + */ +export function normalizeRepositoryProviderHost(rawHost: unknown): string | null { + if (typeof rawHost !== "string") return null; + const trimmed = rawHost.trim(); + if (!trimmed || trimmed.length > 255) return null; + if (trimmed === REPOSITORY_PROVIDER_ANY_HOST) return REPOSITORY_PROVIDER_ANY_HOST; + + let parsed: URL; + try { + parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + } catch { + return null; + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) return null; + if (parsed.pathname && parsed.pathname !== "/") return null; + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + const host = parsed.host.toLowerCase(); + return host || null; +} + +/** + * Builds the canonical registry key for a connector identity. + * Callers must pass already-normalized values; use the normalizers above. + */ +export function repositoryProviderIdentityKey(input: { + provider: string; + host: string; + companyId?: string | null; +}): string { + const scope = input.companyId ? `company:${input.companyId}` : "instance"; + return `${scope}|${input.provider}|${input.host}`; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 79a317bb48f..a109fd0be8f 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -945,6 +945,7 @@ export type { PluginApiRouteDeclaration, PluginObjectReferenceRefreshPolicy, PluginObjectReferenceProviderDeclaration, + PluginRepositoryProviderDeclaration, PaperclipPluginManifestV1, PluginRecord, PluginDatabaseNamespaceRecord, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 2ff9334382f..64beb51ac97 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -564,6 +564,47 @@ export interface PluginObjectReferenceProviderDeclaration { webhookEndpointKeys?: string[]; } +/** + * Declares a first-class repository provider contributed by a trusted + * extension package (e.g. the Paperclip EE GitHub Enterprise provider). + * + * The host owns every persisted row, company scope, and secret boundary. A + * declaration only tells the host *which* connector identity the plugin can + * answer for; the connector itself is reached over the `repositoryProvider*` + * worker RPCs and is registered only while the plugin is ready and running. + * + * Requires the `repository.providers.register` capability. Connector identity + * is `providerKey` plus normalized `host`, so the same provider key may be + * served for several hosts (github.com vs. an enterprise install) without + * either registration shadowing the other. + */ +export interface PluginRepositoryProviderDeclaration { + /** Stable provider key such as "github-enterprise". Lowercase. */ + providerKey: string; + /** Human-readable provider name shown in operator-facing surfaces. */ + displayName: string; + /** Short operator-facing description. */ + description?: string; + /** + * Normalized provider host this connector serves, e.g. `github.example.com`. + * Must be a bare host (optionally with a port) — no scheme, credentials, + * path, query, or fragment. + */ + host: string; + /** + * Declares that the connector implements paginated/searchable discovery. + * Core UI only offers repository browsing when this is true. + */ + supportsDiscovery?: boolean; + /** + * Declares that the connector can mint short-lived clone credentials. + * Credentials are transient: the host never persists or logs them. + */ + supportsCloneCredentials?: boolean; + /** Optional operator-facing documentation URL. */ + documentationUrl?: string; +} + // --------------------------------------------------------------------------- // Plugin Manifest V1 // --------------------------------------------------------------------------- @@ -632,6 +673,11 @@ export interface PaperclipPluginManifestV1 { localFolders?: PluginLocalFolderDeclaration[]; /** External object reference providers this plugin contributes. */ objectReferences?: PluginObjectReferenceProviderDeclaration[]; + /** + * First-class repository providers this plugin contributes. + * Requires the `repository.providers.register` capability. + */ + repositoryProviders?: PluginRepositoryProviderDeclaration[]; /** * Legacy top-level launcher declarations. * Prefer `ui.launchers` for new manifests. diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 5c15c6017a6..046db00458f 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -761,6 +761,7 @@ export { pluginManagedSkillFileDeclarationSchema, pluginManagedSkillDeclarationSchema, pluginApiRouteDeclarationSchema, + pluginRepositoryProviderDeclarationSchema, pluginManifestV1Schema, installPluginSchema, upsertPluginConfigSchema, diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index c2671cc6f07..301c44c3710 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -26,6 +26,10 @@ import { ROUTINE_TRIGGER_SIGNING_MODES, ISSUE_SURFACE_VISIBILITIES, } from "../constants.js"; +import { + normalizeRepositoryProviderHost, + normalizeRepositoryProviderKey, +} from "../repository-provider-identity.js"; import { routineVariableSchema } from "./routine.js"; import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./external-object.js"; @@ -665,6 +669,32 @@ export type PluginObjectReferenceProviderDeclarationInput = z.infer< typeof pluginObjectReferenceProviderDeclarationSchema >; +/** + * A repository provider declaration names the connector identity + * (`providerKey` + normalized `host`) a trusted extension may serve. The host + * refuses to register anything the manifest did not declare, so this schema is + * the outer edge of the deny-by-default boundary. + */ +export const pluginRepositoryProviderDeclarationSchema = z.object({ + providerKey: z.string().trim().min(1).max(64).transform((value) => value.toLowerCase()).refine( + (value) => normalizeRepositoryProviderKey(value) != null, + { message: "providerKey must be lowercase alphanumeric with dots, hyphens, or underscores" }, + ), + displayName: z.string().min(1).max(100), + description: z.string().max(500).optional(), + host: z.string().trim().min(1).max(255).refine( + (value) => normalizeRepositoryProviderHost(value) != null && value !== "*", + { message: "host must be a bare host or host:port with no scheme, credentials, path, query, or fragment" }, + ).transform((value) => normalizeRepositoryProviderHost(value)!), + supportsDiscovery: z.boolean().optional(), + supportsCloneCredentials: z.boolean().optional(), + documentationUrl: z.string().url().max(500).optional(), +}); + +export type PluginRepositoryProviderDeclarationInput = z.infer< + typeof pluginRepositoryProviderDeclarationSchema +>; + // --------------------------------------------------------------------------- // Plugin Manifest V1 schema // --------------------------------------------------------------------------- @@ -745,6 +775,7 @@ export const pluginManifestV1Schema = z.object({ skills: z.array(pluginManagedSkillDeclarationSchema).optional(), localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(), objectReferences: z.array(pluginObjectReferenceProviderDeclarationSchema).optional(), + repositoryProviders: z.array(pluginRepositoryProviderDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(), @@ -803,6 +834,30 @@ export const pluginManifestV1Schema = z.object({ } } + // repository providers require repository.providers.register + if (manifest.repositoryProviders && manifest.repositoryProviders.length > 0) { + if (!manifest.capabilities.includes("repository.providers.register")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'repository.providers.register' is required when repositoryProviders are declared", + path: ["capabilities"], + }); + } + + // Connector identity is provider + normalized host; two declarations that + // collapse to the same identity would make registration order decide which + // one answers, so reject them outright. + const identities = manifest.repositoryProviders.map((entry) => `${entry.providerKey}@${entry.host}`); + const duplicates = identities.filter((identity, i) => identities.indexOf(identity) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate repositoryProviders identities: ${[...new Set(duplicates)].join(", ")}`, + path: ["repositoryProviders"], + }); + } + } + if (manifest.agents && manifest.agents.length > 0) { if (!manifest.capabilities.includes("agents.managed")) { ctx.addIssue({ diff --git a/server/src/__tests__/repository-provider-plugin-bridge.test.ts b/server/src/__tests__/repository-provider-plugin-bridge.test.ts new file mode 100644 index 00000000000..107ab54ac09 --- /dev/null +++ b/server/src/__tests__/repository-provider-plugin-bridge.test.ts @@ -0,0 +1,159 @@ +import { randomUUID } from "node:crypto"; +import { describe } from "vitest"; +import type { RepositoryConnection } from "@paperclipai/shared"; +import type { HostToWorkerMethodName } from "@paperclipai/plugin-sdk"; +import { FakeRepositoryProvider } from "../services/repository-providers/index.js"; +import { createPluginRepositoryProviderConnector } from "../services/repository-providers/plugin-connector.js"; +import { + runProviderConformance, + type ProviderConformanceWorld, +} from "./helpers/repository-provider-conformance.js"; + +/** + * Parity check for the extension seam: the exact conformance suite the + * in-process GitHub.com provider passes is run against a provider reached over + * the plugin worker RPCs. If the wire contract loses a guarantee — a dropped + * cursor, a Date that never round-trips, an error that stops rejecting — this + * fails, so an EE package cannot be blocked by a seam that only looks complete. + * + * The fake "worker" marshals the wire payloads to the in-memory reference + * provider, standing in for a real extension's `onRepositoryProvider*` hooks. + */ + +const HOST = "github.acme-corp.example"; +const PROVIDER = "github-enterprise"; + +function connectionFromSnapshot(snapshot: { + id: string; + companyId: string; + provider: string; + host: string; + installationId: string | null; + accountId: string | null; + accountName: string | null; + syncCursor: string | null; + providerMetadata: Record | null; +}): RepositoryConnection { + const now = new Date(); + return { + id: snapshot.id, + companyId: snapshot.companyId, + provider: snapshot.provider, + host: snapshot.host, + installationId: snapshot.installationId, + accountId: snapshot.accountId, + accountName: snapshot.accountName, + status: "active", + syncStatus: "idle", + syncCursor: snapshot.syncCursor, + syncError: null, + lastSyncedAt: null, + providerMetadata: snapshot.providerMetadata, + disconnectedAt: null, + createdAt: now, + updatedAt: now, + }; +} + +function bridgedWorld(): ProviderConformanceWorld { + const provider = new FakeRepositoryProvider({ provider: PROVIDER, host: HOST, pageSize: 2 }); + const reference = provider.connector(); + const companyId = randomUUID(); + const userId = randomUUID(); + const installationId = `install-${randomUUID().slice(0, 8)}`; + const accountName = "acme"; + + provider.setInstallation({ installationId, accountId: "account-1", accountName, repositories: [] }); + + // Stands in for a plugin worker: JSON-shaped params in, JSON-shaped results + // out, with every Date crossing the boundary as an ISO-8601 string. + const workerManager = { + isRunning: () => true, + call: (async (_pluginId: string, method: HostToWorkerMethodName, params: never) => { + const input = params as Record; + switch (method) { + case "repositoryProviderBeginInstallation": { + const result = await reference.beginInstallation({ + companyId: input.companyId, + userId: input.userId, + redirectPath: input.redirectPath ?? null, + }); + return { ...result, expiresAt: result.expiresAt.toISOString() }; + } + case "repositoryProviderCompleteInstallation": + return await reference.completeInstallation({ + state: input.state, + installationId: input.installationId, + companyId: input.companyId, + }); + case "repositoryProviderDiscover": + return await reference.discover({ + connection: connectionFromSnapshot(input.connection), + query: input.query ?? null, + cursor: input.cursor ?? null, + pageSize: input.pageSize, + }); + case "repositoryProviderRefreshMetadata": + return { + repository: await reference.refreshMetadata({ + connection: connectionFromSnapshot(input.connection), + providerRepositoryId: input.providerRepositoryId, + }), + }; + case "repositoryProviderSync": + return await reference.sync({ + connection: connectionFromSnapshot(input.connection), + cursor: input.cursor ?? null, + }); + case "repositoryProviderDisconnect": + await reference.disconnect?.({ connection: connectionFromSnapshot(input.connection) }); + return undefined; + case "repositoryProviderResolveCloneCredential": { + const result = await reference.resolveCloneCredential({ + connection: connectionFromSnapshot(input.connection), + repository: input.repository, + }); + return { + username: result.username, + token: result.token, + authenticatedCloneUrl: result.authenticatedCloneUrl, + expiresAt: result.expiresAt.toISOString(), + audit: { installationId: result.audit.installationId }, + }; + } + default: + throw new Error(`unexpected worker method ${method}`); + } + }) as never, + }; + + const connector = createPluginRepositoryProviderConnector({ + pluginId: randomUUID(), + pluginKey: "acme.github-enterprise", + declaration: { providerKey: PROVIDER, displayName: "GitHub Enterprise", host: HOST }, + workerManager, + }); + + return { + connector, + companyId, + userId, + installationId, + accountName, + seed(repos) { + provider.setInstallation({ + installationId, + accountId: "account-1", + accountName, + repositories: repos.map((repo) => ({ ...repo })), + }); + }, + rename(providerRepositoryId, next) { + provider.rename(installationId, providerRepositoryId, next); + }, + }; +} + +describe("plugin-bridged repository provider conformance", () => { + runProviderConformance(bridgedWorld); +}); diff --git a/server/src/__tests__/repository-provider-plugin-seam.test.ts b/server/src/__tests__/repository-provider-plugin-seam.test.ts new file mode 100644 index 00000000000..768af1f822c --- /dev/null +++ b/server/src/__tests__/repository-provider-plugin-seam.test.ts @@ -0,0 +1,628 @@ +import { randomUUID } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { PaperclipPluginManifestV1, RepositoryConnection } from "@paperclipai/shared"; +import { pluginManifestV1Schema } from "@paperclipai/shared"; +import type { HostToWorkerMethodName, HostToWorkerMethods } from "@paperclipai/plugin-sdk"; +import { pluginCapabilityValidator } from "../services/plugin-capability-validator.js"; +import { repositoryProviderRegistry } from "../services/repository-connections.js"; +import { + authorizedRepositoryProviderDeclarations, + createPluginRepositoryProviderConnector, + createPluginRepositoryProviderRegistrar, + getRepositoryProviderConnector, + listAvailableRepositoryProviders, + markCloneCredentialNoStore, + pluginRepositoryProviderOwnerKey, + type RepositoryProviderPluginRecord, + type RepositoryProviderPluginSource, +} from "../services/repository-providers/index.js"; + +/** + * Regression coverage for the trusted-extension repository provider seam + * (PAP-17075). These are the failure modes the seam exists to prevent: + * unauthorized registration, registrations outliving their plugin, capability + * claims that outrun what the host can actually serve, secrets escaping into + * anything serializable, and a misbehaving extension being trusted. + */ + +const HOST = "github.acme-corp.example"; +const PROVIDER = "github-enterprise"; +const PLUGIN_ID = "00000000-0000-4000-8000-00000000p1ug".replace("p1ug", "0001"); +const PLUGIN_KEY = "acme.github-enterprise"; + +function manifest(overrides: Partial = {}): PaperclipPluginManifestV1 { + return { + id: PLUGIN_KEY, + apiVersion: 1, + version: "1.0.0", + displayName: "GitHub Enterprise", + description: "Trusted EE repository provider", + author: "Paperclip", + categories: ["connector"], + capabilities: ["repository.providers.register"], + entrypoints: { worker: "dist/worker.js" }, + repositoryProviders: [{ + providerKey: PROVIDER, + displayName: "GitHub Enterprise", + host: HOST, + supportsDiscovery: true, + supportsCloneCredentials: true, + }], + ...overrides, + } as PaperclipPluginManifestV1; +} + +function pluginRecord(overrides: Partial = {}): RepositoryProviderPluginRecord { + return { + id: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + status: "ready", + manifestJson: manifest(), + ...overrides, + }; +} + +/** Minimal in-memory lifecycle emitter matching the manager's on/off surface. */ +function fakeLifecycle() { + const handlers = new Map void>>(); + return { + on(event: string, handler: (payload: never) => void) { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + }, + off(event: string, handler: (payload: never) => void) { + handlers.get(event)?.delete(handler); + }, + emit(event: string, payload: unknown) { + for (const handler of handlers.get(event) ?? []) (handler as (p: unknown) => void)(payload); + }, + }; +} + +interface FakeWorkerOptions { + running?: boolean; + handlers?: Partial<{ + [M in HostToWorkerMethodName]: (params: HostToWorkerMethods[M][0]) => unknown; + }>; +} + +function fakeWorkerManager(options: FakeWorkerOptions = {}) { + const calls: Array<{ method: string; params: unknown }> = []; + let running = options.running ?? true; + return { + calls, + setRunning(next: boolean) { + running = next; + }, + isRunning: () => running, + call: (async (_pluginId: string, method: HostToWorkerMethodName, params: unknown) => { + calls.push({ method, params }); + const handler = options.handlers?.[method] as ((p: unknown) => unknown) | undefined; + if (!handler) throw new Error(`unexpected worker call: ${method}`); + return await handler(params); + }) as never, + }; +} + +function connection(overrides: Partial = {}): RepositoryConnection { + const now = new Date(); + return { + id: randomUUID(), + companyId: randomUUID(), + provider: PROVIDER, + host: HOST, + installationId: "install-1", + accountId: "account-1", + accountName: "acme", + status: "active", + syncStatus: "idle", + syncCursor: null, + syncError: null, + lastSyncedAt: null, + providerMetadata: null, + disconnectedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function pluginSource(records: RepositoryProviderPluginRecord[]): RepositoryProviderPluginSource { + return { + getById: async (pluginId) => records.find((record) => record.id === pluginId) ?? null, + listReady: async () => records.filter((record) => record.status === "ready"), + }; +} + +beforeEach(() => { + repositoryProviderRegistry.unregisterOwner(pluginRepositoryProviderOwnerKey(PLUGIN_ID)); +}); + +describe("manifest authorization", () => { + it("rejects a repositoryProviders manifest that omits the capability", () => { + const withoutCapability = manifest({ capabilities: ["issues.read"] }); + + const parsed = pluginManifestV1Schema.safeParse(withoutCapability); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain("repository.providers.register"); + + const validated = pluginCapabilityValidator().validateManifestCapabilities(withoutCapability); + expect(validated.allowed).toBe(false); + expect(validated.missing).toContain("repository.providers.register"); + }); + + it("accepts a declaration that holds the capability and normalizes its host", () => { + const parsed = pluginManifestV1Schema.safeParse(manifest({ + repositoryProviders: [{ + providerKey: "GitHub-Enterprise", + displayName: "GitHub Enterprise", + host: "HTTPS://GitHub.Acme-Corp.Example/", + }], + })); + expect(parsed.success).toBe(true); + expect(parsed.data?.repositoryProviders?.[0]).toMatchObject({ + providerKey: PROVIDER, + host: HOST, + }); + }); + + it("rejects a host carrying a path, credentials, or a wildcard", () => { + for (const host of ["github.example.com/enterprise", "user:pw@github.example.com", "*"]) { + const parsed = pluginManifestV1Schema.safeParse(manifest({ + repositoryProviders: [{ providerKey: PROVIDER, displayName: "X", host }], + })); + expect(parsed.success, `host ${host} must be rejected`).toBe(false); + } + }); + + it("rejects two declarations that collapse to the same connector identity", () => { + const parsed = pluginManifestV1Schema.safeParse(manifest({ + repositoryProviders: [ + { providerKey: PROVIDER, displayName: "A", host: HOST }, + { providerKey: PROVIDER.toUpperCase(), displayName: "B", host: `HTTPS://${HOST}` }, + ], + })); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain("Duplicate repositoryProviders identities"); + }); + + it("authorizes nothing at all when the capability is missing", () => { + expect(authorizedRepositoryProviderDeclarations(manifest({ capabilities: ["issues.read"] }))).toEqual([]); + expect(authorizedRepositoryProviderDeclarations(manifest())).toHaveLength(1); + }); +}); + +describe("registration lifecycle", () => { + it("does not register a plugin that lacks the capability", async () => { + const lifecycle = fakeLifecycle(); + const registrar = createPluginRepositoryProviderRegistrar({ + lifecycle, + workerManager: fakeWorkerManager(), + plugins: pluginSource([pluginRecord({ manifestJson: manifest({ capabilities: ["issues.read"] }) })]), + }); + await registrar.start(); + + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).toBeNull(); + registrar.stop(); + }); + + it("does not register a plugin whose worker is not running", async () => { + const lifecycle = fakeLifecycle(); + const registrar = createPluginRepositoryProviderRegistrar({ + lifecycle, + workerManager: fakeWorkerManager({ running: false }), + plugins: pluginSource([pluginRecord()]), + }); + await registrar.start(); + + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).toBeNull(); + registrar.stop(); + }); + + it("does not register a plugin that is installed but not ready", async () => { + const lifecycle = fakeLifecycle(); + const registrar = createPluginRepositoryProviderRegistrar({ + lifecycle, + workerManager: fakeWorkerManager(), + plugins: pluginSource([pluginRecord({ status: "disabled" })]), + }); + await registrar.start(); + + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).toBeNull(); + registrar.stop(); + }); + + it("registers on worker start and unregisters on stop, unload, and error", async () => { + const lifecycle = fakeLifecycle(); + const workerManager = fakeWorkerManager({ running: false }); + const registrar = createPluginRepositoryProviderRegistrar({ + lifecycle, + workerManager, + plugins: pluginSource([pluginRecord()]), + }); + await registrar.start(); + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).toBeNull(); + + for (const teardownEvent of ["plugin.worker_stopped", "plugin.unloaded", "plugin.error"]) { + workerManager.setRunning(true); + await registrar.syncPlugin(PLUGIN_ID); + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).not.toBeNull(); + + lifecycle.emit(teardownEvent, { pluginId: PLUGIN_ID, pluginKey: PLUGIN_KEY }); + expect( + getRepositoryProviderConnector(PROVIDER, { host: HOST }), + `${teardownEvent} must drop the registration`, + ).toBeNull(); + } + + registrar.stop(); + }); + + it("drops every registration it owns when stopped", async () => { + const lifecycle = fakeLifecycle(); + const registrar = createPluginRepositoryProviderRegistrar({ + lifecycle, + workerManager: fakeWorkerManager(), + plugins: pluginSource([pluginRecord()]), + }); + await registrar.start(); + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).not.toBeNull(); + + registrar.stop(); + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).toBeNull(); + }); + + it("refuses to let a plugin take over an identity another owner registered", async () => { + const coreUnregister = repositoryProviderRegistry.register( + { provider: PROVIDER, async sync() { return { repositories: [] }; } }, + { host: HOST, descriptor: { displayName: "Core", source: "core" } }, + ); + try { + const lifecycle = fakeLifecycle(); + const registrar = createPluginRepositoryProviderRegistrar({ + lifecycle, + workerManager: fakeWorkerManager(), + plugins: pluginSource([pluginRecord()]), + }); + await registrar.start(); + + // The core registration still owns the identity, and it is a bare + // Foundation adapter — so no connector surface is exposed by the takeover. + expect(getRepositoryProviderConnector(PROVIDER, { host: HOST })).toBeNull(); + const available = listAvailableRepositoryProviders(randomUUID()); + expect(available.find((entry) => entry.provider === PROVIDER)?.source).toBe("core"); + registrar.stop(); + } finally { + coreUnregister(); + } + }); +}); + +describe("capability visibility", () => { + it("only advertises a provider while its plugin is registered", async () => { + const companyId = randomUUID(); + const lifecycle = fakeLifecycle(); + const workerManager = fakeWorkerManager(); + const registrar = createPluginRepositoryProviderRegistrar({ + lifecycle, + workerManager, + plugins: pluginSource([pluginRecord()]), + }); + + expect(listAvailableRepositoryProviders(companyId).some((entry) => entry.provider === PROVIDER)).toBe(false); + + await registrar.start(); + const advertised = listAvailableRepositoryProviders(companyId).find((entry) => entry.provider === PROVIDER); + expect(advertised).toMatchObject({ + provider: PROVIDER, + host: HOST, + displayName: "GitHub Enterprise", + source: "plugin", + pluginKey: PLUGIN_KEY, + supportsDiscovery: true, + supportsCloneCredentials: true, + }); + + lifecycle.emit("plugin.worker_stopped", { pluginId: PLUGIN_ID, pluginKey: PLUGIN_KEY }); + expect(listAvailableRepositoryProviders(companyId).some((entry) => entry.provider === PROVIDER)).toBe(false); + registrar.stop(); + }); + + it("keeps another company's scoped provider out of the listing", () => { + const owner = randomUUID(); + const other = randomUUID(); + const unregister = repositoryProviderRegistry.register( + { provider: "scoped-provider", async sync() { return { repositories: [] }; } }, + { host: "scoped.example.com", companyId: owner, descriptor: { displayName: "Scoped" } }, + ); + try { + expect(listAvailableRepositoryProviders(owner).some((entry) => entry.provider === "scoped-provider")).toBe(true); + expect(listAvailableRepositoryProviders(other).some((entry) => entry.provider === "scoped-provider")).toBe(false); + } finally { + unregister(); + } + }); +}); + +describe("bridge connector", () => { + const declaration = { providerKey: PROVIDER, displayName: "GitHub Enterprise", host: HOST }; + + it("fails closed when the worker is not running", async () => { + const workerManager = fakeWorkerManager({ running: false }); + const connector = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager, + }); + + await expect(connector.discover({ connection: connection() })).rejects.toMatchObject({ status: 422 }); + expect(workerManager.calls).toHaveLength(0); + }); + + it("refuses a connection outside the registered identity", async () => { + const workerManager = fakeWorkerManager(); + const connector = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager, + }); + + await expect( + connector.discover({ connection: connection({ host: "github.com" }) }), + ).rejects.toMatchObject({ status: 422 }); + await expect( + connector.discover({ connection: connection({ provider: "github" }) }), + ).rejects.toMatchObject({ status: 422 }); + expect(workerManager.calls).toHaveLength(0); + }); + + it("refuses an installation result that claims another company or host", async () => { + const companyId = randomUUID(); + const foreignCompany = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderCompleteInstallation: () => ({ + companyId: randomUUID(), + userId: randomUUID(), + installationId: "install-1", + accountId: null, + accountName: null, + host: HOST, + providerMetadata: null, + }), + }, + }), + }); + await expect( + foreignCompany.completeInstallation({ state: "s", installationId: "install-1", companyId }), + ).rejects.toMatchObject({ status: 422 }); + + const foreignHost = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderCompleteInstallation: () => ({ + companyId, + userId: randomUUID(), + installationId: "install-1", + accountId: null, + accountName: null, + host: "evil.example.com", + providerMetadata: null, + }), + }, + }), + }); + await expect( + foreignHost.completeInstallation({ state: "s", installationId: "install-1", companyId }), + ).rejects.toMatchObject({ status: 422 }); + }); + + it("sanitizes untrusted provider metadata and drops malformed rows", async () => { + const connector = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderDiscover: () => ({ + items: [ + { + providerRepositoryId: "1", + owner: "acme", + name: "alpha", + cloneUrl: `https://${HOST}/acme/alpha.git`, + metadata: { owner: "acme", access_token: "ghs_leak", nested: { private_key: "-----BEGIN" } }, + }, + { owner: "acme", name: "missing-id" }, + ], + nextCursor: null, + total: 2, + }), + }, + }), + }); + + const page = await connector.discover({ connection: connection() }); + expect(page.items).toHaveLength(1); + const serialized = JSON.stringify(page.items[0]); + expect(serialized).not.toContain("ghs_leak"); + expect(serialized).not.toContain("BEGIN"); + expect(page.items[0]?.metadata).toMatchObject({ owner: "acme" }); + }); + + it("sanitizes a provider error that embeds a credential", async () => { + const connector = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderSync: () => { + throw new Error("upstream rejected token=ghs_supersecret for https://user:pw@github.example.com/x"); + }, + }, + }), + }); + + await expect(connector.sync({ connection: connection(), cursor: null })).rejects.toMatchObject({ + status: 422, + }); + await connector.sync({ connection: connection(), cursor: null }).catch((err: Error) => { + expect(err.message).not.toContain("ghs_supersecret"); + expect(err.message).not.toContain("user:pw@"); + }); + }); + + it("rebuilds the credential audit from host-known values and marks it no-store", async () => { + const conn = connection(); + const connector = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderResolveCloneCredential: () => ({ + username: "x-access-token", + token: "ghs_transient", + authenticatedCloneUrl: `https://x-access-token:ghs_transient@${HOST}/acme/alpha.git`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + // A hostile provider trying to attribute its use elsewhere. + audit: { installationId: "install-1" }, + connectionId: "someone-elses-connection", + }), + }, + }), + }); + + const resolved = await connector.resolveCloneCredential({ + connection: conn, + repository: { + id: "repo-1", + providerRepositoryId: "1", + owner: "acme", + name: "alpha", + cloneUrl: `https://${HOST}/acme/alpha.git`, + host: HOST, + }, + }); + + expect(resolved.token).toBe("ghs_transient"); + expect(resolved.audit).toMatchObject({ + provider: PROVIDER, + host: HOST, + connectionId: conn.id, + repositoryId: "repo-1", + }); + + // Anything that serializes the credential — logs, portability output, + // prompt rendering — must see a redacted view, not the token. + const serialized = JSON.stringify(resolved); + expect(serialized).not.toContain("ghs_transient"); + expect(serialized).toContain("[redacted]"); + expect(JSON.stringify({ credential: resolved })).not.toContain("ghs_transient"); + }); + + it("rejects an already-expired or unattached credential", async () => { + const expired = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderResolveCloneCredential: () => ({ + username: "x-access-token", + token: "ghs_expired", + authenticatedCloneUrl: `https://x-access-token:ghs_expired@${HOST}/acme/alpha.git`, + expiresAt: new Date(Date.now() - 1_000).toISOString(), + }), + }, + }), + }); + const repository = { + id: "repo-1", + providerRepositoryId: "1", + owner: "acme", + name: "alpha", + cloneUrl: `https://${HOST}/acme/alpha.git`, + host: HOST, + }; + await expect( + expired.resolveCloneCredential({ connection: connection(), repository }), + ).rejects.toMatchObject({ status: 422 }); + + const mismatched = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderResolveCloneCredential: () => ({ + username: "x-access-token", + token: "ghs_one", + authenticatedCloneUrl: `https://x-access-token:ghs_other@${HOST}/acme/alpha.git`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), + }, + }), + }); + await expect( + mismatched.resolveCloneCredential({ connection: connection(), repository }), + ).rejects.toMatchObject({ status: 422 }); + }); + + it("rejects an install URL that is not HTTPS", async () => { + const connector = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderBeginInstallation: () => ({ + installUrl: `http://${HOST}/install`, + state: "state-1", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), + }, + }), + }); + + await expect( + connector.beginInstallation({ companyId: randomUUID(), userId: randomUUID() }), + ).rejects.toMatchObject({ status: 422 }); + }); +}); + +describe("no-store credential marker", () => { + it("redacts the token and clone URL on serialization while keeping them readable", () => { + const expiresAt = new Date(Date.now() + 60_000); + const credential = markCloneCredentialNoStore({ + username: "x-access-token", + token: "ghs_secret", + authenticatedCloneUrl: "https://x-access-token:ghs_secret@example.com/a/b.git", + expiresAt, + audit: { + provider: PROVIDER, + host: HOST, + connectionId: "connection-1", + repositoryId: "repo-1", + installationId: "install-1", + expiresAt, + }, + }); + + expect(credential.token).toBe("ghs_secret"); + expect(JSON.stringify(credential)).not.toContain("ghs_secret"); + expect(JSON.parse(JSON.stringify(credential))).toMatchObject({ + token: "[redacted]", + authenticatedCloneUrl: "[redacted]", + }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 6e9c40ebacc..e408c88baad 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -32,6 +32,7 @@ import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { repositoryRoutes } from "./routes/repositories.js"; import { registerConfiguredRepositoryProviders } from "./services/repository-providers/bootstrap.js"; +import { createPluginRepositoryProviderRegistrar } from "./services/repository-providers/plugin-registrar.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; import { caseRoutes } from "./routes/cases.js"; @@ -477,6 +478,14 @@ export async function createApp( jobStore, }); const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers); + // Repository providers contributed by trusted extension packages are bound to + // the plugin lifecycle: registered when a declaring plugin's worker is ready, + // and dropped on stop, disable, unload, or error. + const repositoryProviderRegistrar = createPluginRepositoryProviderRegistrar({ + db, + lifecycle, + workerManager, + }); let viteHtmlRenderer: ReturnType | null = null; const loader = pluginLoader( db, @@ -649,6 +658,9 @@ export async function createApp( app.use(errorHandler); jobCoordinator.start(); + void repositoryProviderRegistrar.start().catch((err) => { + logger.error({ err }, "Failed to start the repository provider registrar"); + }); scheduler.start(); let feedbackExportShuttingDown = false; let feedbackExportTimer: ReturnType | null = null; @@ -796,6 +808,7 @@ export async function createApp( viteHtmlRenderer?.dispose(); hostServiceCleanup.disposeAll(); hostServiceCleanup.teardown(); + repositoryProviderRegistrar.stop(); }; app.locals.paperclipShutdown = shutdownAppServices; diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts index c6066f9ea70..b42a1ae681a 100644 --- a/server/src/routes/repositories.ts +++ b/server/src/routes/repositories.ts @@ -21,7 +21,10 @@ import { repositoryConnectionService, repositoryService, } from "../services/index.js"; -import { getRepositoryProviderConnector } from "../services/repository-providers/registry.js"; +import { + getRepositoryProviderConnector, + listAvailableRepositoryProviders, +} from "../services/repository-providers/registry.js"; import { forbidden, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; @@ -53,6 +56,17 @@ export function repositoryRoutes(db: Db) { res.json(await repositories.list(companyId, { includeArchived: req.query.includeArchived === "true" })); }); + // Capability surface for core UI: which provider identities this instance can + // actually connect right now. A provider contributed by a trusted extension + // only appears while that extension is installed, ready, and running, so the + // UI never offers a connection the host would refuse. + router.get("/companies/:companyId/repository-providers", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json({ providers: listAvailableRepositoryProviders(companyId) }); + }); + router.post( "/companies/:companyId/repositories", validate(createManualRepositorySchema), @@ -255,7 +269,10 @@ export function repositoryRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const providerKey = (req.params.provider as string).toLowerCase(); - const connector = getRepositoryProviderConnector(providerKey); + // `host` disambiguates a provider key served by more than one host (e.g. + // github.com alongside an enterprise install contributed by an extension). + const requestedHost = typeof req.query.host === "string" ? req.query.host : null; + const connector = getRepositoryProviderConnector(providerKey, { host: requestedHost, companyId }); if (!connector) throw unprocessable(`Repository provider '${providerKey}' is not available`); const { actor } = activityActor(req); const userId = actor.actorType === "user" ? actor.actorId : actor.agentId; @@ -282,7 +299,8 @@ export function repositoryRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const providerKey = (req.params.provider as string).toLowerCase(); - const connector = getRepositoryProviderConnector(providerKey); + const requestedHost = typeof req.query.host === "string" ? req.query.host : null; + const connector = getRepositoryProviderConnector(providerKey, { host: requestedHost, companyId }); if (!connector) throw unprocessable(`Repository provider '${providerKey}' is not available`); const meta = await connector.completeInstallation({ state: req.body.state, @@ -325,7 +343,10 @@ export function repositoryRoutes(db: Db) { "Repository connection not found", ); if (!connection) return; - const connector = getRepositoryProviderConnector(connection.provider); + const connector = getRepositoryProviderConnector(connection.provider, { + host: connection.host, + companyId: connection.companyId, + }); if (!connector) throw unprocessable(`Repository provider '${connection.provider}' does not support discovery`); if (connection.status === "disconnected") throw unprocessable("Disconnected repository connection cannot be discovered"); @@ -374,7 +395,10 @@ export function repositoryRoutes(db: Db) { "Repository connection not found", ); if (!connection) return; - const connector = getRepositoryProviderConnector(connection.provider); + const connector = getRepositoryProviderConnector(connection.provider, { + host: connection.host, + companyId: connection.companyId, + }); if (!connector) throw unprocessable(`Repository provider '${connection.provider}' does not support import`); if (connection.status === "disconnected") throw unprocessable("Disconnected repository connection cannot import repositories"); @@ -447,7 +471,10 @@ export function repositoryRoutes(db: Db) { const connection = await connections.getById(repository.connectionId); if (!connection || connection.status === "disconnected") throw unprocessable("Repository connection is unavailable"); - const connector = getRepositoryProviderConnector(connection.provider); + const connector = getRepositoryProviderConnector(connection.provider, { + host: connection.host, + companyId: connection.companyId, + }); if (!connector) throw unprocessable(`Repository provider '${connection.provider}' cannot resolve clone credentials`); const resolved = await connector.resolveCloneCredential({ diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index 7f03126234e..ee0b496040b 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -147,6 +147,15 @@ const OPERATION_CAPABILITIES: Record = { "environment.captureTemplate": ["environment.drivers.register"], "environment.cancelInteractiveSetup": ["environment.drivers.register"], "environment.deleteTemplate": ["environment.drivers.register"], + + // First-class repository providers contributed by trusted extensions + "repository.provider.beginInstallation": ["repository.providers.register"], + "repository.provider.completeInstallation": ["repository.providers.register"], + "repository.provider.discover": ["repository.providers.register"], + "repository.provider.refreshMetadata": ["repository.providers.register"], + "repository.provider.sync": ["repository.providers.register"], + "repository.provider.disconnect": ["repository.providers.register"], + "repository.provider.resolveCloneCredential": ["repository.providers.register"], }; /** @@ -204,6 +213,7 @@ const FEATURE_CAPABILITIES: Record = { webhooks: "webhooks.receive", database: "database.namespace.migrate", environmentDrivers: "environment.drivers.register", + repositoryProviders: "repository.providers.register", agents: "agents.managed", projects: "projects.managed", routines: "routines.managed", diff --git a/server/src/services/repository-connections.ts b/server/src/services/repository-connections.ts index de5d66f9841..556e53a7e18 100644 --- a/server/src/services/repository-connections.ts +++ b/server/src/services/repository-connections.ts @@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { repositoryConnections } from "@paperclipai/db"; import type { CreateRepositoryConnection, RepositoryConnection } from "@paperclipai/shared"; +import { + REPOSITORY_PROVIDER_ANY_HOST, + normalizeRepositoryProviderHost, + normalizeRepositoryProviderKey, + repositoryProviderIdentityKey, +} from "@paperclipai/shared"; import { conflict, unprocessable } from "../errors.js"; import { normalizeRepositoryHost, @@ -21,19 +27,174 @@ export interface RepositoryProviderAdapter { disconnect?(input: { connection: RepositoryConnection }): Promise; } -class RepositoryProviderRegistry { - private readonly providers = new Map(); +/** + * One registered connector identity. + * + * Identity is provider **plus** normalized host (plus an optional company + * scope), so `github` on github.com and `github` on an enterprise install are + * separate registrations that cannot shadow each other. `ownerKey` records who + * registered it — core bootstrap leaves it null, the plugin registrar sets + * `plugin:` so every registration can be torn down on unload. + */ +/** + * Operator-facing description of a registered connector. Core UI reads this to + * decide which providers to offer, so it must contain nothing secret — only + * what an operator would see on a provider picker. + */ +export interface RepositoryProviderDescriptor { + displayName: string; + description: string | null; + /** Where the connector came from — bundled with core vs. an extension. */ + source: "core" | "plugin"; + pluginKey: string | null; + supportsDiscovery: boolean; + supportsCloneCredentials: boolean; + documentationUrl: string | null; +} - register(provider: RepositoryProviderAdapter) { - const key = provider.provider.toLowerCase(); - this.providers.set(key, provider); +export interface RepositoryProviderRegistration { + readonly provider: string; + readonly host: string; + readonly companyId: string | null; + readonly ownerKey: string | null; + readonly descriptor: RepositoryProviderDescriptor; + readonly adapter: RepositoryProviderAdapter; +} + +export interface RepositoryProviderRegisterOptions { + /** Normalized host this connector serves. Defaults to the adapter's own. */ + host?: string | null; + /** Restrict the registration to one company. Null registers instance-wide. */ + companyId?: string | null; + /** Owner tag used for lifecycle teardown, e.g. `plugin:`. */ + ownerKey?: string | null; + /** Operator-facing description surfaced by the capability listing. */ + descriptor?: Partial; +} + +export interface RepositoryProviderLookup { + host?: string | null; + companyId?: string | null; +} + +class RepositoryProviderRegistry { + private readonly registrations = new Map(); + + /** + * Registers a connector for one identity. + * + * Fails closed on an unusable provider key or host, and refuses to overwrite + * an identity owned by someone else — otherwise a late-loading extension + * could silently take over the connector core already trusts for a host. + */ + register(provider: RepositoryProviderAdapter, options: RepositoryProviderRegisterOptions = {}) { + const normalizedProvider = normalizeRepositoryProviderKey(provider.provider); + if (!normalizedProvider) { + throw new Error(`Repository provider key '${provider.provider}' is not a valid identity`); + } + const rawHost = options.host + ?? (provider as Partial<{ host: string }>).host + ?? REPOSITORY_PROVIDER_ANY_HOST; + const normalizedHost = normalizeRepositoryProviderHost(rawHost); + if (!normalizedHost) { + throw new Error(`Repository provider host '${rawHost}' is not a valid identity`); + } + const companyId = options.companyId ?? null; + const ownerKey = options.ownerKey ?? null; + const key = repositoryProviderIdentityKey({ provider: normalizedProvider, host: normalizedHost, companyId }); + + const existing = this.registrations.get(key); + if (existing && existing.ownerKey !== ownerKey) { + throw new Error( + `Repository provider '${normalizedProvider}' on host '${normalizedHost}' is already registered by another owner`, + ); + } + + const registration: RepositoryProviderRegistration = { + provider: normalizedProvider, + host: normalizedHost, + companyId, + ownerKey, + descriptor: { + displayName: options.descriptor?.displayName ?? normalizedProvider, + description: options.descriptor?.description ?? null, + source: options.descriptor?.source ?? "core", + pluginKey: options.descriptor?.pluginKey ?? null, + supportsDiscovery: options.descriptor?.supportsDiscovery ?? true, + supportsCloneCredentials: options.descriptor?.supportsCloneCredentials ?? true, + documentationUrl: options.descriptor?.documentationUrl ?? null, + }, + adapter: provider, + }; + this.registrations.set(key, registration); return () => { - if (this.providers.get(key) === provider) this.providers.delete(key); + if (this.registrations.get(key) === registration) this.registrations.delete(key); }; } - get(provider: string) { - return this.providers.get(provider.toLowerCase()) ?? null; + /** + * Resolves the connector for a provider, preferring the most specific + * identity: company + host, then instance + host, then the host-agnostic + * fallbacks. When no host is supplied and several hosts are registered for + * the provider the lookup is ambiguous, so it resolves to null rather than + * guessing which enterprise install the caller meant. + */ + get(provider: string, lookup: RepositoryProviderLookup = {}) { + return this.find(provider, lookup)?.adapter ?? null; + } + + find(provider: string, lookup: RepositoryProviderLookup = {}): RepositoryProviderRegistration | null { + const normalizedProvider = normalizeRepositoryProviderKey(provider); + if (!normalizedProvider) return null; + const companyId = lookup.companyId ?? null; + const normalizedHost = lookup.host ? normalizeRepositoryProviderHost(lookup.host) : null; + if (lookup.host && !normalizedHost) return null; + + const candidateKeys = normalizedHost + ? [ + repositoryProviderIdentityKey({ provider: normalizedProvider, host: normalizedHost, companyId }), + repositoryProviderIdentityKey({ provider: normalizedProvider, host: normalizedHost, companyId: null }), + ] + : []; + candidateKeys.push( + repositoryProviderIdentityKey({ + provider: normalizedProvider, + host: REPOSITORY_PROVIDER_ANY_HOST, + companyId, + }), + repositoryProviderIdentityKey({ + provider: normalizedProvider, + host: REPOSITORY_PROVIDER_ANY_HOST, + companyId: null, + }), + ); + for (const key of candidateKeys) { + const registration = this.registrations.get(key); + if (registration) return registration; + } + if (normalizedHost) return null; + + const visible = this.list({ companyId }).filter((entry) => entry.provider === normalizedProvider); + return visible.length === 1 ? visible[0]! : null; + } + + /** Every registration visible to a company (instance-wide plus its own). */ + list(lookup: { companyId?: string | null } = {}): RepositoryProviderRegistration[] { + const companyId = lookup.companyId ?? null; + return [...this.registrations.values()].filter( + (registration) => registration.companyId === null || registration.companyId === companyId, + ); + } + + /** Removes every registration made by an owner. Returns how many were removed. */ + unregisterOwner(ownerKey: string): number { + let removed = 0; + for (const [key, registration] of this.registrations) { + if (registration.ownerKey !== ownerKey) continue; + this.registrations.delete(key); + removed += 1; + } + return removed; } } @@ -104,8 +265,10 @@ export function repositoryConnectionService( create: async (companyId: string, input: CreateRepositoryConnection) => { const provider = input.provider.toLowerCase(); if (provider === "manual") throw unprocessable("Manual repositories do not use provider connections"); - if (!registry.get(provider)) throw unprocessable(`Repository provider '${provider}' is not available`); const host = normalizeRepositoryHost(input.host); + if (!registry.get(provider, { host, companyId })) { + throw unprocessable(`Repository provider '${provider}' is not available`); + } if (input.installationId) { const existing = await db @@ -155,7 +318,7 @@ export function repositoryConnectionService( const row = await getRowById(id); if (!row || row.companyId !== companyId) return null; if (row.status === "disconnected") throw conflict("Disconnected repository connection cannot be synced"); - const provider = registry.get(row.provider); + const provider = registry.get(row.provider, { host: row.host, companyId: row.companyId }); if (!provider) { const failed = await failOperation(row, `Repository provider '${row.provider}' is not available`); throw unprocessable(failed.syncError, { connection: failed.connection }); @@ -205,7 +368,7 @@ export function repositoryConnectionService( const row = await getRowById(id); if (!row || row.companyId !== companyId) return null; if (row.status === "disconnected") return { connection: toRepositoryConnection(row), repositories: [] }; - const provider = registry.get(row.provider); + const provider = registry.get(row.provider, { host: row.host, companyId: row.companyId }); try { await provider?.disconnect?.({ connection: toRepositoryConnection(row) }); } catch (error) { diff --git a/server/src/services/repository-providers/bootstrap.ts b/server/src/services/repository-providers/bootstrap.ts index 5784a223230..b5f24906377 100644 --- a/server/src/services/repository-providers/bootstrap.ts +++ b/server/src/services/repository-providers/bootstrap.ts @@ -1,4 +1,5 @@ import { repositoryProviderRegistry } from "../repository-connections.js"; +import { guardRepositoryProviderConnector } from "./connector-guard.js"; import { createGitHubProvider } from "./github-provider.js"; import { createGitHubAppTransport } from "./github-transport.js"; @@ -32,7 +33,20 @@ export function registerConfiguredRepositoryProviders( apiBaseUrl: env.PAPERCLIP_GITHUB_API_BASE_URL?.trim() || undefined, }); const provider = createGitHubProvider({ transport, config: { appSlug, stateSecret } }); - unregister.push(repositoryProviderRegistry.register(provider)); + unregister.push(repositoryProviderRegistry.register( + // Core providers go through the same hardening wrapper as extensions: + // one contract, one place where provider output is validated. + guardRepositoryProviderConnector(provider, { provider: provider.provider, host: provider.host }), + { + host: provider.host, + descriptor: { + displayName: "GitHub", + source: "core", + supportsDiscovery: true, + supportsCloneCredentials: true, + }, + }, + )); } return unregister; diff --git a/server/src/services/repository-providers/connector-guard.ts b/server/src/services/repository-providers/connector-guard.ts new file mode 100644 index 00000000000..cd7ecb36388 --- /dev/null +++ b/server/src/services/repository-providers/connector-guard.ts @@ -0,0 +1,315 @@ +import { + REPOSITORY_PROVIDER_ANY_HOST, + normalizeRepositoryProviderHost, +} from "@paperclipai/shared"; +import { unprocessable } from "../../errors.js"; +import { + sanitizeRepositoryProviderError, + sanitizeRepositoryProviderMetadata, +} from "../repository-normalization.js"; +import type { + BeginInstallationResult, + CompleteInstallationResult, + DiscoverResult, + DiscoveredRepository, + ProviderRepositorySnapshot, + RepositoryProviderConnector, + ResolveCloneCredentialInput, + ResolveCloneCredentialResult, +} from "./provider-contract.js"; + +/** + * Hardening layer applied to every registered repository provider connector. + * + * A connector is an *input boundary*: whether it is a bundled provider or an + * out-of-process extension, everything it returns is untrusted until proven + * otherwise. This wrapper is the single place that: + * + * - re-validates shapes and drops entries that do not conform, rather than + * letting a malformed row reach persistence; + * - strips secret-looking metadata keys and sanitizes error text before it can + * reach a row, a log line, or an API response; + * - keeps connector identity honest — a connector cannot claim another host; + * - marks the transient clone credential no-store so serializing the result + * (logs, portability output, prompts) cannot leak the token; + * - fails closed: an unexpected throw becomes a sanitized `unprocessable`. + */ + +const MAX_DISCOVERY_ITEMS = 500; +const MAX_SYNC_REPOSITORIES = 5_000; + +/** Marker for a value that must never be persisted, logged, or exported. */ +export const NO_STORE_MARKER = "__paperclipNoStore" as const; + +export interface GuardedCloneCredential extends ResolveCloneCredentialResult { + /** Always true: this value is transient and must not be stored. */ + readonly [NO_STORE_MARKER]: true; +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw unprocessable(`Repository provider returned an invalid ${field}`); + } + return value; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function requireFutureDate(value: unknown, field: string): Date { + const date = value instanceof Date ? value : new Date(String(value ?? "")); + if (Number.isNaN(date.getTime())) { + throw unprocessable(`Repository provider returned an invalid ${field}`); + } + if (date.getTime() <= Date.now()) { + throw unprocessable(`Repository provider returned an already-expired ${field}`); + } + return date; +} + +function requireHttpsUrl(value: unknown, field: string): string { + const raw = requireString(value, field); + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw unprocessable(`Repository provider returned an invalid ${field}`); + } + if (parsed.protocol !== "https:") { + throw unprocessable(`Repository provider ${field} must use HTTPS`); + } + return raw; +} + +function sanitizeDiscoveredRepository(value: unknown): DiscoveredRepository | null { + if (!value || typeof value !== "object") return null; + const item = value as Partial; + if ( + typeof item.providerRepositoryId !== "string" || item.providerRepositoryId.length === 0 + || typeof item.owner !== "string" || item.owner.length === 0 + || typeof item.name !== "string" || item.name.length === 0 + || typeof item.cloneUrl !== "string" || item.cloneUrl.length === 0 + ) { + return null; + } + return { + providerRepositoryId: item.providerRepositoryId, + owner: item.owner, + name: item.name, + cloneUrl: item.cloneUrl, + webUrl: optionalString(item.webUrl), + defaultBranch: optionalString(item.defaultBranch), + visibility: item.visibility, + archived: item.archived === true, + metadata: sanitizeRepositoryProviderMetadata(item.metadata), + }; +} + +function sanitizeSnapshot(value: unknown): ProviderRepositorySnapshot | null { + if (!value || typeof value !== "object") return null; + const snapshot = value as Partial; + if ( + typeof snapshot.providerRepositoryId !== "string" || snapshot.providerRepositoryId.length === 0 + || typeof snapshot.cloneUrl !== "string" || snapshot.cloneUrl.length === 0 + ) { + return null; + } + return { + ...snapshot, + providerRepositoryId: snapshot.providerRepositoryId, + cloneUrl: snapshot.cloneUrl, + providerMetadata: sanitizeRepositoryProviderMetadata(snapshot.providerMetadata), + } as ProviderRepositorySnapshot; +} + +/** + * Wraps a resolved credential so serializing it cannot leak the token. + * + * The token stays readable as a property (the clone-credential endpoint and + * the git transport need it) but `toJSON` — used by `JSON.stringify`, the + * logger, portability export, and prompt rendering — returns a redacted view. + */ +export function markCloneCredentialNoStore( + result: ResolveCloneCredentialResult, +): GuardedCloneCredential { + const guarded = { + ...result, + [NO_STORE_MARKER]: true as const, + }; + Object.defineProperty(guarded, "toJSON", { + enumerable: false, + value: () => ({ + username: result.username, + token: "[redacted]", + authenticatedCloneUrl: "[redacted]", + expiresAt: result.expiresAt, + audit: result.audit, + [NO_STORE_MARKER]: true, + }), + }); + return guarded as GuardedCloneCredential; +} + +export function isNoStoreCloneCredential(value: unknown): boolean { + return Boolean(value && typeof value === "object" && (value as Record)[NO_STORE_MARKER] === true); +} + +async function guarded(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + // Providers surface HTTP-shaped errors deliberately (422 for bad state, + // 409 for conflicts); those pass through with their status intact. Anything + // else becomes a sanitized 422 so a raw provider message — which may embed a + // token or an internal URL — never reaches a caller or a log line. + if (error && typeof error === "object" && typeof (error as { status?: unknown }).status === "number") { + throw error; + } + throw unprocessable(sanitizeRepositoryProviderError(error)); + } +} + +export interface GuardConnectorOptions { + /** + * Identity the connector is allowed to answer for. Results claiming another + * host are rejected rather than silently rewritten, because a mismatch means + * the connector and the host disagree about which install is being talked to. + */ + provider: string; + host: string; +} + +/** + * Returns a connector that enforces the contract on everything the underlying + * implementation returns. + */ +export function guardRepositoryProviderConnector( + connector: RepositoryProviderConnector, + options: GuardConnectorOptions, +): RepositoryProviderConnector { + const provider = options.provider; + const host = options.host; + + function assertHost(claimed: unknown, field: string): string { + if (host === REPOSITORY_PROVIDER_ANY_HOST) { + const normalized = normalizeRepositoryProviderHost(claimed); + if (!normalized) throw unprocessable(`Repository provider returned an invalid ${field}`); + return normalized; + } + const normalized = normalizeRepositoryProviderHost(claimed); + if (normalized !== host) { + throw unprocessable(`Repository provider returned a ${field} outside its registered identity`); + } + return normalized; + } + + return { + provider, + host, + + async beginInstallation(input): Promise { + return await guarded(async () => { + const result = await connector.beginInstallation(input); + return { + installUrl: requireHttpsUrl(result?.installUrl, "install URL"), + state: requireString(result?.state, "installation state"), + expiresAt: requireFutureDate(result?.expiresAt, "installation state expiry"), + }; + }); + }, + + async completeInstallation(input): Promise { + return await guarded(async () => { + const result = await connector.completeInstallation(input); + // Company binding is decided by the host, not reported by the provider: + // a provider that echoes a different company is refused outright. + if (result?.companyId !== input.companyId) { + throw unprocessable("Repository provider returned a company outside the requested scope"); + } + return { + companyId: input.companyId, + userId: requireString(result?.userId, "installation user"), + installationId: requireString(result?.installationId, "installation id"), + accountId: optionalString(result?.accountId), + accountName: optionalString(result?.accountName), + host: assertHost(result?.host, "host"), + providerMetadata: sanitizeRepositoryProviderMetadata(result?.providerMetadata), + }; + }); + }, + + async discover(input): Promise { + return await guarded(async () => { + const result = await connector.discover(input); + const items = Array.isArray(result?.items) ? result.items : []; + if (items.length > MAX_DISCOVERY_ITEMS) { + throw unprocessable("Repository provider returned an oversized discovery page"); + } + return { + items: items.map(sanitizeDiscoveredRepository).filter((item): item is DiscoveredRepository => item != null), + nextCursor: optionalString(result?.nextCursor), + total: typeof result?.total === "number" && Number.isFinite(result.total) ? result.total : null, + }; + }); + }, + + async refreshMetadata(input): Promise { + return await guarded(async () => { + const result = await connector.refreshMetadata(input); + return result == null ? null : sanitizeSnapshot(result); + }); + }, + + async sync(input) { + return await guarded(async () => { + const result = await connector.sync(input); + const repositories = Array.isArray(result?.repositories) ? result.repositories : []; + if (repositories.length > MAX_SYNC_REPOSITORIES) { + throw unprocessable("Repository provider returned an oversized sync page"); + } + return { + repositories: repositories + .map(sanitizeSnapshot) + .filter((snapshot): snapshot is ProviderRepositorySnapshot => snapshot != null), + cursor: optionalString(result?.cursor), + }; + }); + }, + + async disconnect(input) { + if (!connector.disconnect) return; + await guarded(async () => { + await connector.disconnect!(input); + }); + }, + + async resolveCloneCredential(input: ResolveCloneCredentialInput): Promise { + return await guarded(async () => { + const result = await connector.resolveCloneCredential(input); + const expiresAt = requireFutureDate(result?.expiresAt, "clone credential expiry"); + const token = requireString(result?.token, "clone credential token"); + const authenticatedCloneUrl = requireString(result?.authenticatedCloneUrl, "authenticated clone URL"); + if (!authenticatedCloneUrl.includes(token)) { + throw unprocessable("Repository provider returned a clone URL missing its credential"); + } + // The audit line is rebuilt from host-known values so a provider cannot + // attribute its credential use to another connection or company. + return markCloneCredentialNoStore({ + username: requireString(result?.username, "clone credential username"), + token, + authenticatedCloneUrl, + expiresAt, + audit: { + provider, + host, + connectionId: input.connection.id, + repositoryId: input.repository.id, + installationId: optionalString(result?.audit?.installationId) ?? input.connection.installationId, + expiresAt, + }, + }); + }); + }, + }; +} diff --git a/server/src/services/repository-providers/index.ts b/server/src/services/repository-providers/index.ts index aecdfcd326f..aa1754b8e6e 100644 --- a/server/src/services/repository-providers/index.ts +++ b/server/src/services/repository-providers/index.ts @@ -4,4 +4,7 @@ export * from "./github-provider.js"; export * from "./github-transport.js"; export * from "./fake-provider.js"; export * from "./registry.js"; +export * from "./connector-guard.js"; +export * from "./plugin-connector.js"; +export * from "./plugin-registrar.js"; export * from "./bootstrap.js"; diff --git a/server/src/services/repository-providers/plugin-connector.ts b/server/src/services/repository-providers/plugin-connector.ts new file mode 100644 index 00000000000..a3190c305f1 --- /dev/null +++ b/server/src/services/repository-providers/plugin-connector.ts @@ -0,0 +1,269 @@ +import type { + PluginRepositoryConnectionSnapshot, + PluginRepositoryProviderBaseParams, +} from "@paperclipai/plugin-sdk"; +import type { RepositoryConnection, PluginRepositoryProviderDeclaration } from "@paperclipai/shared"; +import { normalizeRepositoryProviderHost } from "@paperclipai/shared"; +import { unprocessable } from "../../errors.js"; +import type { PluginWorkerManager } from "../plugin-worker-manager.js"; +import { sanitizeRepositoryProviderMetadata } from "../repository-normalization.js"; +import { guardRepositoryProviderConnector } from "./connector-guard.js"; +import type { + BeginInstallationInput, + CompleteInstallationInput, + DiscoverInput, + DiscoveredRepository, + ProviderRepositorySnapshot, + RefreshMetadataInput, + RepositoryProviderConnector, + ResolveCloneCredentialInput, +} from "./provider-contract.js"; + +/** + * Bridges a plugin worker into the host's {@link RepositoryProviderConnector} + * contract, so a trusted extension package can serve a first-class repository + * provider without any of its code running in the server process. + * + * The bridge is deliberately thin and paranoid: + * + * - **Deny by default.** It is only built for a manifest-declared identity on a + * plugin that holds `repository.providers.register`, and every call re-checks + * that the worker is still running. A stopped, crashed, or unloaded plugin + * fails closed instead of hanging or half-answering. + * - **Company scoped.** Each RPC carries the company the host resolved, and a + * connection belonging to another company or host is refused before dispatch. + * - **No secrets cross the boundary.** The worker receives a sanitized + * connection snapshot — never a token, key, or raw row — and everything it + * returns passes through {@link guardRepositoryProviderConnector}. + */ + +/** Worker-manager surface the bridge needs; narrow for easy testing. */ +export type RepositoryProviderWorkerHost = Pick; + +export interface PluginRepositoryProviderConnectorOptions { + pluginId: string; + pluginKey: string; + declaration: PluginRepositoryProviderDeclaration; + workerManager: RepositoryProviderWorkerHost; + /** RPC timeout for provider calls. Provider APIs are remote and can be slow. */ + timeoutMs?: number; +} + +const DEFAULT_PROVIDER_RPC_TIMEOUT_MS = 30_000; + +function toSnapshot(connection: RepositoryConnection): PluginRepositoryConnectionSnapshot { + return { + id: connection.id, + companyId: connection.companyId, + provider: connection.provider, + host: connection.host, + installationId: connection.installationId ?? null, + accountId: connection.accountId ?? null, + accountName: connection.accountName ?? null, + status: connection.status, + syncStatus: connection.syncStatus, + syncCursor: connection.syncCursor ?? null, + lastSyncedAt: connection.lastSyncedAt ? connection.lastSyncedAt.toISOString() : null, + providerMetadata: sanitizeRepositoryProviderMetadata(connection.providerMetadata), + }; +} + +function parseDate(value: unknown, field: string): Date { + const date = new Date(String(value ?? "")); + if (Number.isNaN(date.getTime())) { + throw unprocessable(`Repository provider returned an invalid ${field}`); + } + return date; +} + +export function createPluginRepositoryProviderConnector( + options: PluginRepositoryProviderConnectorOptions, +): RepositoryProviderConnector { + const provider = options.declaration.providerKey.toLowerCase(); + const host = normalizeRepositoryProviderHost(options.declaration.host); + if (!host) { + throw new Error( + `Plugin '${options.pluginKey}' declared repository provider host '${options.declaration.host}', which is not a valid identity`, + ); + } + const timeoutMs = options.timeoutMs ?? DEFAULT_PROVIDER_RPC_TIMEOUT_MS; + + function baseParams(companyId: string): PluginRepositoryProviderBaseParams { + if (!options.workerManager.isRunning(options.pluginId)) { + throw unprocessable( + `Repository provider '${provider}' is unavailable because plugin '${options.pluginKey}' is not running`, + ); + } + if (!companyId) { + throw unprocessable(`Repository provider '${provider}' requires a company scope`); + } + return { providerKey: provider, host: host!, companyId }; + } + + /** + * A connection row is only served by the connector registered for its exact + * identity. Without this a provider key registered for two hosts could be + * handed another install's connection and mint credentials against it. + */ + function assertOwnedConnection(connection: RepositoryConnection) { + if (connection.provider.toLowerCase() !== provider) { + throw unprocessable(`Repository connection is not served by provider '${provider}'`); + } + if (normalizeRepositoryProviderHost(connection.host) !== host) { + throw unprocessable(`Repository connection host is outside provider '${provider}' registered identity`); + } + } + + const bridge: RepositoryProviderConnector = { + provider, + host, + + async beginInstallation(input: BeginInstallationInput) { + const result = await options.workerManager.call( + options.pluginId, + "repositoryProviderBeginInstallation", + { + ...baseParams(input.companyId), + userId: input.userId, + redirectPath: input.redirectPath ?? null, + }, + timeoutMs, + ); + return { + installUrl: String(result?.installUrl ?? ""), + state: String(result?.state ?? ""), + expiresAt: parseDate(result?.expiresAt, "installation state expiry"), + }; + }, + + async completeInstallation(input: CompleteInstallationInput) { + const result = await options.workerManager.call( + options.pluginId, + "repositoryProviderCompleteInstallation", + { + ...baseParams(input.companyId), + state: input.state, + installationId: input.installationId, + }, + timeoutMs, + ); + return { + companyId: String(result?.companyId ?? ""), + userId: String(result?.userId ?? ""), + installationId: String(result?.installationId ?? ""), + accountId: result?.accountId ?? null, + accountName: result?.accountName ?? null, + host: String(result?.host ?? ""), + providerMetadata: result?.providerMetadata ?? null, + }; + }, + + async discover(input: DiscoverInput) { + assertOwnedConnection(input.connection); + const result = await options.workerManager.call( + options.pluginId, + "repositoryProviderDiscover", + { + ...baseParams(input.connection.companyId), + connection: toSnapshot(input.connection), + query: input.query ?? null, + cursor: input.cursor ?? null, + pageSize: input.pageSize, + }, + timeoutMs, + ); + // Shapes are only asserted here; the guard wrapper does the validating. + return { + items: (Array.isArray(result?.items) ? result.items : []) as unknown as DiscoveredRepository[], + nextCursor: result?.nextCursor ?? null, + total: result?.total ?? null, + }; + }, + + async refreshMetadata(input: RefreshMetadataInput) { + assertOwnedConnection(input.connection); + const result = await options.workerManager.call( + options.pluginId, + "repositoryProviderRefreshMetadata", + { + ...baseParams(input.connection.companyId), + connection: toSnapshot(input.connection), + providerRepositoryId: input.providerRepositoryId, + }, + timeoutMs, + ); + return (result?.repository ?? null) as unknown as ProviderRepositorySnapshot | null; + }, + + async sync(input) { + assertOwnedConnection(input.connection); + const result = await options.workerManager.call( + options.pluginId, + "repositoryProviderSync", + { + ...baseParams(input.connection.companyId), + connection: toSnapshot(input.connection), + cursor: input.cursor, + }, + timeoutMs, + ); + return { + repositories: (Array.isArray(result?.repositories) + ? result.repositories + : []) as unknown as ProviderRepositorySnapshot[], + cursor: result?.cursor ?? null, + }; + }, + + async disconnect(input) { + assertOwnedConnection(input.connection); + await options.workerManager.call( + options.pluginId, + "repositoryProviderDisconnect", + { + ...baseParams(input.connection.companyId), + connection: toSnapshot(input.connection), + }, + timeoutMs, + ); + }, + + async resolveCloneCredential(input: ResolveCloneCredentialInput) { + assertOwnedConnection(input.connection); + const result = await options.workerManager.call( + options.pluginId, + "repositoryProviderResolveCloneCredential", + { + ...baseParams(input.connection.companyId), + connection: toSnapshot(input.connection), + repository: { + id: input.repository.id, + providerRepositoryId: input.repository.providerRepositoryId, + owner: input.repository.owner, + name: input.repository.name, + cloneUrl: input.repository.cloneUrl, + host: input.repository.host, + }, + }, + timeoutMs, + ); + const expiresAt = parseDate(result?.expiresAt, "clone credential expiry"); + return { + username: String(result?.username ?? ""), + token: String(result?.token ?? ""), + authenticatedCloneUrl: String(result?.authenticatedCloneUrl ?? ""), + expiresAt, + audit: { + provider, + host: host!, + connectionId: input.connection.id, + repositoryId: input.repository.id, + installationId: result?.audit?.installationId ?? input.connection.installationId, + expiresAt, + }, + }; + }, + }; + + return guardRepositoryProviderConnector(bridge, { provider, host }); +} diff --git a/server/src/services/repository-providers/plugin-registrar.ts b/server/src/services/repository-providers/plugin-registrar.ts new file mode 100644 index 00000000000..c898d2c29bc --- /dev/null +++ b/server/src/services/repository-providers/plugin-registrar.ts @@ -0,0 +1,222 @@ +import type { Db } from "@paperclipai/db"; +import type { + PaperclipPluginManifestV1, + PluginRepositoryProviderDeclaration, +} from "@paperclipai/shared"; +import { normalizeRepositoryProviderHost, normalizeRepositoryProviderKey } from "@paperclipai/shared"; +import { logger } from "../../middleware/logger.js"; +import type { PluginLifecycleManager } from "../plugin-lifecycle.js"; +import { pluginRegistryService } from "../plugin-registry.js"; +import { repositoryProviderRegistry } from "../repository-connections.js"; +import { createPluginRepositoryProviderConnector, type RepositoryProviderWorkerHost } from "./plugin-connector.js"; +import { listAvailableRepositoryProviders, type RepositoryProviderAvailability } from "./registry.js"; + +/** + * Binds plugin-contributed repository providers to the plugin lifecycle. + * + * This is the deny-by-default gate for the whole seam. A connector is + * registered only when *all* of the following hold, and it is torn down the + * moment any of them stops holding: + * + * 1. the plugin is `ready` (installed, enabled, not errored); + * 2. its manifest declares `repository.providers.register`; + * 3. its manifest declares the exact provider + host identity; + * 4. its worker process is running. + * + * Nothing here trusts the plugin to deregister itself: every registration is + * tagged `plugin:` and the lifecycle handlers drop the whole tag on + * stop, disable, and unload. A crashed worker therefore stops being an + * available provider rather than lingering as a connector that always throws. + */ + +/** The plugin facts registration depends on. Kept narrow so tests can fake it. */ +export interface RepositoryProviderPluginRecord { + id: string; + pluginKey: string; + status: string; + manifestJson: PaperclipPluginManifestV1; +} + +export interface RepositoryProviderPluginSource { + getById(pluginId: string): Promise; + listReady(): Promise; +} + +export interface PluginRepositoryProviderRegistrarOptions { + /** Database used to build the default plugin source. Required unless `plugins` is given. */ + db?: Db; + lifecycle: Pick; + workerManager: RepositoryProviderWorkerHost; + plugins?: RepositoryProviderPluginSource; + registry?: Pick< + typeof repositoryProviderRegistry, + "register" | "unregisterOwner" | "list" + >; +} + +function databasePluginSource(db: Db): RepositoryProviderPluginSource { + const registry = pluginRegistryService(db); + return { + getById: (pluginId) => registry.getById(pluginId) as Promise, + listReady: () => registry.listByStatus("ready") as Promise, + }; +} + +export interface PluginRepositoryProviderRegistrar { + /** Wire lifecycle listeners and register providers for already-ready plugins. */ + start(): Promise; + /** Re-evaluate one plugin (register or tear down as its state dictates). */ + syncPlugin(pluginId: string): Promise; + /** Drop every registration owned by a plugin. Returns how many were removed. */ + unregisterPlugin(pluginId: string): number; + /** Providers a company may currently connect, for core UI capability display. */ + listAvailableProviders(companyId: string): RepositoryProviderAvailability[]; + /** Remove lifecycle listeners and every registration this registrar made. */ + stop(): void; +} + +export function pluginRepositoryProviderOwnerKey(pluginId: string) { + return `plugin:${pluginId}`; +} + +/** + * Declarations a plugin is allowed to serve, after capability and shape checks. + * Returns an empty list — never a partial one — when the plugin is not + * authorized, so a missing capability can never register "just the safe parts". + */ +export function authorizedRepositoryProviderDeclarations( + manifest: PaperclipPluginManifestV1 | null | undefined, +): PluginRepositoryProviderDeclaration[] { + if (!manifest) return []; + if (!manifest.capabilities?.includes("repository.providers.register")) return []; + const declarations = manifest.repositoryProviders ?? []; + if (declarations.length === 0) return []; + + const seen = new Set(); + const authorized: PluginRepositoryProviderDeclaration[] = []; + for (const declaration of declarations) { + const provider = normalizeRepositoryProviderKey(declaration?.providerKey); + const host = normalizeRepositoryProviderHost(declaration?.host); + // A wildcard host would let one extension answer for every enterprise + // install, including hosts core already serves. Identity must be concrete. + if (!provider || !host || host === "*") continue; + const identity = `${provider}@${host}`; + if (seen.has(identity)) continue; + seen.add(identity); + authorized.push({ ...declaration, providerKey: provider, host }); + } + return authorized; +} + +export function createPluginRepositoryProviderRegistrar( + options: PluginRepositoryProviderRegistrarOptions, +): PluginRepositoryProviderRegistrar { + const log = logger.child({ service: "repository-provider-registrar" }); + const registry = options.registry ?? repositoryProviderRegistry; + if (!options.plugins && !options.db) { + throw new Error("createPluginRepositoryProviderRegistrar requires either a db or a plugin source"); + } + const plugins = options.plugins ?? databasePluginSource(options.db!); + const ownedIdentities = new Map(); + + function unregisterPlugin(pluginId: string): number { + const removed = registry.unregisterOwner(pluginRepositoryProviderOwnerKey(pluginId)); + ownedIdentities.delete(pluginId); + return removed; + } + + async function syncPlugin(pluginId: string): Promise { + // Always tear down first: re-registering after a config change or worker + // restart must not leave a stale connector bound to a dead worker. + unregisterPlugin(pluginId); + + const plugin = await plugins.getById(pluginId); + if (!plugin || plugin.status !== "ready") return 0; + if (!options.workerManager.isRunning(pluginId)) return 0; + + const declarations = authorizedRepositoryProviderDeclarations(plugin.manifestJson); + if (declarations.length === 0) return 0; + + const ownerKey = pluginRepositoryProviderOwnerKey(pluginId); + const identities: string[] = []; + for (const declaration of declarations) { + const identity = `${declaration.providerKey}@${declaration.host}`; + try { + const connector = createPluginRepositoryProviderConnector({ + pluginId, + pluginKey: plugin.pluginKey, + declaration, + workerManager: options.workerManager, + }); + registry.register(connector, { + host: declaration.host, + ownerKey, + descriptor: { + displayName: declaration.displayName, + description: declaration.description ?? null, + source: "plugin", + pluginKey: plugin.pluginKey, + supportsDiscovery: declaration.supportsDiscovery !== false, + supportsCloneCredentials: declaration.supportsCloneCredentials !== false, + documentationUrl: declaration.documentationUrl ?? null, + }, + }); + identities.push(identity); + } catch (err) { + // A rejected identity (bad host, already owned by core) must not stop + // the plugin's other declarations, and must not be silently invisible. + log.warn({ err, pluginId, pluginKey: plugin.pluginKey, identity }, "repository provider registration refused"); + } + } + ownedIdentities.set(pluginId, identities); + if (identities.length > 0) { + log.info({ pluginId, pluginKey: plugin.pluginKey, identities }, "registered plugin repository providers"); + } + return identities.length; + } + + const onWorkerStarted = ({ pluginId }: { pluginId: string }) => { + void syncPlugin(pluginId).catch((err) => { + log.error({ err, pluginId }, "failed to register plugin repository providers"); + }); + }; + const onTeardown = ({ pluginId }: { pluginId: string }) => { + const removed = unregisterPlugin(pluginId); + if (removed > 0) log.info({ pluginId, removed }, "unregistered plugin repository providers"); + }; + + return { + async start() { + options.lifecycle.on("plugin.worker_started", onWorkerStarted); + options.lifecycle.on("plugin.enabled", onWorkerStarted); + options.lifecycle.on("plugin.worker_stopped", onTeardown); + options.lifecycle.on("plugin.disabled", onTeardown); + options.lifecycle.on("plugin.unloaded", onTeardown); + options.lifecycle.on("plugin.error", onTeardown); + + // Catch plugins whose workers started before this registrar was wired. + for (const plugin of await plugins.listReady()) { + await syncPlugin(plugin.id); + } + }, + + syncPlugin, + unregisterPlugin, + + listAvailableProviders(companyId: string) { + return listAvailableRepositoryProviders(companyId, registry); + }, + + stop() { + options.lifecycle.off("plugin.worker_started", onWorkerStarted); + options.lifecycle.off("plugin.enabled", onWorkerStarted); + options.lifecycle.off("plugin.worker_stopped", onTeardown); + options.lifecycle.off("plugin.disabled", onTeardown); + options.lifecycle.off("plugin.unloaded", onTeardown); + options.lifecycle.off("plugin.error", onTeardown); + for (const pluginId of [...ownedIdentities.keys()]) { + unregisterPlugin(pluginId); + } + }, + }; +} diff --git a/server/src/services/repository-providers/registry.ts b/server/src/services/repository-providers/registry.ts index 69e3d275224..36ec0511f16 100644 --- a/server/src/services/repository-providers/registry.ts +++ b/server/src/services/repository-providers/registry.ts @@ -1,4 +1,8 @@ -import { repositoryProviderRegistry } from "../repository-connections.js"; +import { + repositoryProviderRegistry, + type RepositoryProviderDescriptor, + type RepositoryProviderLookup, +} from "../repository-connections.js"; import type { RepositoryProviderConnector } from "./provider-contract.js"; /** @@ -19,10 +23,49 @@ export function isProviderConnector(value: unknown): value is RepositoryProvider ); } +/** + * Resolves the connector for a provider identity. + * + * Pass `lookup.host` (and `lookup.companyId` where known) so the right + * connector answers when several hosts serve the same provider key — the + * github.com connector must never answer for an enterprise host, and vice + * versa. Omitting the host only resolves when the identity is unambiguous. + */ export function getRepositoryProviderConnector( provider: string, + lookup: RepositoryProviderLookup = {}, registry: Pick = repositoryProviderRegistry, ): RepositoryProviderConnector | null { - const adapter = registry.get(provider); + const adapter = registry.get(provider, lookup); return isProviderConnector(adapter) ? adapter : null; } + +/** One connectable provider identity as presented to core UI. */ +export interface RepositoryProviderAvailability extends RepositoryProviderDescriptor { + provider: string; + host: string; +} + +/** + * Providers a company can currently connect. + * + * Availability is derived from live registrations, never from what a manifest + * claims: an extension whose worker is stopped or unloaded disappears from this + * list, so core UI stops offering a provider the host could not actually serve. + */ +export function listAvailableRepositoryProviders( + companyId: string, + registry: Pick = repositoryProviderRegistry, +): RepositoryProviderAvailability[] { + return registry + .list({ companyId }) + .map((registration) => ({ + provider: registration.provider, + host: registration.host, + ...registration.descriptor, + // Discovery is only real when the adapter implements the extended + // connector surface, whatever the declaration claims. + supportsDiscovery: registration.descriptor.supportsDiscovery && isProviderConnector(registration.adapter), + })) + .sort((a, b) => (a.provider === b.provider ? a.host.localeCompare(b.host) : a.provider.localeCompare(b.provider))); +}