Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/connect-per-call-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@github-tools/sdk": minor
"@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.

- `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<string>` providers are unaffected.
- `CONNECT_INSTALLATION_REQUIRED` names the target account ("The connector's GitHub App is not installed on <owner>") when the token targets an org or repository owner.
48 changes: 47 additions & 1 deletion apps/docs/content/docs/2.frameworks/1.eve-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export default githubExtension({
|---|---|---|
| `token` | `string \| (() => Promise<string>)` | 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<string>)` | Vercel Connect connector name, or a resolver to pick one dynamically (e.g. per environment/tenant); takes priority over `token` |
| `connect` | `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) |
| `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) |
| `preset` | preset name or array | `code-review`, `issue-triage`, `ci-ops`, `repo-explorer`, `security-audit`, `release-manager`, `discussion-moderator`, `notification-inbox`, `pr-author`, `maintainer`, see [Presets](/guide/presets) |
| `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 |
Expand Down Expand Up @@ -266,6 +266,52 @@ export default githubExtension({

`ctx.session.auth.current` is the authenticated caller of the active turn β€” eve restores it durably across workflow replay, so the resolver stays correct in multi-turn sessions. A caller without an active GitHub connection gets a `UserAuthorizationRequiredError` from Connect instead of silently falling back to the app installation. A static `subject` value (e.g. `{ type: 'user', id }` fixed at mount time) also works when the agent serves a single known user.

### 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:

```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:

:::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.

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:

```ts [agent/extensions/github.ts]
export default githubExtension({
connector: 'github/my-connector',
connect: (ctx, call) => ({
installationId: call.owner === 'hugorcd' ? 'inst_personal' : undefined,
}),
})
```

## Idempotency

eve replays completed steps but re-runs steps interrupted mid-execution:
Expand Down
15 changes: 15 additions & 0 deletions apps/docs/content/docs/4.guide/5.vercel-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ const tools = connectGithubTools('github/my-connector', {

`subject` defaults to `{ type: 'app' }`, same as `connectGitHubAdapter` from `@vercel/connect`.

### 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).

## Per-user tokens

The default `{ type: 'app' }` subject mints the project's GitHub App **installation** token β€” one identity shared by every caller. In multi-user apps where each user connects their own GitHub account (an integrations panel, a personal agent template), that default silently gives every signed-in user the project-level access. Pass a `{ type: 'user' }` subject to mint the caller's own connection token instead:
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/4.guide/7.errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ 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 |
| `SUBJECT_CONTEXT_REQUIRED` | A `connect.subject` resolver ran outside a tool execution (no eve context) |
| `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()`) |
| `SUBJECT_CONTEXT_REQUIRED` | A `connect` or `connect.subject` resolver ran outside a tool execution (no eve context) |

### GitHub API

Expand Down
29 changes: 27 additions & 2 deletions apps/docs/content/docs/5.api/2.reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ type GithubToolsOptions = {
coAuthors?: CommitIdentity[]
}

type GithubTokenInput = string | (() => Promise<string>)
// Providers receive the tool call the token is requested for (undefined outside a tool call).
type GithubTokenInput = string | ((call?: GithubTokenCall) => Promise<string>)

type GithubTokenCall = {
toolName: GithubToolName
input: unknown // after context defaults
owner?: string // undefined for tools without a repository target
repo?: string
}

type CommitIdentity = {
name: string
Expand Down Expand Up @@ -360,19 +368,36 @@ const tools = connectGithubTools('github/my-connector', {

```ts [types-connect.ts]
type ConnectGithubToolsOptions = GithubToolsOptions & {
connect?: GithubConnectParams
connect?: GithubConnectParams | GithubConnectParamsResolver
}

type GithubConnectParams = Omit<ConnectTokenParams, 'subject'> & {
subject?: ConnectTokenSubject
repositories?: string[]
}

type GithubConnectParamsResolver = (call?: GithubTokenCall) => GithubConnectParams | Promise<GithubConnectParams>

type GithubConnectorInput = string | (() => string | Promise<string>)
```

`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 }),
})
```

## `connectGithubTools(connector, options?)`: eve (deprecated)

::callout{icon="i-lucide-alert-triangle"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ 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.

## Docs

- `/frameworks/eve-extension`
Expand Down
15 changes: 14 additions & 1 deletion packages/github-tools-eve-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ 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(),
})
```

Tools are exposed to the model as `<namespace>__<toolName>`, where `<namespace>` comes from the mount file's name: `agent/extensions/github.ts` yields `github__listPullRequests`, `github__createIssue`, and so on.

