From 6c1b4d5e72286a4dbfd88dae81bd1a6617cfecdd Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Mon, 31 Aug 2026 15:49:04 +0100 Subject: [PATCH] feat(sdk): support per-user Vercel Connect subjects --- .changeset/connect-per-user-subject.md | 6 +++ .../docs/2.frameworks/1.eve-extension.md | 24 +++++++++++- .../content/docs/4.guide/5.vercel-connect.md | 19 +++++++++- apps/docs/content/docs/5.api/2.reference.md | 3 +- .../references/eve-extension.md | 12 ++++++ packages/github-tools-eve-extension/README.md | 2 +- .../extension/extension.ts | 30 +++++++++++++-- .../extension/tools/github.ts | 38 +++++++++++++------ packages/github-tools/src/connect/index.ts | 1 + packages/github-tools/src/connect/params.ts | 4 +- packages/github-tools/src/connect/scopes.ts | 8 ++-- .../github-tools/src/connect/token.test.ts | 16 ++++++++ packages/github-tools/src/connect/types.ts | 11 +++++- packages/github-tools/src/eve-runtime.ts | 1 + 14 files changed, 150 insertions(+), 25 deletions(-) create mode 100644 .changeset/connect-per-user-subject.md diff --git a/.changeset/connect-per-user-subject.md b/.changeset/connect-per-user-subject.md new file mode 100644 index 0000000..4f837f2 --- /dev/null +++ b/.changeset/connect-per-user-subject.md @@ -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. 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 0698168..cf65203 100644 --- a/apps/docs/content/docs/2.frameworks/1.eve-extension.md +++ b/apps/docs/content/docs/2.frameworks/1.eve-extension.md @@ -186,7 +186,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?` | 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 | @@ -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` 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: 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 c08b088..e1c1a16 100644 --- a/apps/docs/content/docs/4.guide/5.vercel-connect.md +++ b/apps/docs/content/docs/4.guide/5.vercel-connect.md @@ -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 diff --git a/apps/docs/content/docs/5.api/2.reference.md b/apps/docs/content/docs/5.api/2.reference.md index 2763938..9be374c 100644 --- a/apps/docs/content/docs/5.api/2.reference.md +++ b/apps/docs/content/docs/5.api/2.reference.md @@ -364,13 +364,14 @@ type ConnectGithubToolsOptions = GithubToolsOptions & { } type GithubConnectParams = Omit & { + subject?: ConnectTokenSubject repositories?: string[] } type GithubConnectorInput = string | (() => string | Promise) ``` -`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) 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 aefbb0c..7d61886 100644 --- a/apps/docs/skills/github-tools-agents/references/eve-extension.md +++ b/apps/docs/skills/github-tools-agents/references/eve-extension.md @@ -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` diff --git a/packages/github-tools-eve-extension/README.md b/packages/github-tools-eve-extension/README.md index 3138b58..6682e1e 100644 --- a/packages/github-tools-eve-extension/README.md +++ b/packages/github-tools-eve-extension/README.md @@ -90,7 +90,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?` | 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 | diff --git a/packages/github-tools-eve-extension/extension/extension.ts b/packages/github-tools-eve-extension/extension/extension.ts index 3de83a4..63ba887 100644 --- a/packages/github-tools-eve-extension/extension/extension.ts +++ b/packages/github-tools-eve-extension/extension/extension.ts @@ -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, @@ -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) + +/** + * 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 & { + 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. @@ -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 + /** + * 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[] /** diff --git a/packages/github-tools-eve-extension/extension/tools/github.ts b/packages/github-tools-eve-extension/extension/tools/github.ts index aadcaea..a87cb4b 100644 --- a/packages/github-tools-eve-extension/extension/tools/github.ts +++ b/packages/github-tools-eve-extension/extension/tools/github.ts @@ -7,6 +7,7 @@ import { listEveToolDescriptors, mapEveApprovalValue, resolveEveApproval, + resolveGithubToken, type EveApprovalConfig, type EveApprovalValue, type EveGithubToolsOptions, @@ -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' /** @@ -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, @@ -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 { @@ -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, 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, buildSessionOptions(ctx)) } function runGithubEveToModelOutput(name: GithubToolName, output: unknown) { @@ -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), }) } diff --git a/packages/github-tools/src/connect/index.ts b/packages/github-tools/src/connect/index.ts index 2c9aee3..9e21a18 100644 --- a/packages/github-tools/src/connect/index.ts +++ b/packages/github-tools/src/connect/index.ts @@ -16,3 +16,4 @@ export type { ConnectGithubToolsOptions, GithubConnectParams, } from './types' +export type { ConnectTokenSubject } from '@vercel/connect' diff --git a/packages/github-tools/src/connect/params.ts b/packages/github-tools/src/connect/params.ts index 675e406..d08baea 100644 --- a/packages/github-tools/src/connect/params.ts +++ b/packages/github-tools/src/connect/params.ts @@ -14,7 +14,7 @@ function buildConnectTokenParams( scopes: string[], params?: GithubConnectParams, ): ConnectTokenParams { - const { repositories, ...rest } = params ?? {} + const { repositories, subject, ...rest } = params ?? {} const authorizationDetails = rest.authorizationDetails ?? (repositories?.length @@ -22,7 +22,7 @@ function buildConnectTokenParams( : undefined) return { - subject: { type: 'app' }, + subject: subject ?? { type: 'app' }, ...rest, scopes, ...(authorizationDetails && { authorizationDetails }), diff --git a/packages/github-tools/src/connect/scopes.ts b/packages/github-tools/src/connect/scopes.ts index f03b0ca..e42e5f2 100644 --- a/packages/github-tools/src/connect/scopes.ts +++ b/packages/github-tools/src/connect/scopes.ts @@ -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 diff --git a/packages/github-tools/src/connect/token.test.ts b/packages/github-tools/src/connect/token.test.ts index 333a8a3..cc91e0b 100644 --- a/packages/github-tools/src/connect/token.test.ts +++ b/packages/github-tools/src/connect/token.test.ts @@ -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', diff --git a/packages/github-tools/src/connect/types.ts b/packages/github-tools/src/connect/types.ts index 59737c7..d4584d5 100644 --- a/packages/github-tools/src/connect/types.ts +++ b/packages/github-tools/src/connect/types.ts @@ -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 & { + /** + * 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[] } diff --git a/packages/github-tools/src/eve-runtime.ts b/packages/github-tools/src/eve-runtime.ts index 5e4364b..8936678 100644 --- a/packages/github-tools/src/eve-runtime.ts +++ b/packages/github-tools/src/eve-runtime.ts @@ -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,