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
6 changes: 6 additions & 0 deletions .changeset/connect-per-user-subject.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@github-tools/sdk": minor
"@github-tools/eve-extension": minor
---

Support per-user Vercel Connect subjects. `GithubConnectParams` now accepts a `subject` (default stays `{ type: 'app' }`, the project's GitHub App installation), so multi-user apps can mint each caller's own connection token with `subject: { type: 'user', id }`. In the eve extension, `connect.subject` also accepts a per-caller resolver called with the tool execution context on every call — e.g. `(ctx) => ({ type: 'user', id: ctx.session.auth.current!.principalId })` — so each signed-in user reaches GitHub through their own connection instead of the shared app installation.
24 changes: 23 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` | `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) |
| `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 @@ -244,6 +244,28 @@ Unlike the deprecated direct import, no `build.externalDependencies` workaround

`@vercel/connect` is an optional peer dependency of the extension, install it only when using `connector`. `connector` also accepts a `() => string | Promise<string>` resolver for picking a connector per environment or tenant, see [dynamic connector selection](/guide/vercel-connect#dynamic-connector-selection). See [Vercel Connect](/guide/vercel-connect#eve-extension) for the connector setup checklist and multi-tenant scoping.

### Per-user tokens

By default Connect mints the project's GitHub App **installation** token (`subject: { type: 'app' }`) — one identity shared by every caller. That is right for single-tenant agents, but in multi-user apps where each user connects their own GitHub account, it silently gives every signed-in user the project-level access. Set `connect.subject` to a per-caller resolver to mint each caller's own connection token instead. The resolver receives the eve tool execution context on every tool call:

```ts [agent/extensions/github.ts]
import githubExtension from '@github-tools/eve-extension'

export default githubExtension({
connector: 'github/my-connector',
preset: 'issue-triage',
connect: {
subject: (ctx) => {
const caller = ctx.session.auth.current
if (!caller) throw new Error('GitHub tools require an authenticated caller')
return { type: 'user', id: caller.principalId }
},
},
})
```

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

## Idempotency

eve replays completed steps but re-runs steps interrupted mid-execution:
Expand Down
19 changes: 18 additions & 1 deletion apps/docs/content/docs/4.guide/5.vercel-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,24 @@ const tools = connectGithubTools('github/my-connector', {
})
```

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

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

```ts [connect-per-user.ts]
import { connectGithubTools } from '@github-tools/sdk/connect'

const tools = connectGithubTools('github/my-connector', {
preset: 'issue-triage',
connect: {
subject: { type: 'user', id: currentUserId },
},
})
```