To hand-pick an exact set of tools instead of a preset, pass `include`:
Expand Down Expand Up @@ -90,7 +103,7 @@ extension/
|---|---|---|
| `token` | `string \| (() => Promise<string>)` (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<string>)` (optional) | Vercel Connect connector name, or a resolver to pick one dynamically (e.g. per environment/tenant); takes priority over `token` |
| `connect` | `record?` | 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 |
| `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 |
| `preset` | preset name or array | `code-review`, `issue-triage`, `ci-ops`, `repo-explorer`, `security-audit`, `release-manager`, `discussion-moderator`, `notification-inbox`, `pr-author`, `maintainer` |
| `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 |
Expand Down
26 changes: 23 additions & 3 deletions packages/github-tools-eve-extension/extension/extension.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GithubTokenInput } from '@github-tools/sdk'
import type { GithubTokenCall, GithubTokenInput } from '@github-tools/sdk'
import type { ConnectTokenSubject, GithubConnectorInput, GithubConnectParams } from '@github-tools/sdk/connect'
import type { ToolContext } from 'eve/tools'
import {
Expand Down Expand Up @@ -49,6 +49,18 @@ export type GithubExtensionConnectParams = Omit<GithubConnectParams, 'subject'>
subject?: GithubConnectSubjectInput
}

/**
* 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.
*/
export type GithubExtensionConnectResolver = (
ctx: ToolContext,
call: GithubTokenCall,
) => GithubExtensionConnectParams | Promise<GithubExtensionConnectParams>

/**
* Config passed to `githubExtension({ ... })` at the agent mount site.
* Declared as an interface (not only a Zod schema) so IDE hovers show JSDoc.
Expand All @@ -72,8 +84,13 @@ export interface GithubExtensionConfig {
* is set. `subject` defaults to `{ type: 'app' }` (the project's GitHub App
* 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`.
*/
connect?: GithubExtensionConnectParams
connect?: GithubExtensionConnectParams | GithubExtensionConnectResolver
/** Restrict tools to a preset (or array of presets). Prefer a focused preset; omit or use `maintainer` for the full catalog. */
preset?: GithubToolPreset | GithubToolPreset[]
/**
Expand Down Expand Up @@ -130,7 +147,10 @@ const configSchema = z.object({
connector: z.custom<GithubConnectorInput>(
value => typeof value === 'string' || typeof value === 'function',
).optional(),
connect: z.record(z.string(), z.unknown()).optional(),
connect: z.union([
z.record(z.string(), z.unknown()),
z.custom<GithubExtensionConnectResolver>(value => typeof value === 'function'),
]).optional(),
preset: z.union([presetNameSchema, z.array(presetNameSchema)]).optional(),
include: z.array(toolNameSchema).optional(),
exclude: z.array(toolNameSchema).optional(),
Expand Down
39 changes: 24 additions & 15 deletions packages/github-tools-eve-extension/extension/tools/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {
listEveToolDescriptors,
mapEveApprovalValue,
resolveEveApproval,
resolveGithubToken,
type EveApprovalConfig,
type EveApprovalValue,
type EveGithubToolsOptions,
type EveToolOverrides,
type GithubTokenCall,
type GithubToolName,
type GithubWriteToolName,
} from '@github-tools/sdk/eve-runtime'
Expand Down Expand Up @@ -45,21 +45,23 @@ function buildSessionOptions(ctx?: ToolContext): EveGithubToolsOptions {
const includeNames = include as GithubToolName[] | undefined
const excludeNames = exclude as GithubToolName[] | undefined

// `connect.subject` may be a per-caller resolver; it needs the execution
// context, so the token is minted lazily, per tool call.
// `connect` and `connect.subject` may be resolvers over the execution
// context and tool call, so params resolve per tool call.
const resolvedToken = connector
? async () => {
const { subject, ...params } = connect ?? {}
const resolvedSubject = typeof subject === 'function'
? await subject(requireToolContext(ctx))
: subject
return resolveGithubToken(connectGithubToken(connector, {
preset,
include: includeNames,
exclude: excludeNames,
params: { ...params, ...(resolvedSubject && { subject: resolvedSubject }) },
}))
}
? connectGithubToken(connector, {
preset,
include: includeNames,
exclude: excludeNames,
params: async (call) => {
const { subject, ...params } = typeof connect === 'function'
? await connect(requireToolContext(ctx), requireToolCall(call))
: connect ?? {}
const resolvedSubject = typeof subject === 'function'
? await subject(requireToolContext(ctx))
: subject
return { ...params, ...(resolvedSubject && { subject: resolvedSubject }) }
},
})
: token

return {
Expand Down Expand Up @@ -102,6 +104,13 @@ function requireToolContext(ctx: ToolContext | undefined): ToolContext {
return ctx
}

function requireToolCall(call: GithubTokenCall | undefined): GithubTokenCall {
if (!call) {
throw githubToolsErrors.SUBJECT_CONTEXT_REQUIRED()
}
return call
}

async function runGithubEveTool(name: GithubToolName, input: unknown, ctx: ToolContext) {
return executeGithubEveTool(name, input as Record<string, unknown>, buildSessionOptions(ctx))
}
Expand Down
Loading
Loading