evOo-local/packages/adapters/src/installed-connectors.ts
cynarAI f14b112cdd
Broker GitHub Integrations token for computer git/gh (#203)
* Broker GitHub integration token for computer git/gh

Use the Integrations GitHub MCP secret for supervisor broker sessions
so home and project computers can clone private repos without PAT prompts.
Add rakazo git/gh shims, push policy, deploy image rebuild, and tests.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger product checks after runner timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: run product change detection on GitHub-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format deployment-github-token

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: self-hosted change detection with timeout and fail-open gate

Keep Detect product changes on evoo-ci with a 10-minute cap, continue-on-error
filtering, and a fail-open product flag when detection fails. Downstream jobs
and Product CI treat a failed or timed-out changes job as full product impact.
Add job timeouts on remaining self-hosted ci jobs.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: continueRun tests and broker home path guard

- Skip broker workspace resolution when computer homeKey is absent
- Add shared continue-run Prisma/home/secretStore test stubs
- Update computer PATH expectation and evo-deploy command sequence
- Remove unused executor imports flagged by Biome

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: Biome format and import order for broker CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: compose YAML, typecheck for broker stubs and GitHub hint

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger Product CI after compose and typecheck fixes

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retry Product CI queue

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: give self-hosted Web E2E a 45m job timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: extend Web E2E job timeout to 60m on self-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: keep github-broker-token off the core browser barrel

Export broker HMAC helpers only from @rakazo/core/node/github-broker-token so
Vite does not pull node:crypto into the web client and break Playwright signup.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-11 22:58:48 +02:00

617 lines
21 KiB
TypeScript

import type {
AdapterContext,
ConnectorCall,
ConnectorEvent,
ConnectorProvider,
ConnectorTool,
} from "@rakazo/adapter-kit";
import { isGithubInstall } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { z } from "zod";
import {
AuthSchema,
applyCredential,
asRecord,
assertNoSensitiveQuery,
HeaderName,
isSensitiveHeader,
isTransportHeader,
PublicHeadersSchema,
readBoundedText,
requireCredential,
} from "./connector-http.js";
import {
combineSignals,
redactConnectorPayload,
sanitizeConnectorError,
} from "./connector-safety.js";
import { executeGraphqlOperation, GraphqlConfigSchema } from "./graphql-connectors.js";
import {
CATALOG_EXECUTE,
catalogEntries,
catalogGroupLabel,
DIRECT_TOOL_LIMIT,
disambiguateInstalledToolNames,
executeLazyCatalogControl,
isLazyCatalogControlRoute,
lazyCatalogTools,
resolveCatalogCall,
} from "./lazy-tool-catalog.js";
import {
assertSafeRemoteUrl,
callRemoteMcpTool,
createSafeRemoteFetch,
listRemoteMcpTools,
type RemoteTransportDependencies,
} from "./remote-mcp.js";
import type { EncryptedSecretStore } from "./secrets.js";
const ModelHeaderName = HeaderName.refine(
(name) => !isSensitiveHeader(name),
"Sensitive headers cannot be model-controlled",
);
const McpAuthSchema = z
.object({
type: z.enum(["none", "bearer", "header"]).default("none"),
name: HeaderName.optional(),
})
.default({ type: "none" });
const McpConfigSchema = z.object({
preset: z.enum(["treg", "github", "custom"]).default("custom"),
auth: McpAuthSchema,
headers: PublicHeadersSchema,
});
const ApiOperationSchema = z.object({
id: z.string().min(1).max(160),
name: z.string().min(1).max(160).optional(),
description: z.string().max(2_000).optional(),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string().startsWith("/").max(2_048),
inputSchema: z.record(z.string(), z.unknown()).default({
type: "object",
properties: {},
}),
readOnly: z.boolean().default(false),
queryParameters: z.array(z.string().min(1).max(120)).default([]),
headerParameters: z.array(ModelHeaderName).default([]),
});
const ApiConfigSchema = z.object({
auth: AuthSchema,
headers: PublicHeadersSchema,
operations: z.array(ApiOperationSchema).min(1).max(100),
});
type ApiOperation = z.infer<typeof ApiOperationSchema>;
type InstalledRow = {
id: string;
kind: string;
name: string;
source: string;
secretId: string | null;
config: unknown;
};
export type RemoteConnectorDependencies = RemoteTransportDependencies;
export class InstalledConnectorProvider implements ConnectorProvider {
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: EncryptedSecretStore,
private readonly remote: RemoteConnectorDependencies = {},
) {}
describe() {
return {
id: "installed",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { discover: true, oauth: false, secretsBrokered: true },
};
}
async discoverTools(context: AdapterContext): Promise<ConnectorTool[]> {
const tools = await this.authorizedTools(context);
if (tools.length <= DIRECT_TOOL_LIMIT) return tools;
return lazyCatalogTools("installed", "installed", "API", catalogEntries(tools));
}
async resolveCall(
call: ConnectorCall,
context: AdapterContext,
): Promise<{ call: ConnectorCall; tool: ConnectorTool } | undefined> {
// Wrappers have no resourceId; real tools always do.
if (call.route?.resourceId || call.route?.toolName !== CATALOG_EXECUTE) return undefined;
return resolveCatalogCall(call, catalogEntries(await this.authorizedTools(context)));
}
private async authorizedTools(context: AdapterContext): Promise<ConnectorTool[]> {
// A user's tool sources follow them into every space they work in.
const installs = await this.prisma.capabilityInstall.findMany({
where: {
userId: context.userId,
kind: { in: ["mcp", "api", "graphql"] },
},
orderBy: { createdAt: "asc" },
});
const tools: ConnectorTool[] = [];
for (let offset = 0; offset < installs.length; offset += 4) {
const groups = await Promise.all(
installs.slice(offset, offset + 4).map((install) => this.discoverInstall(install, context)),
);
tools.push(...groups.flat());
}
return disambiguateInstalledToolNames(tools);
}
private async discoverInstall(
install: InstalledRow,
context: AdapterContext,
): Promise<ConnectorTool[]> {
const catalogGroup = catalogGroupLabel(install.name, install.kind, install.id);
try {
if (install.kind === "mcp") {
const config = McpConfigSchema.parse(install.config);
const credential = await this.loadCredential(install, context);
const remote = await listRemoteMcpTools({
endpoint: install.source,
headers: connectorHeaders(config, credential),
signal: context.signal,
fetch: this.remote.fetch,
resolveHostname: this.remote.resolveHostname,
});
return remote.map((tool) => ({
...tool,
route: {
connectorId: "installed",
resourceId: install.id,
toolName: tool.name,
catalogGroup,
},
}));
}
if (install.kind === "api") {
const config = ApiConfigSchema.parse(install.config);
return config.operations.map((operation) => ({
name: operation.name ?? operation.id,
description: operation.description ?? `${operation.method} ${operation.path}`,
inputSchema: operation.inputSchema,
readOnly: operation.readOnly,
route: {
connectorId: "installed",
resourceId: install.id,
toolName: operation.id,
catalogGroup,
},
}));
}
if (install.kind === "graphql") {
const config = GraphqlConfigSchema.parse(install.config);
return config.operations.map((operation) => ({
name: operation.name ?? operation.id,
description: operation.description ?? `${operation.operationType} ${operation.fieldName}`,
inputSchema: operation.inputSchema,
readOnly: operation.readOnly,
route: {
connectorId: "installed",
resourceId: install.id,
toolName: operation.id,
catalogGroup,
},
}));
}
} catch (error) {
if (install.kind === "mcp") {
const parsed = McpConfigSchema.safeParse(install.config);
if (parsed.success && parsed.data.preset === "github") {
getLogger().warn("GitHub MCP discovery failed", {
installId: install.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
// One unavailable user-installed source must not hide the other tools.
}
return [];
}
async *execute(call: ConnectorCall, context: AdapterContext): AsyncIterable<ConnectorEvent> {
if (call.route?.connectorId === "installed" && isLazyCatalogControlRoute(call.route)) {
try {
yield* executeLazyCatalogControl(
call,
catalogEntries(await this.authorizedTools(context)),
(resolved) => this.execute(resolved, context),
);
} catch (error) {
yield { type: "error", message: sanitizeConnectorError(error) };
}
return;
}
const installId = call.route?.resourceId;
if (!installId) {
yield { type: "error", message: "Installed connector route is missing" };
return;
}
const install = await this.prisma.capabilityInstall.findFirst({
where: {
id: installId,
userId: context.userId,
kind: { in: ["mcp", "api", "graphql"] },
},
});
if (!install) {
yield { type: "error", message: "Installed connector is unavailable" };
return;
}
let credential: string | undefined;
try {
credential = await this.loadCredential(install, context);
if (install.kind === "mcp") {
const config = McpConfigSchema.parse(install.config);
const result = await callRemoteMcpTool(
{
endpoint: install.source,
headers: connectorHeaders(config, credential),
signal: context.signal,
fetch: this.remote.fetch,
resolveHostname: this.remote.resolveHostname,
},
call.route?.toolName ?? call.tool,
call.args,
);
yield {
type: "result",
data: redactConnectorPayload(result, credential ? [credential] : []),
};
return;
}
if (install.kind === "graphql") {
const config = GraphqlConfigSchema.parse(install.config);
const operation = config.operations.find(
(candidate) => candidate.id === (call.route?.toolName ?? call.tool),
);
if (!operation) throw new Error("GraphQL operation is unavailable");
const result = await executeGraphqlOperation(
install.source,
config,
operation,
call.args,
credential,
context.signal,
this.remote,
);
yield {
type: "result",
data: redactConnectorPayload(result, credential ? [credential] : []),
};
return;
}
const config = ApiConfigSchema.parse(install.config);
const operation = config.operations.find(
(candidate) => candidate.id === (call.route?.toolName ?? call.tool),
);
if (!operation) throw new Error("API operation is unavailable");
const result = await executeApiOperation(
install.source,
config,
operation,
call.args,
credential,
context.signal,
this.remote,
);
yield {
type: "result",
data: redactConnectorPayload(result, credential ? [credential] : []),
};
} catch (error) {
yield {
type: "error",
message: sanitizeConnectorError(error, credential ? [credential] : []),
};
}
}
private async loadCredential(
install: Pick<InstalledRow, "secretId">,
context: AdapterContext,
): Promise<string | undefined> {
if (!install.secretId) return undefined;
const row = await this.prisma.secret.findFirst({
where: { id: install.secretId, userId: context.userId },
});
return row ? this.secrets.load(row.ciphertext, row.id) : undefined;
}
}
export async function githubIntegrationDiscoveryHint(
prisma: PrismaClient,
userId: string,
tools: readonly ConnectorTool[],
): Promise<string | undefined> {
const installs = await prisma.capabilityInstall.findMany({
where: { userId, kind: "mcp" },
select: { id: true, kind: true, config: true, secretId: true },
});
const githubInstall = installs.find((install) =>
isGithubInstall({ kind: install.kind, config: asRecord(install.config) ?? {} }),
);
if (!githubInstall?.secretId) return undefined;
const hasGithubTools = tools.some((tool) => {
const group = tool.route?.catalogGroup?.toLowerCase();
return group === "github" || tool.name.toLowerCase().includes("github");
});
if (hasGithubTools) return undefined;
return "GitHub is connected under Integrations but its MCP tools are unreachable right now. Use git and gh on your computer (brokered auth) for repository work, or ask the user to reconnect GitHub if that fails.";
}
export async function verifyMcpInstall(input: {
source: string;
config: unknown;
credential?: string;
signal?: AbortSignal;
remote?: RemoteConnectorDependencies;
}): Promise<{ config: Record<string, unknown>; toolCount: number }> {
assertNoSensitiveQuery(input.source);
const config = McpConfigSchema.parse(input.config);
requireCredential(config.auth, input.credential);
const tools = await listRemoteMcpTools({
endpoint: input.source,
headers: connectorHeaders(config, input.credential),
signal: input.signal,
fetch: input.remote?.fetch,
resolveHostname: input.remote?.resolveHostname,
});
if (tools.length === 0) throw new Error("MCP server returned no tools");
return { config, toolCount: tools.length };
}
export async function prepareApiInstall(input: {
source: string;
config: Record<string, unknown>;
credential?: string;
signal?: AbortSignal;
remote?: RemoteConnectorDependencies;
}): Promise<{ source: string; config: Record<string, unknown>; operationCount: number }> {
const auth = AuthSchema.parse(input.config.auth);
requireCredential(auth, input.credential);
const headers = PublicHeadersSchema.parse(input.config.headers);
assertNoSensitiveQuery(input.source);
let source = input.source;
let operationsValue = input.config.operations;
if (input.config.openApi === true || input.config.openapi === true) {
const documentUrl = new URL(input.source);
const documentHeaders: Record<string, string> = { accept: "application/json", ...headers };
applyCredential(documentUrl, documentHeaders, auth, input.credential);
const document = await loadOpenApiDocument(
documentUrl,
documentHeaders,
input.signal,
input.remote,
);
const imported = importOpenApiDocument(document);
source = imported.baseUrl;
operationsValue = imported.operations;
}
await assertSafeRemoteUrl(source, input.remote?.resolveHostname);
const config = ApiConfigSchema.parse({ auth, headers, operations: operationsValue });
return {
source,
config,
operationCount: config.operations.length,
};
}
async function loadOpenApiDocument(
url: URL,
headers: Record<string, string>,
signal?: AbortSignal,
remote: RemoteConnectorDependencies = {},
): Promise<Record<string, unknown>> {
const safeFetch = createSafeRemoteFetch(remote.fetch, remote.resolveHostname);
try {
const response = await safeFetch(url, {
headers,
signal: combineSignals(signal, AbortSignal.timeout(15_000)),
});
if (!response.ok) throw new Error(`OpenAPI document returned HTTP ${response.status}`);
const size = Number(response.headers.get("content-length") ?? 0);
if (size > 2_000_000) throw new Error("OpenAPI document is too large");
const { text, truncated } = await readBoundedText(response, 2_000_000);
if (truncated) throw new Error("OpenAPI document is too large");
return JSON.parse(text) as Record<string, unknown>;
} finally {
await safeFetch.close().catch(() => undefined);
}
}
export function importOpenApiDocument(document: Record<string, unknown>): {
baseUrl: string;
operations: ApiOperation[];
} {
const server = Array.isArray(document.servers) ? document.servers[0] : undefined;
const baseUrl =
server && typeof server === "object" && typeof (server as { url?: unknown }).url === "string"
? String((server as { url: string }).url)
: "";
if (!baseUrl) throw new Error("OpenAPI document must define servers[0].url");
assertNoSensitiveQuery(baseUrl);
const paths = document.paths;
if (!paths || typeof paths !== "object" || Array.isArray(paths)) {
throw new Error("OpenAPI document has no paths");
}
const operations: ApiOperation[] = [];
for (const [path, pathValue] of Object.entries(paths)) {
if (!pathValue || typeof pathValue !== "object" || Array.isArray(pathValue)) continue;
for (const method of ["get", "post", "put", "patch", "delete"] as const) {
const raw = (pathValue as Record<string, unknown>)[method];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
const operation = raw as Record<string, unknown>;
const id = typeof operation.operationId === "string" ? operation.operationId : undefined;
if (!id) continue;
const properties: Record<string, unknown> = {};
const required: string[] = [];
const queryParameters: string[] = [];
const headerParameters: string[] = [];
const commonParameters = Array.isArray((pathValue as Record<string, unknown>).parameters)
? ((pathValue as Record<string, unknown>).parameters as unknown[])
: [];
const ownParameters = Array.isArray(operation.parameters)
? (operation.parameters as unknown[])
: [];
for (const parameterValue of [...commonParameters, ...ownParameters]) {
if (!parameterValue || typeof parameterValue !== "object") continue;
const parameter = parameterValue as Record<string, unknown>;
if (typeof parameter.name !== "string") continue;
properties[parameter.name] = asRecord(parameter.schema) ?? { type: "string" };
if (parameter.in === "query") queryParameters.push(parameter.name);
if (parameter.in === "header") {
if (isSensitiveHeader(parameter.name) || isTransportHeader(parameter.name)) {
throw new Error(`OpenAPI operation ${id} contains unsafe header ${parameter.name}`);
}
headerParameters.push(parameter.name);
}
if (parameter.required === true) required.push(parameter.name);
}
const requestBody = asRecord(operation.requestBody);
const content = asRecord(requestBody?.content);
const jsonBody = asRecord(content?.["application/json"]);
const bodySchema = asRecord(jsonBody?.schema);
if (bodySchema) {
properties.body = bodySchema;
if (requestBody?.required === true) required.push("body");
}
operations.push({
id,
name: id,
description:
typeof operation.description === "string"
? operation.description
: typeof operation.summary === "string"
? operation.summary
: `${method.toUpperCase()} ${path}`,
method: method.toUpperCase() as ApiOperation["method"],
path,
inputSchema: {
type: "object",
properties,
...(required.length > 0 ? { required: [...new Set(required)] } : {}),
},
readOnly: method === "get",
queryParameters,
headerParameters,
});
if (operations.length >= 100) break;
}
if (operations.length >= 100) break;
}
if (operations.length === 0) {
throw new Error("OpenAPI document has no operations with operationId");
}
return { baseUrl, operations };
}
function connectorHeaders(
config: z.infer<typeof McpConfigSchema>,
credential?: string,
): Record<string, string> {
const headers = { ...config.headers };
if (!credential || config.auth.type === "none") return headers;
const name = config.auth.type === "header" ? config.auth.name : "authorization";
if (!name) throw new Error("Authentication header name is required");
headers[name] = config.auth.type === "bearer" ? `Bearer ${credential}` : credential;
return headers;
}
async function executeApiOperation(
baseUrl: string,
config: z.infer<typeof ApiConfigSchema>,
operation: ApiOperation,
args: Record<string, unknown>,
credential: string | undefined,
signal: AbortSignal,
remote: RemoteConnectorDependencies,
): Promise<unknown> {
const consumed = new Set<string>();
const path = operation.path.replace(/\{([^}]+)\}/g, (_match, name: string) => {
const value = args[name];
if (value == null) throw new Error(`Missing path parameter ${name}`);
consumed.add(name);
return encodeURIComponent(String(value));
});
const url = joinApiUrl(baseUrl, path);
const headers: Record<string, string> = { accept: "application/json", ...config.headers };
let body: string | undefined;
for (const key of operation.headerParameters) {
const value = args[key];
consumed.add(key);
if (value != null) headers[key] = String(value);
}
if (operation.method === "GET" || operation.method === "DELETE") {
for (const [key, value] of Object.entries(args)) {
if (consumed.has(key) || value == null) continue;
if (Array.isArray(value)) {
for (const item of value) url.searchParams.append(key, String(item));
} else if (typeof value !== "object") {
url.searchParams.set(key, String(value));
}
}
} else {
for (const key of operation.queryParameters) {
const value = args[key];
consumed.add(key);
if (value == null) continue;
if (Array.isArray(value)) {
for (const item of value) url.searchParams.append(key, String(item));
} else {
url.searchParams.set(key, String(value));
}
}
const payload =
args.body ?? Object.fromEntries(Object.entries(args).filter(([key]) => !consumed.has(key)));
body = JSON.stringify(payload);
headers["content-type"] = "application/json";
}
applyCredential(url, headers, config.auth, credential);
const safeFetch = createSafeRemoteFetch(remote.fetch, remote.resolveHostname);
try {
const response = await safeFetch(url, {
method: operation.method,
headers,
body,
signal: combineSignals(signal, AbortSignal.timeout(30_000)),
});
const { text, truncated } = await readBoundedText(response, 1_000_000);
let data: unknown = text;
try {
data = text ? JSON.parse(text) : null;
} catch {
// Keep non-JSON API responses as bounded text.
}
if (!response.ok) {
return {
error: true,
status: response.status,
data,
...(truncated ? { truncated: true } : {}),
};
}
return { status: response.status, data, ...(truncated ? { truncated: true } : {}) };
} finally {
await safeFetch.close().catch(() => undefined);
}
}
function joinApiUrl(baseUrl: string, path: string): URL {
const base = new URL(baseUrl);
const prefix = base.pathname.replace(/\/$/, "");
base.pathname = `${prefix}/${path.replace(/^\//, "")}`;
base.search = "";
base.hash = "";
return base;
}