mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
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:
1 parent
d832e56fcb
commit
a7d6eea66a
27 files changed
+3231
-20
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -945,6 +945,7 @@ export type {
|
||||
PluginApiRouteDeclaration,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PluginRepositoryProviderDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -761,6 +761,7 @@ export {
|
||||
pluginManagedSkillFileDeclarationSchema,
|
||||
pluginManagedSkillDeclarationSchema,
|
||||
pluginApiRouteDeclarationSchema,
|
||||
pluginRepositoryProviderDeclarationSchema,
|
||||
pluginManifestV1Schema,
|
||||
installPluginSchema,
|
||||
upsertPluginConfigSchema,
|
||||
|
||||
@@ -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]",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Block a user