A user without an active connection gets a `UserAuthorizationRequiredError` from Connect instead of falling back to the app installation. In the eve extension, `connect.subject` also accepts a per-caller resolver — see [the extension's Vercel Connect section](/frameworks/eve-extension#vercel-connect).

## Dynamic connector selection

Expand Down
3 changes: 2 additions & 1 deletion apps/docs/content/docs/5.api/2.reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,13 +364,14 @@ type ConnectGithubToolsOptions = GithubToolsOptions & {
}

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

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

`subject` is always `{ type: 'app' }`. See [Vercel Connect guide](/guide/vercel-connect#dynamic-connector-selection) for the dynamic connector example.
`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.

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

Expand Down
12 changes: 12 additions & 0 deletions apps/docs/skills/github-tools-agents/references/eve-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ export default githubExtension({

No separate `connectGithubTools` import needed. `connector` is a mount-config field.

`connect.subject` defaults to `{ type: 'app' }` (the project's GitHub App installation — one identity shared by every caller). For multi-user apps where each user connects their own GitHub account, pass a per-caller resolver so each caller gets their own connection token:

```ts
export default githubExtension({
connector: 'github/my-connector',
preset: 'issue-triage',
connect: {
subject: (ctx) => ({ type: 'user', id: ctx.session.auth.current!.principalId }),
},
})
```

## Docs

- `/frameworks/eve-extension`
Expand Down
2 changes: 1 addition & 1 deletion packages/github-tools-eve-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,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` | `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 |
| `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
30 changes: 27 additions & 3 deletions packages/github-tools-eve-extension/extension/extension.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { GithubTokenInput } from '@github-tools/sdk'
import type { GithubConnectorInput } from '@github-tools/sdk/connect'
import type { ConnectTokenSubject, GithubConnectorInput, GithubConnectParams } from '@github-tools/sdk/connect'
import type { ToolContext } from 'eve/tools'
import {
GITHUB_TOOL_NAMES,
GITHUB_WRITE_TOOLS,
Expand Down Expand Up @@ -30,6 +31,24 @@ export interface GithubExtensionContext {
ref?: string
}

/**
* Connect token subject for the extension: a static value, or a resolver
* called with the eve tool execution context on every tool call — e.g.
* `(ctx) => ({ type: 'user', id: ctx.session.auth.current!.principalId })`
* to mint each caller's own GitHub connection token in multi-user apps.
*/
export type GithubConnectSubjectInput =
| ConnectTokenSubject
| ((ctx: ToolContext) => ConnectTokenSubject | Promise<ConnectTokenSubject>)

/**
* Connect token params for the extension. Same as the SDK's
* `GithubConnectParams`, except `subject` may also be a per-caller resolver.
*/
export type GithubExtensionConnectParams = Omit<GithubConnectParams, 'subject'> & {
subject?: GithubConnectSubjectInput
}

/**
* 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 @@ -48,8 +67,13 @@ export interface GithubExtensionConfig {
* (e.g. per environment or tenant). Takes priority over `token`.
*/
connector?: GithubConnectorInput
/** Vercel Connect token params passed through to `getToken` when `connector` is set. */
connect?: Record<string, unknown>
/**
* Vercel Connect token params passed through to `getToken` when `connector`
* 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.
*/
connect?: GithubExtensionConnectParams
/** 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
38 changes: 27 additions & 11 deletions packages/github-tools-eve-extension/extension/tools/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
listEveToolDescriptors,
mapEveApprovalValue,
resolveEveApproval,
resolveGithubToken,
type EveApprovalConfig,
type EveApprovalValue,
type EveGithubToolsOptions,
Expand All @@ -15,7 +16,7 @@ import {
type GithubWriteToolName,
} from '@github-tools/sdk/eve-runtime'
import type { ApprovalContext } from 'eve/tools/approval'
import { defineDynamic, defineTool, type ToolDefinition } from 'eve/tools'
import { defineDynamic, defineTool, type ToolContext, type ToolDefinition } from 'eve/tools'
import extension from '../extension'

/**
Expand All @@ -25,7 +26,7 @@ import extension from '../extension'
* a spread or call expression is invisible to eve's stamp, and 0.44+ then
* drops the whole toolset.
*/
function buildSessionOptions(): EveGithubToolsOptions {
function buildSessionOptions(ctx?: ToolContext): EveGithubToolsOptions {
const {
token,
connector,
Expand All @@ -44,13 +45,21 @@ function buildSessionOptions(): 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.
const resolvedToken = connector
? connectGithubToken(connector, {
preset,
include: includeNames,
exclude: excludeNames,
params: connect,
})
? 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 }) },
}))
}
: token

