diff --git a/workers/api/src/lib/connector-grants.ts b/workers/api/src/lib/connector-grants.ts index d759dc51..822250e4 100644 --- a/workers/api/src/lib/connector-grants.ts +++ b/workers/api/src/lib/connector-grants.ts @@ -1,7 +1,10 @@ import { HttpError } from "./auth.js"; import type { Env } from "../types.js"; -export type ConnectorProvider = "google_drive" | "zoho_workdrive"; +// Widened to `string` (#86): connectors now cover resource types beyond drive/workdrive +// (repos, spreadsheets, …). The known ingest providers stay documented for reference. +// Known values: "google_drive" | "zoho_workdrive" | (any connector id with grantModel:"instance-resource"). +export type ConnectorProvider = string; export interface ConnectorGrant { id: string; diff --git a/workers/api/src/lib/connectors/behaviour-identity.test.ts b/workers/api/src/lib/connectors/behaviour-identity.test.ts new file mode 100644 index 00000000..c9ee25ee --- /dev/null +++ b/workers/api/src/lib/connectors/behaviour-identity.test.ts @@ -0,0 +1,94 @@ +// Proves the #86 refactor is BEHAVIOUR-IDENTICAL: the github + meta tools now obtain +// auth via ctx.connectorClient(...) instead of importing token fns directly, but the +// SAME token source is used (installationTokenForOwner for github, META_ACCESS_TOKEN for +// meta), the SAME requests are made, and the SAME outputs come back. +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Env } from "../../types.js"; + +vi.mock("../github-app.js", () => ({ + githubAppConfigured: () => true, + installationTokenForOwner: vi.fn(), +})); + +import { runRegistryTool } from "../tool-registry.js"; +import { installationTokenForOwner } from "../github-app.js"; + +/** Env whose consent lookup returns write-consent for every (instance,connector). */ +function envWithConsent(extra: Partial = {}): Env { + return { + DB: { prepare: () => ({ bind: () => ({ first: async () => ({ ok: 1 }) }) }) }, + ...extra, + } as unknown as Env; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("github tools stay behaviour-identical through connectorClient", () => { + it("github_workflow_runs mints the owner's installation token and returns the same shape", async () => { + vi.mocked(installationTokenForOwner).mockResolvedValue("gh-installation-token"); + const capturedAuth: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + capturedAuth.push(new Headers(init?.headers).get("Authorization") ?? ""); + return new Response(JSON.stringify({ workflow_runs: [{ status: "completed", conclusion: "success", head_branch: "main" }] }), { status: 200 }); + }); + + const r = await runRegistryTool( + "github_workflow_runs", + { env: envWithConsent(), userId: "u1", instanceId: "i1" }, + { repo: "acme/widgets" }, + ); + expect(r.success).toBe(true); + // Token minted for the repo owner — same as the old inline installationTokenForOwner(env, userId, owner). + expect(installationTokenForOwner).toHaveBeenCalledWith(expect.anything(), "u1", "acme"); + // The GitHub REST calls use the classic `token ` header the tool already built. + expect(capturedAuth[0]).toBe("token gh-installation-token"); + expect(JSON.parse(r.content)[0]).toMatchObject({ status: "completed", conclusion: "success", branch: "main" }); + }); + + it("github_create_issue (write) still resolves the owner's token after the consent gate", async () => { + vi.mocked(installationTokenForOwner).mockResolvedValue("gh-tok"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ number: 7, html_url: "https://github.com/acme/widgets/issues/7" }), { status: 201 }), + ); + const r = await runRegistryTool( + "github_create_issue", + { env: envWithConsent(), userId: "u1", instanceId: "i1" }, + { repo: "acme/widgets", title: "hi" }, + ); + expect(r.success).toBe(true); + expect(r.content).toContain("Opened issue #7"); + expect(installationTokenForOwner).toHaveBeenCalledWith(expect.anything(), "u1", "acme"); + }); +}); + +describe("meta tools stay behaviour-identical through connectorClient", () => { + it("whatsapp_send_message uses the platform META_ACCESS_TOKEN as the Bearer, same request", async () => { + let auth = ""; + let url = ""; + vi.spyOn(globalThis, "fetch").mockImplementation(async (u, init) => { + url = String(u); + auth = new Headers(init?.headers).get("Authorization") ?? ""; + return new Response(JSON.stringify({ messages: [{ id: "wamid.1" }] }), { status: 200 }); + }); + const env = envWithConsent({ META_ACCESS_TOKEN: "meta-business-token", WHATSAPP_PHONE_NUMBER_ID: "phone-1" }); + const r = await runRegistryTool( + "whatsapp_send_message", + { env, userId: "u1", instanceId: "i1" }, + { to: "+14155552671", text: "hello" }, + ); + expect(r.success).toBe(true); + expect(auth).toBe("Bearer meta-business-token"); // same token source as the old ctx.env.META_ACCESS_TOKEN + expect(url).toContain("/phone-1/messages"); + }); + + it("meta tool with the env token unset → same 'not configured' result (no throw)", async () => { + const env = envWithConsent({ WHATSAPP_PHONE_NUMBER_ID: "phone-1" }); // no META_ACCESS_TOKEN + const r = await runRegistryTool( + "whatsapp_send_message", + { env, userId: "u1", instanceId: "i1" }, + { to: "+14155552671", text: "hello" }, + ); + expect(r.success).toBe(false); + expect(r.content).toMatch(/not configured/); + }); +}); diff --git a/workers/api/src/lib/connectors/client.test.ts b/workers/api/src/lib/connectors/client.test.ts new file mode 100644 index 00000000..ee1a1b29 --- /dev/null +++ b/workers/api/src/lib/connectors/client.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Env } from "../../types.js"; +import type { Connector } from "./registry.js"; + +// Mock the collaborators so we can assert connectorClient's dispatch + enforcement in +// isolation from real GitHub/OAuth/DB. Each auth type routes to a distinct collaborator. +vi.mock("./registry.js", () => ({ getConnector: (id: string) => FIXTURES[id] })); +vi.mock("../github-app.js", () => ({ installationTokenForOwner: vi.fn() })); +vi.mock("../connector-oauth.js", () => ({ readConnectorRefreshToken: vi.fn() })); +vi.mock("../connector-grants.js", () => ({ requireConnectorGrant: vi.fn() })); + +import { connectorClient } from "./client.js"; +import { installationTokenForOwner } from "../github-app.js"; +import { readConnectorRefreshToken } from "../connector-oauth.js"; +import { requireConnectorGrant } from "../connector-grants.js"; + +const FIXTURES: Record = { + app_conn: { id: "app_conn", label: "App Conn", auth: "app", scopes: { read: true, write: true }, grantModel: "user", tools: [] }, + oauth_conn: { id: "google_drive", label: "Drive", auth: "oauth", scopes: { read: true, write: false }, grantModel: "user", tools: [] }, + env_token_conn: { id: "env_token_conn", label: "Env Token", auth: "token", scopes: { read: false, write: true }, grantModel: "user", tokenEnv: "META_ACCESS_TOKEN", tools: [] }, + user_token_conn: { id: "user_token_conn", label: "User Token", auth: "token", scopes: { read: true, write: false }, grantModel: "user", tools: [] }, + none_conn: { id: "none_conn", label: "None", auth: "none", scopes: { read: true, write: true }, grantModel: "user", tools: [] }, + readonly_conn: { id: "readonly_conn", label: "Read Only", auth: "app", scopes: { read: true, write: false }, grantModel: "user", tools: [] }, + granted_conn: { id: "granted_conn", label: "Granted", auth: "app", scopes: { read: true, write: true }, grantModel: "instance-resource", tools: [] }, +}; +// Point the oauth fixture at the real Drive endpoint key used by client.ts. +FIXTURES.google_drive = FIXTURES.oauth_conn; + +const caller = { userId: "u1", instanceId: "i1" }; + +afterEach(() => vi.clearAllMocks()); + +describe("connectorClient token dispatch", () => { + it("app auth → installationTokenForOwner, scoped to the resource owner", async () => { + vi.mocked(installationTokenForOwner).mockResolvedValue("gh-token"); + const c = connectorClient({} as Env, "app_conn", caller); + const t = await c.token({ resourceId: "acme/widgets" }); + expect(t).toBe("gh-token"); + expect(installationTokenForOwner).toHaveBeenCalledWith({}, "u1", "acme"); + }); + + it("app auth → throws when no installation token", async () => { + vi.mocked(installationTokenForOwner).mockResolvedValue(null); + const c = connectorClient({} as Env, "app_conn", caller); + await expect(c.token({ resourceId: "acme/widgets" })).rejects.toThrow(/No app_conn access/); + }); + + it("oauth auth → reads the refresh token then mints an access token from the provider endpoint", async () => { + vi.mocked(readConnectorRefreshToken).mockResolvedValue("refresh-xyz"); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ access_token: "access-abc" }), { status: 200 }), + ); + const env = { GOOGLE_CLIENT_ID: "cid", GOOGLE_CLIENT_SECRET: "sec" } as Env; + const c = connectorClient(env, "google_drive", caller); + const t = await c.token(); + expect(t).toBe("access-abc"); + expect(readConnectorRefreshToken).toHaveBeenCalledWith(env, "u1", "google_drive", "Drive"); + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toBe("https://oauth2.googleapis.com/token"); + expect(String((init as RequestInit).body)).toContain("refresh_token=refresh-xyz"); + fetchSpy.mockRestore(); + }); + + it("token auth (platform env) → returns the env token", async () => { + const env = { META_ACCESS_TOKEN: " meta-tok " } as Env; + const c = connectorClient(env, "env_token_conn", caller); + expect(await c.token()).toBe("meta-tok"); + }); + + it("token auth (platform env) → throws a clear error when the env var is unset", async () => { + const c = connectorClient({} as Env, "env_token_conn", caller); + await expect(c.token()).rejects.toThrow(/not configured/); + }); + + it("token auth (no env) → falls back to the user's stored key", async () => { + vi.mocked(readConnectorRefreshToken).mockResolvedValue("stored-key"); + const c = connectorClient({} as Env, "user_token_conn", caller); + expect(await c.token()).toBe("stored-key"); + expect(readConnectorRefreshToken).toHaveBeenCalledWith({}, "u1", "user_token_conn", "User Token"); + }); + + it("none auth → empty token, no collaborators called", async () => { + const c = connectorClient({} as Env, "none_conn", caller); + expect(await c.token()).toBe(""); + expect(installationTokenForOwner).not.toHaveBeenCalled(); + expect(readConnectorRefreshToken).not.toHaveBeenCalled(); + }); + + it("unknown connector → throws", () => { + expect(() => connectorClient({} as Env, "nope", caller)).toThrow(/Unknown connector/); + }); +}); + +describe("connectorClient scope enforcement", () => { + it("read-only connector rejects a write-scoped token request", async () => { + const c = connectorClient({} as Env, "readonly_conn", caller); + await expect(c.token({ scope: "write", resourceId: "acme/x" })).rejects.toThrow(/read-only/); + expect(installationTokenForOwner).not.toHaveBeenCalled(); + }); + + it("read-only connector still serves a read-scoped request", async () => { + vi.mocked(installationTokenForOwner).mockResolvedValue("ro-token"); + const c = connectorClient({} as Env, "readonly_conn", caller); + expect(await c.token({ scope: "read", resourceId: "acme/x" })).toBe("ro-token"); + }); +}); + +describe("connectorClient grant enforcement (fail-closed)", () => { + it("instance-resource connector mints a token only after requireConnectorGrant passes", async () => { + vi.mocked(requireConnectorGrant).mockResolvedValue({} as never); + vi.mocked(installationTokenForOwner).mockResolvedValue("granted-token"); + const c = connectorClient({} as Env, "granted_conn", caller); + const t = await c.token({ resourceId: "res-1" }); + expect(t).toBe("granted-token"); + expect(requireConnectorGrant).toHaveBeenCalledWith({}, "i1", "u1", "granted_conn", "res-1"); + }); + + it("instance-resource connector → 403 when the grant is missing (fail-closed, no token minted)", async () => { + vi.mocked(requireConnectorGrant).mockRejectedValue(Object.assign(new Error("Connector grant does not allow this agent to access that resource"), { status: 403 })); + const c = connectorClient({} as Env, "granted_conn", caller); + await expect(c.token({ resourceId: "res-x" })).rejects.toThrow(/grant does not allow/); + expect(installationTokenForOwner).not.toHaveBeenCalled(); + }); + + it("requireGrant() → 403 when there is no instance context (fail-closed)", async () => { + const c = connectorClient({} as Env, "granted_conn", { userId: "u1" }); + await expect(c.requireGrant("res-1")).rejects.toThrow(/not granted|No instance/); + }); + + it("requireGrant() on a non-resource connector is a misuse → errors", async () => { + const c = connectorClient({} as Env, "app_conn", caller); + await expect(c.requireGrant("res-1")).rejects.toThrow(/not resource-granted/); + }); +}); + +describe("connectorClient.fetch", () => { + it("attaches the minted token as a Bearer header", async () => { + const env = { META_ACCESS_TOKEN: "meta-tok" } as Env; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok", { status: 200 })); + const c = connectorClient(env, "env_token_conn", caller); + await c.fetch("https://graph.example/messages", { method: "POST" }); + const [, init] = fetchSpy.mock.calls[0]; + expect(new Headers((init as RequestInit).headers).get("Authorization")).toBe("Bearer meta-tok"); + fetchSpy.mockRestore(); + }); + + it("attaches no Authorization header for a none-auth connector", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok", { status: 200 })); + const c = connectorClient({} as Env, "none_conn", caller); + await c.fetch("https://relay.example/ping"); + const [, init] = fetchSpy.mock.calls[0]; + expect(new Headers((init as RequestInit).headers).has("Authorization")).toBe(false); + fetchSpy.mockRestore(); + }); +}); diff --git a/workers/api/src/lib/connectors/client.ts b/workers/api/src/lib/connectors/client.ts new file mode 100644 index 00000000..39d5114d --- /dev/null +++ b/workers/api/src/lib/connectors/client.ts @@ -0,0 +1,161 @@ +// connectorClient (issue #86) — the ONE place a connector tool obtains its access +// token and has grant/scope enforced. Handlers no longer import token-minting fns +// directly; they call `ctx.connectorClient(provider)` and get back a small client that +// • token(opts?) — mints/reads the provider's access token (dispatched by auth type) +// • requireGrant(id) — fail-closed grant check for instance-resource connectors (403) +// • fetch(url, init) — a Bearer-authorized fetch (token minted for you) +// This centralizes auth + enforces scope so a new connector declares {auth, scopes, +// grantModel} once instead of hand-rolling token logic in each tool. +import { HttpError } from "../auth.js"; +import type { Env } from "../../types.js"; +import { installationTokenForOwner } from "../github-app.js"; +import { readConnectorRefreshToken } from "../connector-oauth.js"; +import { requireConnectorGrant, type ConnectorGrant } from "../connector-grants.js"; +import { getConnector, type Connector } from "./registry.js"; + +export interface ConnectorClientCaller { + userId?: string; + instanceId?: string; +} + +export interface TokenOpts { + /** For app-auth connectors (github): the resource whose owner the token is minted for (e.g. "owner/name" or "owner"). For instance-resource grants: the granted resourceId. */ + resourceId?: string; + /** The scope the caller intends. A "write" against a read-only connector is rejected. */ + scope?: "read" | "write"; +} + +export interface ConnectorClient { + /** The resolved connector definition. */ + readonly connector: Connector; + /** Mint/read the access token for this connector. Returns "" for auth:"none". */ + token(opts?: TokenOpts): Promise; + /** Fail-closed grant check (instance-resource connectors only). Throws HttpError(403) if not granted. */ + requireGrant(resourceId: string): Promise; + /** A Bearer-authorized fetch — the access token is minted and attached for you. */ + fetch(url: string, init?: RequestInit, opts?: TokenOpts): Promise; +} + +// OAuth token endpoints per provider, so the generalized minter (extracted from +// mintDriveAccessToken) can refresh any oauth connector. Keyed by connector id. +const OAUTH_TOKEN_ENDPOINTS: Record = { + google_drive: "https://oauth2.googleapis.com/token", +}; + +interface OauthClientCreds { + clientId?: string; + clientSecret?: string; +} + +function oauthCreds(env: Env, connectorId: string): OauthClientCreds { + switch (connectorId) { + case "google_drive": + return { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }; + default: + return {}; + } +} + +/** + * Generalized OAuth access-token minter — the mintDriveAccessToken pattern, switched on + * the provider's token endpoint + client credentials. Refreshes a stored refresh token + * into a short-lived access token. + */ +async function mintOauthAccessToken(env: Env, connectorId: string, refreshToken: string): Promise { + const endpoint = OAUTH_TOKEN_ENDPOINTS[connectorId]; + const { clientId, clientSecret } = oauthCreds(env, connectorId); + if (!endpoint || !clientId || !clientSecret) { + throw new HttpError(500, `OAuth is not configured for the ${connectorId} connector on this deployment`); + } + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + if (!res.ok) throw new HttpError(502, `Could not refresh ${connectorId} access (${res.status}). Reconnect it in settings.`); + const data = (await res.json()) as { access_token?: string }; + if (!data.access_token) throw new HttpError(502, `${connectorId} did not return an access token`); + return data.access_token; +} + +/** + * connectorClient(env, provider, {userId, instanceId}) — resolves the connector and + * returns a client that mints its token + enforces grant/scope. Throws HttpError(400) + * for an unknown provider. + */ +export function connectorClient(env: Env, provider: string, caller: ConnectorClientCaller): ConnectorClient { + const resolved = getConnector(provider); + if (!resolved) throw new HttpError(400, `Unknown connector: ${provider}`); + const connector: Connector = resolved; + + function assertScope(opts?: TokenOpts): void { + // A read-only connector (no write scope) can never satisfy a write request. + if (opts?.scope === "write" && !connector.scopes.write) { + throw new HttpError(403, `The ${connector.id} connector is read-only — writes are not permitted.`); + } + } + + async function requireGrant(resourceId: string): Promise { + if (connector.grantModel !== "instance-resource") { + throw new HttpError(500, `The ${connector.id} connector is not resource-granted`); + } + if (!caller.instanceId || !caller.userId) { + throw new HttpError(403, "No instance context — resource access is not granted"); + } + return requireConnectorGrant(env, caller.instanceId, caller.userId, connector.id, resourceId); + } + + async function token(opts?: TokenOpts): Promise { + assertScope(opts); + // instance-resource connectors: a token is only minted once the resource is granted. + if (connector.grantModel === "instance-resource" && opts?.resourceId) { + await requireGrant(opts.resourceId); + } + switch (connector.auth) { + case "none": + return ""; + case "app": { + // GitHub-App installation token, scoped to the resource owner. + const owner = ownerOf(opts?.resourceId ?? ""); + const t = await installationTokenForOwner(env, caller.userId ?? "", owner).catch(() => null); + if (!t) throw new HttpError(403, `No ${connector.id} access for "${owner}".`); + return t; + } + case "oauth": { + const refresh = await readConnectorRefreshToken(env, caller.userId ?? "", connector.id, connector.label); + return mintOauthAccessToken(env, connector.id, refresh); + } + case "token": { + // A platform-env token (e.g. Meta business token) takes precedence; otherwise + // the user's stored key from user_api_keys. + if (connector.tokenEnv) { + const t = env[connector.tokenEnv]?.trim(); + if (!t) throw new HttpError(400, `${connector.label} is not configured`); + return t; + } + return readConnectorRefreshToken(env, caller.userId ?? "", connector.id, connector.label); + } + } + } + + async function authedFetch(url: string, init?: RequestInit, opts?: TokenOpts): Promise { + const t = await token(opts); + const headers = new Headers(init?.headers); + if (t) headers.set("Authorization", `Bearer ${t}`); + return fetch(url, { ...init, headers }); + } + + return { connector, token, requireGrant, fetch: authedFetch }; +} + +/** owner from "owner/name" (or a bare "owner"). */ +function ownerOf(resource: string): string { + const s = String(resource || "").trim(); + if (!s) return ""; + return s.includes("/") ? s.split("/")[0] : s; +} diff --git a/workers/api/src/lib/connectors/github.ts b/workers/api/src/lib/connectors/github.ts index 7c92c722..631f297f 100644 --- a/workers/api/src/lib/connectors/github.ts +++ b/workers/api/src/lib/connectors/github.ts @@ -4,7 +4,7 @@ // the repos the owner's installation covers. Writes (create issue/PR, trigger) come // later behind consent (#90). These replace Coder's hard-wired GH logic over time. import type { ToolDef, RegistryToolCtx } from "../tool-registry.js"; -import { githubAppConfigured, installationTokenForOwner } from "../github-app.js"; +import { githubAppConfigured } from "../github-app.js"; import { listIssues, readIssue } from "../github-issues.js"; const GH = (token: string) => ({ @@ -19,12 +19,18 @@ function ownerOf(repo: string): string { return p.length === 2 && p[0] && p[1] ? p[0] : ""; } -/** Resolve an installation token for the repo's owner, or a helpful error string. */ +/** + * Resolve an installation token for the repo's owner, or a helpful error string. Auth is + * minted via the connectorClient (issue #86): `token({resourceId: repo})` runs the same + * installationTokenForOwner path (the "github" connector is auth:"app"), so behaviour is + * identical — same token, same scoping. The platform-configured + owner-parse checks stay + * here so their user-facing messages are unchanged. + */ async function resolveRepo(ctx: RegistryToolCtx, repo: string): Promise<{ token: string } | { error: string }> { if (!githubAppConfigured(ctx.env)) return { error: "GitHub is not connected on this platform (GitHub App not configured)." }; const owner = ownerOf(repo); if (!owner) return { error: `Invalid repo "${repo}" — use the form "owner/name".` }; - const token = await installationTokenForOwner(ctx.env, ctx.userId ?? "", owner).catch(() => null); + const token = await ctx.connectorClient?.("github").token({ resourceId: repo }).catch(() => null); if (!token) return { error: `No GitHub access for "${owner}". Install/authorize the ProAgentStore GitHub App for that account, then try again.` }; return { token }; } diff --git a/workers/api/src/lib/connectors/meta.ts b/workers/api/src/lib/connectors/meta.ts index b8e58548..20817297 100644 --- a/workers/api/src/lib/connectors/meta.ts +++ b/workers/api/src/lib/connectors/meta.ts @@ -14,8 +14,12 @@ import type { ToolDef, RegistryToolCtx } from "../tool-registry.js"; const GRAPH = "https://graph.facebook.com/v20.0"; -function metaToken(ctx: RegistryToolCtx): string | null { - return ctx.env.META_ACCESS_TOKEN?.trim() || null; +// Auth via the connectorClient (issue #86): the "meta" connector is auth:"token" backed +// by the platform env META_ACCESS_TOKEN, so token() returns exactly what the old inline +// `ctx.env.META_ACCESS_TOKEN?.trim()` did. Caught to null so the handlers keep emitting +// their combined "not configured" message (which also names the missing phone/IG id). +async function metaToken(ctx: RegistryToolCtx): Promise { + return (await ctx.connectorClient?.("meta").token().catch(() => null)) || null; } async function graphPost(token: string, path: string, body: unknown): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> { @@ -49,7 +53,7 @@ export const META_TOOLS: ToolDef[] = [ required: ["to"], }, handler: async (ctx, input) => { - const token = metaToken(ctx); + const token = await metaToken(ctx); const phoneId = ctx.env.WHATSAPP_PHONE_NUMBER_ID?.trim(); if (!token || !phoneId) return { content: "WhatsApp Business API not configured (set META_ACCESS_TOKEN + WHATSAPP_PHONE_NUMBER_ID after Meta app review).", success: false }; const to = String(input.to || "").replace(/[^\d+]/g, ""); @@ -93,7 +97,7 @@ export const META_TOOLS: ToolDef[] = [ required: ["recipient_id", "text"], }, handler: async (ctx, input) => { - const token = metaToken(ctx); + const token = await metaToken(ctx); const igId = ctx.env.META_IG_ID?.trim(); if (!token || !igId) return { content: "Instagram messaging not configured (set META_ACCESS_TOKEN + META_IG_ID after Meta app review).", success: false }; const recipient = String(input.recipient_id || "").trim(); diff --git a/workers/api/src/lib/connectors/registry.test.ts b/workers/api/src/lib/connectors/registry.test.ts new file mode 100644 index 00000000..8e61bffd --- /dev/null +++ b/workers/api/src/lib/connectors/registry.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { CONNECTORS, connectorTools, getConnector } from "./registry.js"; + +describe("connector registry", () => { + it("declares github, meta, and tmux", () => { + const ids = CONNECTORS.map((c) => c.id).sort(); + expect(ids).toEqual(["github", "meta", "tmux"]); + }); + + it("github is an app-auth, read+write, user-grant connector", () => { + const gh = getConnector("github"); + expect(gh?.auth).toBe("app"); + expect(gh?.scopes).toEqual({ read: true, write: true }); + expect(gh?.grantModel).toBe("user"); + }); + + it("meta is a token-auth connector backed by META_ACCESS_TOKEN (write-only)", () => { + const meta = getConnector("meta"); + expect(meta?.auth).toBe("token"); + expect(meta?.tokenEnv).toBe("META_ACCESS_TOKEN"); + expect(meta?.scopes).toEqual({ read: false, write: true }); + }); + + it("tmux is a no-auth local connector", () => { + expect(getConnector("tmux")?.auth).toBe("none"); + }); + + it("unknown connector → undefined", () => { + expect(getConnector("nope")).toBeUndefined(); + }); + + it("connectorTools flattens every connector's tools and stamps connector/tier/scope", () => { + const tools = connectorTools(); + // Every connector's tools are present. + const byConnector = new Map(); + for (const t of tools) { + expect(t.tier).toBe("connector"); + expect(typeof t.connector).toBe("string"); + expect(t.scope === "read" || t.scope === "write").toBe(true); + byConnector.set(t.connector as string, (byConnector.get(t.connector as string) ?? 0) + 1); + } + expect(byConnector.get("github")).toBeGreaterThan(0); + expect(byConnector.get("meta")).toBeGreaterThan(0); + expect(byConnector.get("tmux")).toBeGreaterThan(0); + }); + + it("the flattened tool set matches the sum of each connector's tools", () => { + const declared = CONNECTORS.reduce((n, c) => n + c.tools.length, 0); + expect(connectorTools().length).toBe(declared); + }); +}); diff --git a/workers/api/src/lib/connectors/registry.ts b/workers/api/src/lib/connectors/registry.ts new file mode 100644 index 00000000..deb8da97 --- /dev/null +++ b/workers/api/src/lib/connectors/registry.ts @@ -0,0 +1,93 @@ +// Connector registry (issue #86). A connector is DECLARED once — its auth model, +// scopes, grant model, and the tools it provides — and everything else (the tool +// REGISTRY, the connectorClient auth dispatch, the catalog groups) derives from it. +// Adding a connector = add an entry here; no bespoke routes. +import type { Env } from "../../types.js"; +import type { ToolDef } from "../tool-registry.js"; +import { GITHUB_TOOLS } from "./github.js"; +import { META_TOOLS } from "./meta.js"; +import { TMUX_TOOLS } from "./tmux.js"; + +export interface Connector { + /** Stable id, also the `connector` stamped on its tools and the grants/consent key. */ + id: string; + /** Human label for errors/UI. */ + label: string; + /** + * How connectorClient obtains a token: + * app — GitHub-App installation token (installationTokenForOwner) + * oauth — refresh-token → access-token mint (Drive-style) + * token — a stored/opaque token (platform env `tokenEnv`, else user_api_keys) + * none — no cloud auth (e.g. tmux, reached over the runner relay) + */ + auth: "oauth" | "token" | "app" | "none"; + /** What the connector can do. A read-only connector rejects write-scoped token requests. */ + scopes: { read: boolean; write: boolean }; + /** + * user — auth is the user's (installation/oauth/env); no per-resource grant. + * instance-resource — each tool call must target a resource granted to the instance. + */ + grantModel: "user" | "instance-resource"; + /** For auth:"token" connectors backed by a platform env var (e.g. Meta). */ + tokenEnv?: EnvTokenKey; + /** The tools this connector provides. Their `connector`/`tier`/`scope` are stamped from here. */ + tools: ToolDef[]; +} + +/** Env keys usable as a platform token source (all `string | undefined`). */ +type EnvTokenKey = "META_ACCESS_TOKEN"; + +// Assert a key of Env exists (compile-time guard for tokenEnv values). +type _AssertEnvKey = EnvTokenKey extends keyof Env ? true : never; +const _assertEnvKey: _AssertEnvKey = true; +void _assertEnvKey; + +export const CONNECTORS: Connector[] = [ + { + id: "github", + label: "GitHub", + auth: "app", + scopes: { read: true, write: true }, + grantModel: "user", // access scoped by the owner's GitHub-App installation, not a per-resource grant row + tools: GITHUB_TOOLS, + }, + { + id: "meta", + label: "Meta (WhatsApp + Instagram)", + auth: "token", + scopes: { read: false, write: true }, + grantModel: "user", + tokenEnv: "META_ACCESS_TOKEN", + tools: META_TOOLS, + }, + { + id: "tmux", + label: "tmux (local runner)", + auth: "none", // reached over the runner relay; no cloud credential + scopes: { read: true, write: true }, + grantModel: "user", + tools: TMUX_TOOLS, + }, +]; + +const BY_ID: ReadonlyMap = new Map(CONNECTORS.map((c) => [c.id, c] as const)); + +export function getConnector(id: string): Connector | undefined { + return BY_ID.get(id); +} + +/** + * Every connector's tools, with `connector`, `tier:"connector"`, and a default `scope` + * stamped from the connector definition (so a tool declared without them still lands + * correctly). Flattened for the tool REGISTRY. + */ +export function connectorTools(): ToolDef[] { + return CONNECTORS.flatMap((c) => + c.tools.map((t) => ({ + ...t, + connector: t.connector ?? c.id, + tier: t.tier ?? "connector", + scope: t.scope ?? "read", + })), + ); +} diff --git a/workers/api/src/lib/tool-registry.ts b/workers/api/src/lib/tool-registry.ts index 70aa7b69..438ccbcd 100644 --- a/workers/api/src/lib/tool-registry.ts +++ b/workers/api/src/lib/tool-registry.ts @@ -4,9 +4,8 @@ // instead of the current triple-definition. Additive: the legacy AGENT_TOOLS / // STORAGE_TOOLS catalog is untouched; registry tools are dispatched alongside them. import type { Env } from "../types.js"; -import { GITHUB_TOOLS } from "./connectors/github.js"; -import { TMUX_TOOLS } from "./connectors/tmux.js"; -import { META_TOOLS } from "./connectors/meta.js"; +import { connectorTools, getConnector } from "./connectors/registry.js"; +import { connectorClient, type ConnectorClient } from "./connectors/client.js"; import { hasConsent } from "./connector-consent.js"; export interface RegistryToolCtx { @@ -14,6 +13,13 @@ export interface RegistryToolCtx { userId?: string; agentId?: string; instanceId?: string; + /** + * The connector client factory (issue #86) — handlers call + * `ctx.connectorClient(provider)` to mint the provider's token and enforce + * grant/scope, instead of importing token-minting fns directly. Injected by + * runRegistryTool; optional so tests can construct a ctx without it. + */ + connectorClient?: (provider: string) => ConnectorClient; } export interface RegistryToolResult { @@ -56,9 +62,18 @@ export interface ToolDef { */ export type RegistryTool = ToolDef; -// All connectors' tools, keyed by name. Add a connector = add its tools array here. +/** + * First-party registry tools that are NOT provided by a connector (base/standard/runtime + * tiers). Empty for now — kept so the REGISTRY can carry non-connector tools without + * changing its shape. + */ +const FIRST_PARTY_TOOLS: ToolDef[] = []; + +// The tool REGISTRY, keyed by name: every connector's tools (flattened from the connector +// registry, with connector/tier/scope stamped) plus first-party tools. Add a connector = +// add it to CONNECTORS in connectors/registry.ts. const REGISTRY: ReadonlyMap = new Map( - [...GITHUB_TOOLS, ...TMUX_TOOLS, ...META_TOOLS].map((t) => [t.name, t] as const), + [...connectorTools(), ...FIRST_PARTY_TOOLS].map((t) => [t.name, t] as const), ); export function getRegistryTool(name: string): ToolDef | undefined { @@ -103,6 +118,14 @@ export async function runRegistryTool( ): Promise<{ name: string; content: string; success: boolean }> { const tool = REGISTRY.get(name); if (!tool) return { name, content: `Unknown tool: ${name}`, success: false }; + // Scope enforcement (issue #86): a write-scoped tool on a read-only connector is + // unreachable — reject before consent/handler so the abstraction can't be bypassed. + if (tool.scope === "write" && tool.connector) { + const conn = getConnector(tool.connector); + if (conn && !conn.scopes.write) { + return { name, content: `The ${conn.id} connector is read-only — "${name}" cannot run.`, success: false }; + } + } // Write-consent gate (issue #90): a write tool needs explicit per-instance consent // for its connector. Fail-closed — no connector, no instance context, or no consent // → refused. (A write-scoped tool without a connector can't be consented to, so it's @@ -118,7 +141,13 @@ export async function runRegistryTool( } } try { - const r = await tool.handler(ctx, input || {}); + // Inject the connector-client factory so handlers mint tokens + enforce grant/scope + // through the ONE path (issue #86) instead of importing token fns directly. + const handlerCtx: RegistryToolCtx = { + ...ctx, + connectorClient: ctx.connectorClient ?? ((provider: string) => connectorClient(ctx.env, provider, { userId: ctx.userId, instanceId: ctx.instanceId })), + }; + const r = await tool.handler(handlerCtx, input || {}); return { name, content: r.content, success: r.success }; } catch (err) { return { name, content: `Error: ${err instanceof Error ? err.message : String(err)}`, success: false };