From d1f924e0505d7067dd2d38efff106a4de7c79d96 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 24 Sep 2026 09:25:28 +0100 Subject: [PATCH] feat: target the call's repository installation by default for Connect tokens --- .changeset/connect-per-call-token.md | 3 +- .changeset/connect-per-repository-default.md | 10 +++ .../docs/2.frameworks/1.eve-extension.md | 25 ++----- .../content/docs/4.guide/5.vercel-connect.md | 13 +--- apps/docs/content/docs/4.guide/7.errors.md | 2 +- apps/docs/content/docs/5.api/2.reference.md | 15 +--- .../references/eve-extension.md | 2 +- packages/github-tools-eve-extension/README.md | 15 +--- .../extension/extension.ts | 13 ++-- packages/github-tools/README.md | 8 +-- packages/github-tools/src/connect/index.ts | 2 - .../src/connect/per-repository.ts | 38 ---------- .../github-tools/src/connect/token.test.ts | 72 +++++++++++++++++-- packages/github-tools/src/connect/token.ts | 41 +++++++---- packages/github-tools/src/connect/types.ts | 9 +-- packages/github-tools/src/token-call.test.ts | 18 ----- 16 files changed, 128 insertions(+), 158 deletions(-) create mode 100644 .changeset/connect-per-repository-default.md delete mode 100644 packages/github-tools/src/connect/per-repository.ts diff --git a/.changeset/connect-per-call-token.md b/.changeset/connect-per-call-token.md index f0bb991..1bd2bdf 100644 --- a/.changeset/connect-per-call-token.md +++ b/.changeset/connect-per-call-token.md @@ -3,10 +3,9 @@ "@github-tools/eve-extension": minor --- -Mint Vercel Connect tokens per tool call, for the call's target repository. This covers GitHub Apps installed on several accounts. +Mint Vercel Connect tokens per tool call. - `connect` on `githubExtension` accepts a `(ctx, call) => params` resolver, where `call` is `{ toolName, input, owner?, repo? }`. `owner` / `repo` are the tool's inputs after `context` defaults, and are undefined for tools without a repository target (search, gists, notifications). The static shape and the `connect.subject` resolver keep working unchanged. -- New `perRepository(params?)` in `@github-tools/sdk/connect`: `connect: perRepository()` mints each token with `authorizationDetails: [{ type: 'github_app_installation', org: owner, repositories: [repo] }]`, and falls back to the static `params` for calls without a repository target. It works with `githubExtension`, `connectGithubTools`, and `connectGithubToken`. Using it from an eve agent requires `@github-tools/sdk` as a direct dependency. - `connectGithubTools` / `connectGithubToken` accept a `(call) => params` resolver as `connect` / `params`. Scopes still derive from `preset` / `include` / `exclude` unless the resolved params set `scopes`. Connect caches tokens per connector and params, so calls on the same repository reuse one token. - Token providers (`GithubTokenInput`) now receive an optional `GithubTokenCall` argument on every tool call, from both `createGithubTools` and the eve runtime. Existing `() => Promise` providers are unaffected. - `CONNECT_INSTALLATION_REQUIRED` names the target account ("The connector's GitHub App is not installed on ") when the token targets an org or repository owner. diff --git a/.changeset/connect-per-repository-default.md b/.changeset/connect-per-repository-default.md new file mode 100644 index 0000000..a9a6608 --- /dev/null +++ b/.changeset/connect-per-repository-default.md @@ -0,0 +1,10 @@ +--- +"@github-tools/sdk": minor +"@github-tools/eve-extension": minor +--- + +Target the GitHub App installation that owns each tool call's repository by default. A GitHub App installed on several accounts now works with `githubExtension({ connector })`, `connectGithubTools` and `connectGithubToken` as they are, with nothing to configure. + +- App-subject tokens for a call with `owner` / `repo` (after `context` defaults) get `authorizationDetails: [{ type: 'github_app_installation', org: owner, repositories: [repo] }]`. Static `connect` params merge in. Calls without a repository target and calls outside a tool use the connector's default installation, as before. +- An explicit `installationId`, `authorizationDetails` or `repositories` in the resolved params pins the installation and is never overridden. User subjects (`{ type: 'user' }`) are not targeted: a user token already spans installations. +- Scopes still derive from `preset` / `include` / `exclude`. Connect caches tokens per connector and params, so calls on one repository reuse the token; single-installation connectors resolve to the same installation they used before. diff --git a/apps/docs/content/docs/2.frameworks/1.eve-extension.md b/apps/docs/content/docs/2.frameworks/1.eve-extension.md index 84d8f94..29af33a 100644 --- a/apps/docs/content/docs/2.frameworks/1.eve-extension.md +++ b/apps/docs/content/docs/2.frameworks/1.eve-extension.md @@ -203,7 +203,7 @@ export default githubExtension({ |---|---|---| | `token` | `string \| (() => Promise)` | PAT string, or an async provider for rotating tokens (e.g. a GitHub App installation token) — the same `GithubTokenInput` the SDK accepts; falls back to `GITHUB_TOKEN` when omitted and `connector` is not set | | `connector` | `string \| (() => string \| Promise)` | Vercel Connect connector name, or a resolver to pick one dynamically (e.g. per environment/tenant); takes priority over `token` | -| `connect` | `record \| ((ctx, call) => record)` | Passed through to `getToken` when `connector` is set; `connect.subject` defaults to `{ type: 'app' }` and also accepts a per-caller resolver, see [Per-user tokens](#per-user-tokens). A resolver picks params per tool call, see [Multiple GitHub App installations](#multiple-github-app-installations) | +| `connect` | `record \| ((ctx, call) => record)` | Passed through to `getToken` when `connector` is set; `connect.subject` defaults to `{ type: 'app' }` and also accepts a per-caller resolver, see [Per-user tokens](#per-user-tokens). App tokens target the installation owning each call's repository by default, see [Multiple GitHub App installations](#multiple-github-app-installations); a resolver picks other params per tool call | | `preset` | preset name, array, or `'auto'` | `code-review`, `issue-triage`, `ci-ops`, `repo-explorer`, `security-audit`, `release-manager`, `discussion-moderator`, `notification-inbox`, `pr-author`, `maintainer`, see [Presets](/guide/presets). `'auto'` routes each user message, see [Automatic presets and approval](#automatic-presets-and-approval) | | `include` | `string[]?` | Tool names to add on top of `preset` (union), or the full set standalone, see [Pick exact tools](#pick-exact-tools) | | `exclude` | `string[]?` | Tool names to remove from the resolved `preset` + `include` set | @@ -287,38 +287,21 @@ export default githubExtension({ ### Multiple GitHub App installations -Connect picks the GitHub App installation from the token's `authorizationDetails`. It checks an explicit `installationId` first, then the `org` (or the owner of a qualified repository), then the connector default. With static `connect` params, every tool call gets a token for one installation. When the App is installed on several accounts, a call to a repository on another account then fails with a 403. Pass `perRepository()` as `connect` to mint each token for the installation that owns the call's target repository: +Nothing to configure. Each app token is minted for the GitHub App installation that owns the call's target repository, so one App installed on several orgs or users just works: ```ts [agent/extensions/github.ts] import githubExtension from '@github-tools/eve-extension' -import { perRepository } from '@github-tools/sdk/connect' export default githubExtension({ connector: 'github/my-connector', preset: 'pr-author', context: { owner: 'evloghq', repo: 'evlog' }, - connect: perRepository(), }) ``` -`perRepository` is exported by the SDK, so add `@github-tools/sdk` to the agent's dependencies: +The target is the tool's `owner` / `repo` input after `context` defaults are applied, so a call that omits them uses the configured home repository. Tools without a repository target (`searchCode`, gist and notification tools) get the connector's default installation. Static `connect` params (`validityBufferMs`, `scopes`, ...) merge in. Connect caches tokens per connector and params, so repeated calls on the same repository reuse one token. When the App is not installed on the target account, the tool returns `CONNECT_INSTALLATION_REQUIRED` naming that account instead of a GitHub 403. -:::code-group -```bash [pnpm] -pnpm add @github-tools/sdk -``` -```bash [npm] -npm install @github-tools/sdk -``` -```bash [yarn] -yarn add @github-tools/sdk -``` -```bash [bun] -bun add @github-tools/sdk -``` -::: - -The target is the tool's `owner` / `repo` input after `context` defaults are applied, so a call that omits them uses the configured home repository. Tools without a repository target (`searchCode`, gist and notification tools) get the static params instead. Pass extra static params to merge them, e.g. `perRepository({ validityBufferMs: 60_000 })`. Scopes still derive from `preset` / `include` / `exclude` unless you set `scopes`. Connect caches tokens per connector and params, so repeated calls on the same repository reuse one token. When the App is not installed on the target account, the tool returns `CONNECT_INSTALLATION_REQUIRED` naming that account instead of a GitHub 403. +To pin one installation instead, set `installationId`, `authorizationDetails` or `repositories` in `connect`; an explicit choice is never overridden. User-subject tokens already span installations and are left untouched. For other per-call rules, pass your own resolver. It receives the eve tool execution context and the call (`{ toolName, input, owner?, repo? }`), and may return a `subject` resolver too: diff --git a/apps/docs/content/docs/4.guide/5.vercel-connect.md b/apps/docs/content/docs/4.guide/5.vercel-connect.md index 17aa739..a1302b1 100644 --- a/apps/docs/content/docs/4.guide/5.vercel-connect.md +++ b/apps/docs/content/docs/4.guide/5.vercel-connect.md @@ -140,18 +140,7 @@ const tools = connectGithubTools('github/my-connector', { ### One App installed on several accounts -Static `connect` params pin every token to one installation. When the connector's GitHub App is installed on several orgs or users, pass `perRepository()` to mint each token for the installation that owns the tool call's target repository: - -```ts [connect-per-repository.ts] -import { connectGithubTools, perRepository } from '@github-tools/sdk/connect' - -const tools = connectGithubTools('github/my-connector', { - preset: 'pr-author', - connect: perRepository(), -}) -``` - -Calls that target `owner/repo` (after `context` defaults) get `authorizationDetails: [{ type: 'github_app_installation', org: owner, repositories: [repo] }]`. Calls without a repository target get the static params passed to `perRepository({ ... })`. `connect` also accepts any `(call) => params` resolver. Connect caches tokens per connector and params, so repeated calls on one repository reuse the token. For the eve extension, see [Multiple GitHub App installations](/frameworks/eve-extension#multiple-github-app-installations). +Nothing to configure: app tokens are minted for the installation that owns the tool call's target repository. Calls that target `owner/repo` (after `context` defaults) get `authorizationDetails: [{ type: 'github_app_installation', org: owner, repositories: [repo] }]`; calls without a repository target use the connector's default installation. Static `connect` params merge in, and an explicit `installationId`, `authorizationDetails` or `repositories` pins one installation instead. User subjects are left untouched. `connect` also accepts any `(call) => params` resolver. Connect caches tokens per connector and params, so repeated calls on one repository reuse the token. For the eve extension, see [Multiple GitHub App installations](/frameworks/eve-extension#multiple-github-app-installations). ## Per-user tokens diff --git a/apps/docs/content/docs/4.guide/7.errors.md b/apps/docs/content/docs/4.guide/7.errors.md index 82745e1..7e5d3fe 100644 --- a/apps/docs/content/docs/4.guide/7.errors.md +++ b/apps/docs/content/docs/4.guide/7.errors.md @@ -42,7 +42,7 @@ The SDK maps every failure it can classify to a structured error from an [evlog] | `OIDC_TOKEN_EXPIRED` | `VERCEL_OIDC_TOKEN` is past its `exp` claim — thrown before any request is made, with the exact expiry time. Run `vercel env pull` locally | | `CONNECT_NOT_AUTHORIZED` | Connect rejected the calling process's identity (403). The request never reached GitHub — this is not a GitHub permission problem | | `CONNECT_USER_NOT_CONNECTED` | A `{ type: 'user' }` subject was requested but that user has no active GitHub connection | -| `CONNECT_INSTALLATION_REQUIRED` | The connector's GitHub App is not installed on the target account — named in the message when the token targets an `org` or repository owner (e.g. with `perRepository()`) | +| `CONNECT_INSTALLATION_REQUIRED` | The connector's GitHub App is not installed on the target account — named in the message when the token targets an `org` or repository owner, which every tool call with a repository target does | | `SUBJECT_CONTEXT_REQUIRED` | A `connect` or `connect.subject` resolver ran outside a tool execution (no eve context) | ### GitHub API diff --git a/apps/docs/content/docs/5.api/2.reference.md b/apps/docs/content/docs/5.api/2.reference.md index b2eefd1..97db4bc 100644 --- a/apps/docs/content/docs/5.api/2.reference.md +++ b/apps/docs/content/docs/5.api/2.reference.md @@ -398,20 +398,7 @@ type GithubConnectorInput = string | (() => string | Promise) `subject` defaults to `{ type: 'app' }` (the project's GitHub App installation). Pass `{ type: 'user', id }` to mint a token for that user's own connection — see [per-user tokens](/guide/vercel-connect#per-user-tokens). See the [Vercel Connect guide](/guide/vercel-connect#dynamic-connector-selection) for the dynamic connector example. -A `connect` resolver runs on every tool call with its `GithubTokenCall`, so the token can target the call's repository. Scopes still derive from `preset` unless the resolved params set `scopes`. - -## `perRepository(params?)` - -Import from `@github-tools/sdk/connect`. Returns a `connect` resolver that mints each token for the GitHub App installation owning the call's target repository: `{ ...params, authorizationDetails: [{ type: 'github_app_installation', org: owner, repositories: [repo] }] }` when the call has `owner` / `repo`, and `params` unchanged otherwise. Works as `connect` for `connectGithubTools`, `connectGithubToken` (`params`), and [`githubExtension`](/frameworks/eve-extension#multiple-github-app-installations): - -```ts [connect-per-repository.ts] -import { connectGithubTools, perRepository } from '@github-tools/sdk/connect' - -const tools = connectGithubTools('github/my-connector', { - preset: 'pr-author', - connect: perRepository({ validityBufferMs: 60_000 }), -}) -``` +App-subject tokens target the GitHub App installation that owns the call's repository: when the call has `owner` / `repo` and the params set none of `installationId`, `authorizationDetails` or `repositories`, `authorizationDetails: [{ type: 'github_app_installation', org: owner, repositories: [repo] }]` is added. See [One App installed on several accounts](/guide/vercel-connect#one-app-installed-on-several-accounts). A `connect` resolver runs on every tool call with its `GithubTokenCall` for other per-call rules. Scopes still derive from `preset` unless the resolved params set `scopes`. ## `connectGithubTools(connector, options?)`: eve (deprecated) diff --git a/apps/docs/skills/github-tools-agents/references/eve-extension.md b/apps/docs/skills/github-tools-agents/references/eve-extension.md index f2a1b11..3b1aa78 100644 --- a/apps/docs/skills/github-tools-agents/references/eve-extension.md +++ b/apps/docs/skills/github-tools-agents/references/eve-extension.md @@ -88,7 +88,7 @@ export default githubExtension({ }) ``` -When one GitHub App is installed on several accounts, static `connect` params pin every token to one installation, so calls to repos on other accounts 403. Pass `connect: perRepository()` (import from `@github-tools/sdk/connect`; the agent must depend on `@github-tools/sdk`). Each token then targets the installation owning the call's `owner/repo`, after `context` defaults. Tools without a repository target (search, gists, notifications) use the static params passed to `perRepository({ ... })`. `connect` also accepts a custom `(ctx, call) => params` resolver, where `call` is `{ toolName, input, owner?, repo? }`. A missing installation surfaces as `CONNECT_INSTALLATION_REQUIRED` naming the account. +One GitHub App installed on several accounts needs no configuration: each app token targets the installation owning the call's `owner/repo`, after `context` defaults. Tools without a repository target (search, gists, notifications) use the connector's default installation. Set `installationId`, `authorizationDetails` or `repositories` in `connect` to pin one installation; user subjects are left untouched. `connect` also accepts a custom `(ctx, call) => params` resolver, where `call` is `{ toolName, input, owner?, repo? }`. A missing installation surfaces as `CONNECT_INSTALLATION_REQUIRED` naming the account. ## Docs diff --git a/packages/github-tools-eve-extension/README.md b/packages/github-tools-eve-extension/README.md index abe1d9a..26ef109 100644 --- a/packages/github-tools-eve-extension/README.md +++ b/packages/github-tools-eve-extension/README.md @@ -54,18 +54,7 @@ export default githubExtension({ }) ``` -When the connector's GitHub App is installed on several accounts, pass `perRepository()` (from `@github-tools/sdk/connect`, so add `@github-tools/sdk` to the agent) as `connect`. Each token is then minted for the installation that owns the call's target `owner/repo`, after `context` defaults. Tools without a repository target use the static params you pass to `perRepository({ ... })`. If the App is not installed on the target account, the tool returns `CONNECT_INSTALLATION_REQUIRED` naming that account: - -```ts -import { perRepository } from '@github-tools/sdk/connect' - -export default githubExtension({ - connector: 'github/my-connector', - preset: 'pr-author', - context: { owner: 'evloghq', repo: 'evlog' }, - connect: perRepository(), -}) -``` +A GitHub App installed on several accounts needs no configuration: each token is minted for the installation that owns the call's target `owner/repo`, after `context` defaults. Tools without a repository target use the connector's default installation. Set `installationId`, `authorizationDetails` or `repositories` in `connect` to pin one installation. If the App is not installed on the target account, the tool returns `CONNECT_INSTALLATION_REQUIRED` naming that account. Tools are exposed to the model as `__`, where `` comes from the mount file's name: `agent/extensions/github.ts` yields `github__listPullRequests`, `github__createIssue`, and so on. @@ -103,7 +92,7 @@ extension/ |---|---|---| | `token` | `string \| (() => Promise)` (optional) | PAT string, or an async provider for rotating tokens (e.g. a GitHub App installation token) — the same `GithubTokenInput` the SDK accepts; falls back to `GITHUB_TOKEN` when omitted and `connector` is not set | | `connector` | `string \| (() => string \| Promise)` (optional) | Vercel Connect connector name, or a resolver to pick one dynamically (e.g. per environment/tenant); takes priority over `token` | -| `connect` | `record \| ((ctx, call) => record)` (optional) | Passed through to `getToken` when `connector` is set. `connect.subject` defaults to `{ type: 'app' }` (the project's GitHub App installation); pass `{ type: 'user', id }` or a per-caller resolver `(ctx) => subject` to mint each caller's own connection token in multi-user apps. Pass a resolver (e.g. `perRepository()`) to pick params per tool call | +| `connect` | `record \| ((ctx, call) => record)` (optional) | Passed through to `getToken` when `connector` is set. `connect.subject` defaults to `{ type: 'app' }` (the project's GitHub App installation); pass `{ type: 'user', id }` or a per-caller resolver `(ctx) => subject` to mint each caller's own connection token in multi-user apps. App tokens target the installation owning each call's repository unless `installationId`, `authorizationDetails` or `repositories` pins one; pass a `(ctx, call) => record` resolver for other per-call rules | | `preset` | preset name, array, or `'auto'` | `code-review`, `issue-triage`, `ci-ops`, `repo-explorer`, `security-audit`, `release-manager`, `discussion-moderator`, `notification-inbox`, `pr-author`, `maintainer`; `'auto'` picks at most two presets per user message | | `include` | `string[]?` | Tool names to add on top of `preset` (union), or the full set standalone | | `exclude` | `string[]?` | Tool names to remove from the resolved `preset` + `include` set | diff --git a/packages/github-tools-eve-extension/extension/extension.ts b/packages/github-tools-eve-extension/extension/extension.ts index 5a42268..bbd4349 100644 --- a/packages/github-tools-eve-extension/extension/extension.ts +++ b/packages/github-tools-eve-extension/extension/extension.ts @@ -54,8 +54,8 @@ export type GithubExtensionConnectParams = Omit * Resolves Connect token params for each tool call. Receives the eve tool * execution context and the call — `owner` / `repo` are the tool's resolved * inputs after `context` defaults, undefined for tools without a repository - * target. Use `perRepository()` from `@github-tools/sdk/connect` for the - * common case of selecting the GitHub App installation per target repository. + * target. Selecting the installation per target repository is the default and + * needs no resolver. */ export type GithubExtensionConnectResolver = ( ctx: ToolContext, @@ -101,10 +101,11 @@ export interface GithubExtensionConfig { * installation, shared by every caller); pass a value or a per-caller * resolver to mint per-user tokens instead. * - * Pass a resolver to pick params per tool call, e.g. `perRepository()` from - * `@github-tools/sdk/connect` when the GitHub App is installed on several - * accounts. Scopes still derive from `preset` / `include` / `exclude` unless - * the resolved params set `scopes`. + * App tokens target the GitHub App installation owning each call's + * repository, so an App installed on several accounts needs nothing here; + * `installationId`, `authorizationDetails` or `repositories` pins one. Pass + * a resolver for other per-call rules. Scopes still derive from `preset` / + * `include` / `exclude` unless the resolved params set `scopes`. */ connect?: GithubExtensionConnectParams | GithubExtensionConnectResolver /** diff --git a/packages/github-tools/README.md b/packages/github-tools/README.md index 2c78744..63ddc10 100644 --- a/packages/github-tools/README.md +++ b/packages/github-tools/README.md @@ -341,13 +341,7 @@ connectGithubTools('github/my-connector', { }) ``` -When the GitHub App is installed on several accounts, `connect: perRepository()` mints each token for the installation that owns the tool call's target `owner/repo`. `connect` also accepts any `(call) => params` resolver: - -```ts -import { connectGithubTools, perRepository } from '@github-tools/sdk/connect' - -connectGithubTools('github/my-connector', { preset: 'pr-author', connect: perRepository() }) -``` +A GitHub App installed on several accounts needs no configuration: each app token is minted for the installation that owns the tool call's target `owner/repo`. Set `installationId`, `authorizationDetails` or `repositories` in `connect` to pin one installation. `connect` also accepts any `(call) => params` resolver for other per-call rules. > `@vercel/connect` is an optional peer dependency, install it only when using the `/connect` subpath. diff --git a/packages/github-tools/src/connect/index.ts b/packages/github-tools/src/connect/index.ts index a4d7133..b11a3fb 100644 --- a/packages/github-tools/src/connect/index.ts +++ b/packages/github-tools/src/connect/index.ts @@ -10,8 +10,6 @@ export { export type { ConnectScopeSelection } from './scopes' export { connectGithubToken } from './token' export { connectGithubTools } from './tools' -export { perRepository } from './per-repository' -export type { PerRepositoryConnectResolver } from './per-repository' export type { ConnectGithubEveToolsOptions, ConnectGithubTokenOptions, diff --git a/packages/github-tools/src/connect/per-repository.ts b/packages/github-tools/src/connect/per-repository.ts deleted file mode 100644 index 3cff905..0000000 --- a/packages/github-tools/src/connect/per-repository.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { GithubTokenCall } from '../core/token' -import type { GithubConnectParams } from './types' - -/** - * Connect params resolver returned by {@link perRepository}. Callable as an - * SDK `connect` resolver (`(call)`) and as an `@github-tools/eve-extension` - * `connect` resolver (`(ctx, call)`). - */ -export type PerRepositoryConnectResolver = { - (call?: GithubTokenCall): GithubConnectParams - (ctx: unknown, call: GithubTokenCall): GithubConnectParams -} - -/** - * Mint each Connect token for the GitHub App installation that owns the tool - * call's target repository. When the call targets `owner/repo`, returns - * `params` with `authorizationDetails: [{ type: 'github_app_installation', org: owner, repositories: [repo] }]`; - * calls without a repository target (search, gists, notifications) get `params` unchanged. - * - * Use it when one GitHub App is installed on several accounts. `params` merges - * extra static Connect params (`scopes`, `validityBufferMs`, `subject`, …). - * - * @example - * ```ts - * githubExtension({ connector: 'github/my-connector', connect: perRepository() }) - * connectGithubTools('github/my-connector', { connect: perRepository({ validityBufferMs: 60_000 }) }) - * ``` - */ -export function perRepository(params: GithubConnectParams = {}): PerRepositoryConnectResolver { - return (...args: [call?: GithubTokenCall] | [ctx: unknown, call: GithubTokenCall]) => { - const call = args.length === 2 ? args[1] : args[0] - if (!call?.owner || !call.repo) return params - return { - ...params, - authorizationDetails: [{ type: 'github_app_installation', org: call.owner, repositories: [call.repo] }], - } - } -} diff --git a/packages/github-tools/src/connect/token.test.ts b/packages/github-tools/src/connect/token.test.ts index 55cb268..1357a64 100644 --- a/packages/github-tools/src/connect/token.test.ts +++ b/packages/github-tools/src/connect/token.test.ts @@ -26,7 +26,6 @@ vi.mock('@vercel/connect', () => ({ })) import type { GithubTokenCall } from '../core/token' -import { perRepository } from './per-repository' import { connectGithubScopesForPreset } from './scopes' import { connectGithubToken } from './token' @@ -292,11 +291,8 @@ describe('connectGithubToken', () => { }, undefined) }) - it('selects the installation per target repository with perRepository', async () => { - const resolve = resolveConnectToken('github/my-connector', { - preset: 'pr-author', - params: perRepository({ validityBufferMs: 60_000 }), - }) + it('targets the installation owning the call repository by default', async () => { + const resolve = resolveConnectToken('github/my-connector', { preset: 'pr-author', params: { validityBufferMs: 60_000 } }) await resolve({ toolName: 'createPullRequest', input: {}, owner: 'hugorcd', repo: 'hr-folio' }) expect(getToken).toHaveBeenLastCalledWith('github/my-connector', { @@ -306,17 +302,79 @@ describe('connectGithubToken', () => { authorizationDetails: [{ type: 'github_app_installation', org: 'hugorcd', repositories: ['hr-folio'] }], }, undefined) + await resolve({ toolName: 'createPullRequest', input: {}, owner: 'evloghq', repo: 'evlog' }) + expect(getToken).toHaveBeenLastCalledWith('github/my-connector', expect.objectContaining({ + authorizationDetails: [{ type: 'github_app_installation', org: 'evloghq', repositories: ['evlog'] }], + }), undefined) + await resolve({ toolName: 'searchCode', input: { query: 'evlog' } }) expect(getToken).toHaveBeenLastCalledWith('github/my-connector', { subject: { type: 'app' }, validityBufferMs: 60_000, scopes: connectGithubScopesForPreset('pr-author'), }, undefined) + + await resolve() + expect(getToken).toHaveBeenLastCalledWith('github/my-connector', { + subject: { type: 'app' }, + validityBufferMs: 60_000, + scopes: connectGithubScopesForPreset('pr-author'), + }, undefined) + }) + + it('leaves user subjects untargeted', async () => { + const resolve = resolveConnectToken('github/my-connector', { + preset: 'pr-author', + params: { subject: { type: 'user', id: 'user_123' } }, + }) + + await resolve({ toolName: 'createPullRequest', input: {}, owner: 'hugorcd', repo: 'hr-folio' }) + expect(getToken).toHaveBeenLastCalledWith('github/my-connector', { + subject: { type: 'user', id: 'user_123' }, + scopes: connectGithubScopesForPreset('pr-author'), + }, undefined) + }) + + it('keeps an explicit installation choice over the call repository', async () => { + const call: GithubTokenCall = { toolName: 'createPullRequest', input: {}, owner: 'hugorcd', repo: 'hr-folio' } + + await resolveConnectToken('github/my-connector', { preset: 'pr-author', params: { installationId: 'inst_abc' } })(call) + expect(getToken).toHaveBeenLastCalledWith('github/my-connector', { + subject: { type: 'app' }, + installationId: 'inst_abc', + scopes: connectGithubScopesForPreset('pr-author'), + }, undefined) + + await resolveConnectToken('github/my-connector', { + preset: 'pr-author', + params: { authorizationDetails: [{ type: 'github_app_installation', org: 'evloghq' }] }, + })(call) + expect(getToken).toHaveBeenLastCalledWith('github/my-connector', expect.objectContaining({ + authorizationDetails: [{ type: 'github_app_installation', org: 'evloghq' }], + }), undefined) + + await resolveConnectToken('github/my-connector', { preset: 'pr-author', params: { repositories: ['evloghq/evlog'] } })(call) + expect(getToken).toHaveBeenLastCalledWith('github/my-connector', expect.objectContaining({ + authorizationDetails: [{ type: 'github_app_installation', repositories: ['evloghq/evlog'] }], + }), undefined) + }) + + it('targets the call repository after a params resolver too', async () => { + const resolve = resolveConnectToken('github/my-connector', { + preset: 'pr-author', + params: () => ({ validityBufferMs: 60_000 }), + }) + + await resolve({ toolName: 'createPullRequest', input: {}, owner: 'hugorcd', repo: 'hr-folio' }) + expect(getToken).toHaveBeenLastCalledWith('github/my-connector', expect.objectContaining({ + validityBufferMs: 60_000, + authorizationDetails: [{ type: 'github_app_installation', org: 'hugorcd', repositories: ['hr-folio'] }], + }), undefined) }) it('names the target owner when the App is not installed there', async () => { getToken.mockRejectedValueOnce(new ConnectorInstallationRequiredError('installation required')) - const resolve = resolveConnectToken('github/my-connector', { preset: 'pr-author', params: perRepository() }) + const resolve = resolveConnectToken('github/my-connector', { preset: 'pr-author' }) await expect(resolve({ toolName: 'createPullRequest', input: {}, owner: 'hugorcd', repo: 'hr-folio' })).rejects.toMatchObject({ code: 'github_tools.CONNECT_INSTALLATION_REQUIRED', diff --git a/packages/github-tools/src/connect/token.ts b/packages/github-tools/src/connect/token.ts index 1306a0d..c19664d 100644 --- a/packages/github-tools/src/connect/token.ts +++ b/packages/github-tools/src/connect/token.ts @@ -10,7 +10,7 @@ import { githubToolsErrors } from '../core/errors' import type { GithubTokenCall, GithubTokenInput } from '../core/token' import { resolveGithubConnector, type GithubConnectorInput } from './connector' import { resolveGithubConnectTokenParams } from './params' -import type { ConnectGithubTokenOptions } from './types' +import type { ConnectGithubTokenOptions, GithubConnectParams } from './types' /** * Returns a lazy GitHub token provider backed by a Vercel Connect connector. @@ -21,10 +21,13 @@ import type { ConnectGithubTokenOptions } from './types' * different connector per environment (production vs. preview) or tenant. * It's re-resolved on every call, alongside the token itself. * - * `params` may be a resolver called with the tool call on every token - * request — e.g. {@link perRepository} to select the GitHub App installation - * that owns the call's target repository. Connect caches tokens per - * `(connector, params)`, so repeated calls on the same repository reuse one token. + * App-subject tokens are minted for the GitHub App installation that owns the + * call's target repository, so one App installed on several accounts works + * with no configuration; an explicit `installationId`, `authorizationDetails` + * or `repositories` in `params` pins the installation instead. `params` may + * also be a resolver called with the tool call on every token request. + * Connect caches tokens per `(connector, params)`, so repeated calls on the + * same repository reuse one token. */ export function connectGithubToken( connector: GithubConnectorInput, @@ -49,19 +52,33 @@ export function connectGithubToken( } /** - * Static params resolve once; a params resolver runs on every call. Both go - * through the same scope derivation, so per-call params keep the - * preset/include/exclude scope narrowing unless they set `scopes` explicitly. + * Static and resolved params go through the same repository targeting and + * scope derivation, so per-call params keep the preset/include/exclude scope + * narrowing unless they set `scopes` explicitly. */ function createTokenParamsResolver( options: ConnectGithubTokenOptions, ): (call?: GithubTokenCall) => Promise { const { params } = options - if (typeof params === 'function') { - return async call => resolveGithubConnectTokenParams({ ...options, params: await params(call) }) + return async (call) => { + const resolved = typeof params === 'function' ? await params(call) : params + return resolveGithubConnectTokenParams({ ...options, params: withRepositoryTarget(resolved, call) }) + } +} + +/** + * Target the installation that owns the call's repository. Only app subjects + * are installation-scoped, and an explicit installation choice in `params` + * always wins. + */ +export function withRepositoryTarget(params: GithubConnectParams | undefined, call: GithubTokenCall | undefined): GithubConnectParams | undefined { + if (!call?.owner || !call.repo) return params + if (params?.subject !== undefined && params.subject.type !== 'app') return params + if (params?.installationId !== undefined || params?.authorizationDetails !== undefined || params?.repositories !== undefined) return params + return { + ...params, + authorizationDetails: [{ type: 'github_app_installation', org: call.owner, repositories: [call.repo] }], } - const tokenParams = resolveGithubConnectTokenParams({ ...options, params }) - return async () => tokenParams } /** Account Connect selects the installation for: the detail's `org`, else the owner of a qualified repository. */ diff --git a/packages/github-tools/src/connect/types.ts b/packages/github-tools/src/connect/types.ts index 5dc0322..d63135e 100644 --- a/packages/github-tools/src/connect/types.ts +++ b/packages/github-tools/src/connect/types.ts @@ -8,7 +8,9 @@ import type { EveGithubToolsOptions } from '../eve/types' /** * Token parameters for Vercel Connect GitHub connectors. * `subject` defaults to `{ type: 'app' }` — the project's GitHub App - * installation, same as `connectGitHubAdapter`. + * installation, same as `connectGitHubAdapter`. App-subject tokens target + * the installation that owns the tool call's repository unless + * `installationId`, `authorizationDetails` or `repositories` pins one. */ export type GithubConnectParams = Omit & { /** @@ -24,9 +26,8 @@ export type GithubConnectParams = Omit & { } /** - * Resolves Connect token params for each tool call, e.g. to select the GitHub - * App installation that owns the call's target repository (see - * {@link perRepository}). `call` is undefined when the token is resolved + * Resolves Connect token params for each tool call, e.g. to pick an + * `installationId` per tenant. `call` is undefined when the token is resolved * outside a tool call. Unless the returned params set `scopes`, scopes are * still derived from `preset` / `include` / `exclude`. */ diff --git a/packages/github-tools/src/token-call.test.ts b/packages/github-tools/src/token-call.test.ts index acbbfa8..6bd5b28 100644 --- a/packages/github-tools/src/token-call.test.ts +++ b/packages/github-tools/src/token-call.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { perRepository } from './connect/per-repository' import * as repositoryCore from './core/repository' import * as searchCore from './core/search' import { githubTokenCall } from './core/token' @@ -58,20 +57,3 @@ describe('token call threading', () => { expect(token).toHaveBeenCalledWith(expect.objectContaining({ toolName: 'getRepository', owner: 'evloghq', repo: 'evlog' })) }) }) - -describe('perRepository', () => { - const call = { toolName: 'createPullRequest', input: {}, owner: 'hugorcd', repo: 'hr-folio' } as const - - it('reads the call from the eve extension (ctx, call) form', () => { - expect(perRepository({ scopes: ['pull_requests:write'] })({ session: {} }, call)).toEqual({ - scopes: ['pull_requests:write'], - authorizationDetails: [{ type: 'github_app_installation', org: 'hugorcd', repositories: ['hr-folio'] }], - }) - }) - - it('returns the static params when the call has no repository target', () => { - const params = { installationId: 'inst_default' } - expect(perRepository(params)()).toBe(params) - expect(perRepository(params)({ toolName: 'listNotifications', input: {} })).toBe(params) - }) -})