return {
Expand Down Expand Up @@ -86,8 +95,15 @@ function writeToolName(name: GithubToolName): GithubWriteToolName | undefined {
return GITHUB_WRITE_TOOLS[name as keyof typeof GITHUB_WRITE_TOOLS]
}

async function runGithubEveTool(name: GithubToolName, input: unknown) {
return executeGithubEveTool(name, input as Record<string, unknown>, buildSessionOptions())
function requireToolContext(ctx: ToolContext | undefined): ToolContext {
if (!ctx) {
throw new Error('connect.subject resolver needs the tool execution context — it is only available while a tool call executes')
}
return ctx
}

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

function runGithubEveToModelOutput(name: GithubToolName, output: unknown) {
Expand Down Expand Up @@ -133,7 +149,7 @@ export default defineDynamic({
...(override?.outputSchema !== undefined && {
outputSchema: override.outputSchema,
}),
execute: async (input) => runGithubEveTool(name, input),
execute: async (input, ctx) => runGithubEveTool(name, input, ctx),
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/github-tools/src/connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export type {
ConnectGithubToolsOptions,
GithubConnectParams,
} from './types'
export type { ConnectTokenSubject } from '@vercel/connect'
4 changes: 2 additions & 2 deletions packages/github-tools/src/connect/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ function buildConnectTokenParams(
scopes: string[],
params?: GithubConnectParams,
): ConnectTokenParams {
const { repositories, ...rest } = params ?? {}
const { repositories, subject, ...rest } = params ?? {}

const authorizationDetails = rest.authorizationDetails
?? (repositories?.length
? [{ type: 'github_app_installation' as const, repositories }]
: undefined)

return {
subject: { type: 'app' },
subject: subject ?? { type: 'app' },
...rest,
scopes,
...(authorizationDetails && { authorizationDetails }),
Expand Down
8 changes: 4 additions & 4 deletions packages/github-tools/src/connect/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import { ALL_GITHUB_TOOL_NAMES, type GithubToolName } from '../core/tool-names'
*
* Gist tools in `repo-explorer` and `maintainer` are intentionally left
* unscoped: the Gists API only accepts GitHub App *user* access tokens, never
* installation tokens, and Connect always mints `subject: { type: 'app' }`
* installation tokens. Gist calls made with a Connect-derived token 403
* regardless of requested scopes — use a fine-grained PAT with the "Gists"
* account permission for those tools instead.
* installation tokens, and Connect mints `subject: { type: 'app' }`
* installation tokens by default. Gist calls made with an app-subject token
* 403 regardless of requested scopes — use a `{ type: 'user' }` subject or a
* fine-grained PAT with the "Gists" account permission for those tools instead.
*
* Notification tools in `maintainer` and `notification-inbox` are unscoped for
* the same reason: `notifications` is an account-level GitHub App permission
Expand Down
16 changes: 16 additions & 0 deletions packages/github-tools/src/connect/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ describe('connectGithubToken', () => {
}, undefined)
})

it('passes an explicit user subject through instead of the app default', async () => {
const resolve = resolveConnectToken('github/my-connector', {
preset: 'issue-triage',
params: { subject: { type: 'user', id: 'user_123', issuer: 'https://auth.example.com' } },
})

await resolve()
expect(getToken).toHaveBeenCalledWith(
'github/my-connector',
expect.objectContaining({
subject: { type: 'user', id: 'user_123', issuer: 'https://auth.example.com' },
}),
undefined,
)
})

it('maps repositories to github_app_installation authorization details', async () => {
const resolve = resolveConnectToken('github/my-connector', {
preset: 'issue-triage',
Expand Down
11 changes: 10 additions & 1 deletion packages/github-tools/src/connect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@ import type { EveGithubToolsOptions } from '../eve/types'

/**
* Token parameters for Vercel Connect GitHub connectors.
* `subject` is pinned to `{ type: 'app' }` by the SDK — same as `connectGitHubAdapter`.
* `subject` defaults to `{ type: 'app' }` — the project's GitHub App
* installation, same as `connectGitHubAdapter`.
*/
export type GithubConnectParams = Omit<ConnectTokenParams, 'subject'> & {
/**
* Connect token subject. Defaults to `{ type: 'app' }` (the project's GitHub
* App installation — one identity shared by every caller). Pass
* `{ type: 'user', id }` to mint a token for that user's own GitHub
* connection instead, e.g. in multi-user apps where each user connects
* their account from an integrations panel.
*/
subject?: ConnectTokenParams['subject']
/** Restrict the token to specific repositories via GitHub authorization details. */
repositories?: string[]
}
Expand Down
1 change: 1 addition & 0 deletions packages/github-tools/src/eve-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
hasGithubEveToolModelOutput,
} from './eve/build'
export { mapEveApprovalValue, resolveEveApproval, resolveEveToolApproval, isEveApprovalDisabled } from './eve/approval'
export { resolveGithubToken } from './core/token'
export type {
EveApprovalConfig,
EveApprovalValue,
Expand Down
Loading