feat(repositories): expose the provider contract to trusted extensions

Add a deny-by-default plugin capability, manifest declaration, worker RPC bridge, lifecycle-bound registration, provider discovery endpoint, output hardening, and a reusable extension conformance suite.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent d832e56fcb
commit a7d6eea66a
27 files changed
+3231 -20

No files matched your search

+39
View File
@@ -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
+8
View File
@@ -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"
+71
View File
@@ -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<PluginEnvironmentDeleteTemplateResult>;
// -------------------------------------------------------------------------
// 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<PluginRepositoryProviderBeginInstallationResult>;
/** Called with the provider callback's signed state to finish installation. */
onRepositoryProviderCompleteInstallation?(
params: PluginRepositoryProviderCompleteInstallationParams,
): Promise<PluginRepositoryProviderCompleteInstallationResult>;
/** Called to list repositories visible to a connection, paginated/searchable. */
onRepositoryProviderDiscover?(
params: PluginRepositoryProviderDiscoverParams,
): Promise<PluginRepositoryProviderDiscoverResult>;
/** Called to re-read one repository's metadata (rename/transfer/visibility). */
onRepositoryProviderRefreshMetadata?(
params: PluginRepositoryProviderRefreshMetadataParams,
): Promise<PluginRepositoryProviderRefreshMetadataResult>;
/** Called to enumerate a connection's repositories for idempotent sync. */
onRepositoryProviderSync?(
params: PluginRepositoryProviderSyncParams,
): Promise<PluginRepositoryProviderSyncResult>;
/** Called when a connection is disconnected so the provider can revoke state. */
onRepositoryProviderDisconnect?(
params: PluginRepositoryProviderDisconnectParams,
): Promise<void>;
/**
* 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<PluginRepositoryProviderResolveCloneCredentialResult>;
}
// ---------------------------------------------------------------------------
+17
View File
@@ -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,
+204
View File
@@ -923,6 +923,175 @@ export interface PluginEnvironmentDeleteTemplateResult {
metadata?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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;
// ---------------------------------------------------------------------------
@@ -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<PluginRepositoryProviderBeginInstallationResult>;
onRepositoryProviderCompleteInstallation(
params: PluginRepositoryProviderCompleteInstallationParams,
): Promise<PluginRepositoryProviderCompleteInstallationResult>;
onRepositoryProviderDiscover(
params: PluginRepositoryProviderDiscoverParams,
): Promise<PluginRepositoryProviderDiscoverResult>;
onRepositoryProviderRefreshMetadata(
params: PluginRepositoryProviderRefreshMetadataParams,
): Promise<PluginRepositoryProviderRefreshMetadataResult>;
onRepositoryProviderSync(
params: PluginRepositoryProviderSyncParams,
): Promise<PluginRepositoryProviderSyncResult>;
onRepositoryProviderResolveCloneCredential(
params: PluginRepositoryProviderResolveCloneCredentialParams,
): Promise<PluginRepositoryProviderResolveCloneCredentialResult>;
onRepositoryProviderDisconnect?(
params: PluginRepositoryProviderDisconnectParams,
): Promise<void>;
}
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<RepositoryProviderConformanceWorld>;
export interface RepositoryProviderConformanceCheck {
name: string;
run(): Promise<void>;
}
/** 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<unknown>, 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<RepositoryProviderConformanceFailure[]> {
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;
}
+100
View File
@@ -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
// -----------------------------------------------------------------------
@@ -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<string, unknown>) {
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<RepositoryProviderConformanceHandlers> = {},
): 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([]);
});
});
+5
View File
@@ -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",
+9
View File
@@ -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,
@@ -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}`;
}
+1
View File
@@ -945,6 +945,7 @@ export type {
PluginApiRouteDeclaration,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PluginRepositoryProviderDeclaration,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
+46
View File
@@ -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.
+1
View File
@@ -761,6 +761,7 @@ export {
pluginManagedSkillFileDeclarationSchema,
pluginManagedSkillDeclarationSchema,
pluginApiRouteDeclarationSchema,
pluginRepositoryProviderDeclarationSchema,
pluginManifestV1Schema,
installPluginSchema,
upsertPluginConfigSchema,
+55
View File
@@ -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({
@@ -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<string, unknown> | 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<string, never>;
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);
});
@@ -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> = {}): 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> = {}): 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<string, Set<(payload: never) => 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> = {}): 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]",
});
});
});
+13
View File
@@ -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<typeof createCachedViteHtmlRenderer> | 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<typeof setInterval> | null = null;
@@ -796,6 +808,7 @@ export async function createApp(
viteHtmlRenderer?.dispose();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();
repositoryProviderRegistrar.stop();
};
app.locals.paperclipShutdown = shutdownAppServices;
+33 -6
View File
@@ -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({
@@ -147,6 +147,15 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"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<string, PluginCapability> = {
webhooks: "webhooks.receive",
database: "database.namespace.migrate",
environmentDrivers: "environment.drivers.register",
repositoryProviders: "repository.providers.register",
agents: "agents.managed",
projects: "projects.managed",
routines: "routines.managed",
+174 -11
View File
@@ -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<void>;
}
class RepositoryProviderRegistry {
private readonly providers = new Map<string, RepositoryProviderAdapter>();
/**
* 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:<pluginId>` 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:<pluginId>`. */
ownerKey?: string | null;
/** Operator-facing description surfaced by the capability listing. */
descriptor?: Partial<RepositoryProviderDescriptor>;
}
export interface RepositoryProviderLookup {
host?: string | null;
companyId?: string | null;
}
class RepositoryProviderRegistry {
private readonly registrations = new Map<string, RepositoryProviderRegistration>();
/**
* 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) {
@@ -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;
@@ -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<DiscoveredRepository>;
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<ProviderRepositorySnapshot>;
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<string, unknown>)[NO_STORE_MARKER] === true);
}
async function guarded<T>(operation: () => Promise<T>): Promise<T> {
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<BeginInstallationResult> {
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<CompleteInstallationResult> {
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<DiscoverResult> {
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<ProviderRepositorySnapshot | null> {
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<ResolveCloneCredentialResult> {
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,
},
});
});
},
};
}
@@ -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";
@@ -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<PluginWorkerManager, "call" | "isRunning">;
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 });
}
@@ -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:<pluginId>` 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<RepositoryProviderPluginRecord | null>;
listReady(): Promise<RepositoryProviderPluginRecord[]>;
}
export interface PluginRepositoryProviderRegistrarOptions {
/** Database used to build the default plugin source. Required unless `plugins` is given. */
db?: Db;
lifecycle: Pick<PluginLifecycleManager, "on" | "off">;
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<RepositoryProviderPluginRecord | null>,
listReady: () => registry.listByStatus("ready") as Promise<RepositoryProviderPluginRecord[]>,
};
}
export interface PluginRepositoryProviderRegistrar {
/** Wire lifecycle listeners and register providers for already-ready plugins. */
start(): Promise<void>;
/** Re-evaluate one plugin (register or tear down as its state dictates). */
syncPlugin(pluginId: string): Promise<number>;
/** 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<string>();
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<string, string[]>();
function unregisterPlugin(pluginId: string): number {
const removed = registry.unregisterOwner(pluginRepositoryProviderOwnerKey(pluginId));
ownedIdentities.delete(pluginId);
return removed;
}
async function syncPlugin(pluginId: string): Promise<number> {
// 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);
}
},
};
}
@@ -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<typeof repositoryProviderRegistry, "get"> = 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<typeof repositoryProviderRegistry, "list"> = 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)));
}