fix(repositories): make provider disconnect optional and disambiguate hosts

Skip optional disconnect hooks when a worker does not advertise them. Forward provider hosts through the UI connection flow so hosted and enterprise identities can coexist.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 83d0f420ff
commit 3d4c484816
9 files changed
+314 -35

No files matched your search

@@ -1,9 +1,13 @@
import { randomUUID } from "node:crypto";
import { describe } from "vitest";
import { describe, expect, it } from "vitest";
import type { RepositoryConnection } from "@paperclipai/shared";
import type { HostToWorkerMethodName } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import { FakeRepositoryProvider } from "../services/repository-providers/index.js";
import { createPluginRepositoryProviderConnector } from "../services/repository-providers/plugin-connector.js";
import {
createPluginRepositoryProviderConnector,
REQUIRED_REPOSITORY_PROVIDER_METHODS,
} from "../services/repository-providers/plugin-connector.js";
import {
runProviderConformance,
type ProviderConformanceWorld,
@@ -157,3 +161,90 @@ function bridgedWorld(): ProviderConformanceWorld {
describe("plugin-bridged repository provider conformance", () => {
runProviderConformance(bridgedWorld);
});
/**
* Disconnect is the one provider hook a plugin may leave out — a provider with
* nothing to revoke on its side has no work to do. The host must still tear the
* connection down locally. Dispatching the RPC anyway makes the worker answer
* `METHOD_NOT_IMPLEMENTED`, which would strand the connection in `error` and
* leave an operator unable to disconnect at all.
*/
describe("plugin-bridged optional disconnect", () => {
const connection = connectionFromSnapshot({
id: randomUUID(),
companyId: randomUUID(),
provider: PROVIDER,
host: HOST,
installationId: "install-1",
accountId: "account-1",
accountName: "acme",
syncCursor: null,
providerMetadata: null,
});
function connectorWith(worker: { supportedMethods?: string[]; call: () => Promise<unknown> }) {
const calls: string[] = [];
const connector = createPluginRepositoryProviderConnector({
pluginId: randomUUID(),
pluginKey: "acme.github-enterprise",
declaration: { providerKey: PROVIDER, displayName: "GitHub Enterprise", host: HOST },
workerManager: {
isRunning: () => true,
getWorker: ((() =>
worker.supportedMethods ? { supportedMethods: worker.supportedMethods } : undefined) as never),
call: (async (_pluginId: string, method: HostToWorkerMethodName) => {
calls.push(method);
return await worker.call();
}) as never,
},
});
return { connector, calls };
}
it("does not dispatch when the worker does not advertise the hook", async () => {
const { connector, calls } = connectorWith({
supportedMethods: [...REQUIRED_REPOSITORY_PROVIDER_METHODS],
call: async () => {
throw new Error("must not dispatch");
},
});
await expect(connector.disconnect!({ connection })).resolves.toBeUndefined();
expect(calls).toEqual([]);
});
it("tolerates METHOD_NOT_IMPLEMENTED from a worker that advertises nothing", async () => {
const { connector, calls } = connectorWith({
call: async () => {
throw new JsonRpcCallError({
code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED,
message: "repositoryProviderDisconnect is not implemented",
});
},
});
await expect(connector.disconnect!({ connection })).resolves.toBeUndefined();
expect(calls).toEqual(["repositoryProviderDisconnect"]);
});
it("dispatches when the worker advertises the hook", async () => {
const { connector, calls } = connectorWith({
supportedMethods: [...REQUIRED_REPOSITORY_PROVIDER_METHODS, "repositoryProviderDisconnect"],
call: async () => undefined,
});
await connector.disconnect!({ connection });
expect(calls).toEqual(["repositoryProviderDisconnect"]);
});
it("still surfaces a real provider failure", async () => {
const { connector } = connectorWith({
supportedMethods: [...REQUIRED_REPOSITORY_PROVIDER_METHODS, "repositoryProviderDisconnect"],
call: async () => {
throw new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, message: "worker crashed" });
},
});
await expect(connector.disconnect!({ connection })).rejects.toThrow("worker crashed");
});
});
@@ -2,6 +2,7 @@ import type {
PluginRepositoryConnectionSnapshot,
PluginRepositoryProviderBaseParams,
} from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import type { RepositoryConnection, PluginRepositoryProviderDeclaration } from "@paperclipai/shared";
import { normalizeRepositoryProviderHost } from "@paperclipai/shared";
import { unprocessable } from "../../errors.js";
@@ -57,6 +58,14 @@ export const REQUIRED_REPOSITORY_PROVIDER_METHODS = [
"repositoryProviderResolveCloneCredential",
] as const;
/**
* Hooks a repository provider may leave unimplemented. Disconnect is advisory —
* it lets a provider revoke an installation on its side — so a provider that has
* nothing to revoke is allowed to omit it, and the host still tears the
* connection down locally.
*/
export const OPTIONAL_REPOSITORY_PROVIDER_METHODS = ["repositoryProviderDisconnect"] as const;
/**
* Methods a running worker declared at initialization but does not implement.
* Returns an empty list when the worker manager cannot report them, since an
@@ -71,6 +80,29 @@ export function missingRepositoryProviderMethods(
return REQUIRED_REPOSITORY_PROVIDER_METHODS.filter((method) => !supported.includes(method));
}
/**
* Whether a worker advertises an optional method.
*
* A worker that reports no method list at all predates the advertisement, so it
* is given the benefit of the doubt and the call is attempted — a worker that
* turns out not to implement it answers `METHOD_NOT_IMPLEMENTED`, which the
* caller treats as "nothing to do".
*/
function workerAdvertisesMethod(
workerManager: RepositoryProviderWorkerHost,
pluginId: string,
method: string,
): boolean {
const supported = workerManager.getWorker?.(pluginId)?.supportedMethods;
if (!supported) return true;
return supported.includes(method);
}
/** True when an RPC failure means "the worker does not implement this method". */
function isMethodNotImplemented(error: unknown): boolean {
return error instanceof JsonRpcCallError && error.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED;
}
export interface PluginRepositoryProviderConnectorOptions {
pluginId: string;
pluginKey: string;
@@ -246,17 +278,30 @@ export function createPluginRepositoryProviderConnector(
};
},
/**
* Disconnect is optional for a provider. When the worker does not advertise
* the hook — or answers `METHOD_NOT_IMPLEMENTED` because it predates the
* advertisement — the host still tears the connection down locally instead
* of failing the disconnect and stranding the connection in `error`.
*/
async disconnect(input) {
assertOwnedConnection(input.connection);
await options.workerManager.call(
options.pluginId,
"repositoryProviderDisconnect",
{
...baseParams(input.connection.companyId),
connection: toSnapshot(input.connection),
},
timeoutMs,
);
if (!workerAdvertisesMethod(options.workerManager, options.pluginId, "repositoryProviderDisconnect")) {
return;
}
try {
await options.workerManager.call(
options.pluginId,
"repositoryProviderDisconnect",
{
...baseParams(input.connection.companyId),
connection: toSnapshot(input.connection),
},
timeoutMs,
);
} catch (error) {
if (!isMethodNotImplemented(error)) throw error;
}
},
async resolveCloneCredential(input: ResolveCloneCredentialInput) {